Skip to content

Commit e7940da

Browse files
committed
fix(pause): preserve sandbox when snapshot fails, behind a flag
A pause whose snapshot failed after the guest was suspended (e.g. a rootfs-diff fsync EIO, or a memfd ENOMEM under host memory/hugepage pressure) destroyed the sandbox: Server.Pause armed an unconditional deferred stop before snapshotting, so the error path tore down the already-suspended VM, and the API had already removed the routing entry and store record. The sandbox was left unrecoverable with no snapshot (#3658). Behind PauseRefusalRestoreFlag (off by default, so today's destroy-on-failure behaviour is unchanged), keep the sandbox recoverable: - sandbox: decouple the resume-on-error cleanup from WithMaintainSandbox via a new WithResumeOnFailure() option. It arms the same resume-in-place cleanup (health checks restarted, guest clock re-synced) that the in-place checkpoint uses, but only on failure - a successful pause still suspends and leaves the VM for the caller to stop. - Server.Pause: pass resumeOnFailure when the flag is on, and arm the deferred stop only after the snapshot succeeds. On snapshot error the guest has been resumed in place, so re-register it in the live map (MarkRunning) and return FailedPrecondition with a "sandbox preserved" marker. ErrSandboxLost (the resume itself failed) falls through to the destroy path and returns Internal unchanged. - api: classify that FailedPrecondition+marker as ErrPausePreservedSandbox (pause_instance.go) and map it in DeleteInstance to the existing restoreRefusedPause path, restoring the store record and route instead of removing them. Tests: TestSnapshotInstance_PreservedSandboxIsClassified and _PlainFailedPreconditionNotPreserved cover the API classification (a plain FailedPrecondition is not misclassified). All three packages build; the full internal/orchestrator suite passes. End-to-end validation with the flag enabled against real Firecracker (inject a snapshot fault, assert the sandbox survives and stays routable) requires a KVM host and is a follow-up. Addresses the pause half of #3658 (checkpoint half: #3666). Signed-off-by: adababys <8872412+adababys@users.noreply.github.com>
1 parent 132dadd commit e7940da

6 files changed

Lines changed: 168 additions & 11 deletions

File tree

‎packages/api/internal/orchestrator/delete_instance.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,45 @@ func (o *Orchestrator) RemoveSandbox(ctx context.Context, teamID uuid.UUID, sand
182182
return PauseQueueExhaustedError{}
183183
}
184184

185+
// The node's snapshot failed but it resumed the sandbox in place
186+
// (e2b-dev/infra#3658). The VM is alive and back in the node's live map,
187+
// so restore the store record and route — exactly like a retryable
188+
// refusal — instead of removing them and orphaning a healthy sandbox.
189+
// Gated by the same restoreOnRefusal flag the node used to decide to
190+
// preserve; if it is off the node would have taken the destroy path and
191+
// never returned this error.
192+
if errors.Is(err, ErrPausePreservedSandbox) {
193+
if restoreOnRefusal {
194+
outcome := o.restoreRefusedPause(context.WithoutCancel(ctx), transition)
195+
o.recordRefusalRestore(ctx, outcome, opts.Eviction)
196+
switch outcome {
197+
case restoreOutcomeRestored:
198+
preserveRecord = true
199+
err = sandbox.ErrTransitionRestored
200+
case restoreOutcomeSuperseded:
201+
preserveRecord = true
202+
err = sandbox.ErrTransitionRestored
203+
204+
return fmt.Errorf("%w: %w", ErrSandboxNotFound, sandbox.ErrExecutionMismatch)
205+
}
206+
}
207+
208+
logger.L().Info(ctx, "Pause snapshot failed but the node preserved the sandbox",
209+
logger.WithSandboxID(sbx.SandboxID),
210+
zap.Bool("restored", preserveRecord),
211+
)
212+
213+
if !preserveRecord {
214+
// The sandbox is alive on the node but we could not restore its
215+
// record/route, so it would be an unrouteable orphan: kill it.
216+
o.killRefusedSandbox(ctx, sbx)
217+
218+
return ErrSandboxOperationFailed
219+
}
220+
221+
return nil
222+
}
223+
185224
if errors.Is(err, ErrRefusedRouteLost) {
186225
// The record is going and the route is already gone: kill the VM
187226
// now rather than leaving it to the orphan reconciler.

‎packages/api/internal/orchestrator/errors.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@ var (
99
// ErrRefusedRouteLost: the node refused the pause retryably but the edge
1010
// could not put the sandbox's route back, so the sandbox cannot be kept.
1111
ErrRefusedRouteLost = errors.New("pause refused by the node and the edge could not restore its route")
12+
// ErrPausePreservedSandbox: the node's snapshot failed but it resumed the
13+
// sandbox in place instead of destroying it (e2b-dev/infra#3658). Handled
14+
// like a retryable refusal: the store record and route are restored so the
15+
// still-healthy sandbox stays usable, rather than removed.
16+
ErrPausePreservedSandbox = errors.New("pause snapshot failed but the node preserved the sandbox")
1217
)

‎packages/api/internal/orchestrator/pause_instance.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"strings"
78

89
"github.com/gogo/status"
910
"github.com/google/uuid"
@@ -118,6 +119,17 @@ func snapshotInstance(ctx context.Context, node *nodemanager.Node, sbx sandbox.S
118119
return ErrRefusedRouteLost
119120
}
120121

122+
// The node's snapshot failed but it resumed the sandbox in place instead of
123+
// destroying it (e2b-dev/infra#3658). The orchestrator signals this with
124+
// FailedPrecondition and a stable "sandbox preserved" marker in the message.
125+
// Classify it so DeleteInstance restores the record + route rather than
126+
// removing them, keeping the still-healthy sandbox usable.
127+
if st.Code() == codes.FailedPrecondition && strings.Contains(st.Message(), "sandbox preserved after snapshot failure") {
128+
logger.L().Warn(ctx, "Pause snapshot failed but the node preserved the sandbox", logger.WithSandboxID(sbx.SandboxID), zap.String("node_message", st.Message()))
129+
130+
return ErrPausePreservedSandbox
131+
}
132+
121133
return fmt.Errorf("failed to pause sandbox '%s': %w", sbx.SandboxID, err)
122134
}
123135

‎packages/api/internal/orchestrator/pause_instance_failure_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,38 @@ func TestPauseSandbox_FailsBuildWhenPauseQueueExhausted(t *testing.T) {
128128
assert.Equal(t, string(types.BuildStatusFailed), buildStatus)
129129
assert.True(t, hasFinishedAt, "a terminal build must record finished_at")
130130
}
131+
132+
// A FailedPrecondition carrying the "sandbox preserved after snapshot failure"
133+
// marker (emitted by the node when WithResumeOnFailure resumed the VM instead
134+
// of destroying it, e2b-dev/infra#3658) must be classified as
135+
// ErrPausePreservedSandbox so DeleteInstance restores the record + route.
136+
func TestSnapshotInstance_PreservedSandboxIsClassified(t *testing.T) {
137+
t.Parallel()
138+
139+
node := nodemanager.NewTestNode("node-preserved", api.NodeStatusReady, 0, 8)
140+
node.SetSandboxClient(&pauseFailingSandboxClient{
141+
err: status.Error(codes.FailedPrecondition, "sandbox preserved after snapshot failure for 'sbx-x': fsync: input/output error"),
142+
})
143+
144+
sbx := sandbox.Sandbox{SandboxID: "sbx-x", ClusterID: consts.LocalClusterID}
145+
146+
err := snapshotInstance(t.Context(), node, sbx, "tmpl", "build", false, true)
147+
require.ErrorIs(t, err, ErrPausePreservedSandbox)
148+
}
149+
150+
// A plain FailedPrecondition WITHOUT the preserved marker (e.g. an envd-version
151+
// precondition) must NOT be misclassified as a preserved sandbox.
152+
func TestSnapshotInstance_PlainFailedPreconditionNotPreserved(t *testing.T) {
153+
t.Parallel()
154+
155+
node := nodemanager.NewTestNode("node-plain-fp", api.NodeStatusReady, 0, 8)
156+
node.SetSandboxClient(&pauseFailingSandboxClient{
157+
err: status.Error(codes.FailedPrecondition, "envd version too old"),
158+
})
159+
160+
sbx := sandbox.Sandbox{SandboxID: "sbx-y", ClusterID: consts.LocalClusterID}
161+
162+
err := snapshotInstance(t.Context(), node, sbx, "tmpl", "build", false, true)
163+
require.Error(t, err)
164+
require.NotErrorIs(t, err, ErrPausePreservedSandbox)
165+
}

‎packages/orchestrator/pkg/sandbox/sandbox.go‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1915,6 +1915,7 @@ type pauseOptions struct {
19151915
filesystemSnapshot bool
19161916
deferRootfsExport bool
19171917
maintainSandbox bool
1918+
resumeOnFailure bool
19181919
}
19191920

19201921
type PauseOption func(*pauseOptions)
@@ -1928,6 +1929,22 @@ func WithMaintainSandbox() PauseOption {
19281929
return func(o *pauseOptions) { o.maintainSandbox = true }
19291930
}
19301931

1932+
// WithResumeOnFailure keeps the sandbox recoverable across a FAILED pause: if
1933+
// the snapshot fails after the guest has been suspended, the VM is resumed in
1934+
// place (health checks restarted, guest clock re-synced) instead of being left
1935+
// frozen for the caller to tear down, so a transient snapshot error (e.g. a
1936+
// rootfs-diff fsync EIO) no longer destroys an otherwise-healthy sandbox (see
1937+
// e2b-dev/infra#3658).
1938+
//
1939+
// Unlike WithMaintainSandbox, this does NOT resume on success: a successful
1940+
// pause still suspends the guest and leaves it for the caller to stop. It only
1941+
// arms the same resume-on-error cleanup that the in-place checkpoint uses, for
1942+
// the destroy path. The two compose: maintainSandbox implies resume on every
1943+
// outcome; resumeOnFailure alone resumes only on failure.
1944+
func WithResumeOnFailure() PauseOption {
1945+
return func(o *pauseOptions) { o.resumeOnFailure = true }
1946+
}
1947+
19311948
// WithFilesystemSnapshot makes the pause produce a filesystem-only snapshot:
19321949
// guest memory is not snapshotted, only the filesystem (rootfs) is persisted.
19331950
// Resuming such a snapshot reboots the guest instead of restoring memory state.
@@ -2130,8 +2147,13 @@ func (s *Sandbox) Pause(
21302147
// assignment).
21312148
memExportDeferred := false
21322149
var freezeStart time.Time
2133-
resumeOnError := pauseOpts.maintainSandbox
2134-
if pauseOpts.maintainSandbox {
2150+
// resumeOnError arms the resume-in-place cleanup below for BOTH the in-place
2151+
// checkpoint (maintainSandbox: resume on every outcome) and the recoverable
2152+
// destroy path (resumeOnFailure: resume only when the pause fails). The
2153+
// cleanup runs only on the error path (see the top-level deferred cleanup.Run
2154+
// guarded by e != nil), so a successful pause never resumes here regardless.
2155+
resumeOnError := pauseOpts.maintainSandbox || pauseOpts.resumeOnFailure
2156+
if resumeOnError {
21352157
cleanup.Add(ctx, func(ctx context.Context) error {
21362158
if !resumeOnError {
21372159
return nil
@@ -2186,14 +2208,17 @@ func (s *Sandbox) Pause(
21862208
}
21872209

21882210
freezeStart = time.Now()
2189-
if pauseOpts.maintainSandbox {
2211+
if resumeOnError {
21902212
// The pause PATCH is the one state flip whose failure is AMBIGUOUS: a
21912213
// request-ctx cancellation (client disconnect) can kill the round-trip
21922214
// after FC already applied it. So it runs immune to request
21932215
// cancellation under the same state-flip bound as the resume, and the
21942216
// cleanup above resumes on EVERY outcome (see the pre-arm rule at its
21952217
// registration). pauseLanded — the metric/clock gate — is set only on
21962218
// a successful return, the one case the guest is KNOWN to have frozen.
2219+
// Both the in-place checkpoint and the recoverable destroy path
2220+
// (resumeOnFailure) need this: each arms a resume that must be able to
2221+
// unfreeze the guest even if the caller's context died.
21972222
pauseCtx, cancelPause := context.WithTimeout(context.WithoutCancel(ctx), inPlaceStateFlipTimeout)
21982223
err := s.process.Pause(pauseCtx)
21992224
cancelPause()
@@ -2202,9 +2227,8 @@ func (s *Sandbox) Pause(
22022227
}
22032228
pauseLanded = true
22042229
} else {
2205-
// Destroy path: no resume cleanup exists (resumeOnError is false), so
2206-
// the ambiguity above has no consumer; keep the plain request-scoped
2207-
// call.
2230+
// Plain destroy path with no resume cleanup: the ambiguity above has no
2231+
// consumer, so keep the plain request-scoped call.
22082232
if err := s.process.Pause(ctx); err != nil {
22092233
return nil, fmt.Errorf("failed to pause VM: %w", err)
22102234
}

‎packages/orchestrator/pkg/server/sandboxes.go‎

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -997,22 +997,59 @@ func (s *Server) Pause(ctx context.Context, in *orchestrator.SandboxPauseRequest
997997
// guest and can close the sandbox, which would read as a crash.
998998
sbx.SetStopReason(sandbox.StopReasonPaused)
999999

1000-
// Stop the old sandbox in background after we're done
1001-
defer s.stopSandboxAsync(context.WithoutCancel(ctx), sbx)
1000+
// When enabled, a snapshot that fails AFTER the guest was suspended (e.g. a
1001+
// rootfs-diff fsync EIO or a memfd ENOMEM) resumes the VM in place instead
1002+
// of leaving it frozen for the deferred stop to tear down, so a transient
1003+
// snapshot error no longer destroys an otherwise-healthy sandbox
1004+
// (e2b-dev/infra#3658). Gated by the same flag as pause-refusal restore:
1005+
// both keep the sandbox recoverable when a pause could not be persisted, and
1006+
// the API path that restores the store record + route already keys off it.
1007+
// Off by default preserves today's destroy-on-failure behaviour.
1008+
preserveOnFailure := s.featureFlags.BoolFlag(ctx, featureflags.PauseRefusalRestoreFlag)
10021009

10031010
// Defer the rootfs reflink off the pause critical path when enabled: pause is a
10041011
// suspend, so nothing reads the diff until a later resume (which waits on the
10051012
// upload anyway). NBD provider only; falls back to synchronous export otherwise.
10061013
deferRootfsExport := s.featureFlags.BoolFlag(ctx, featureflags.DeferRootfsExportFlag)
10071014

10081015
// Fire and forget - upload completes in the background
1009-
res, err := s.snapshotAndCacheSandbox(ctx, sbx, in.GetBuildId(), map[string]string{storage.ObjectMetadataTemplateID: in.GetTemplateId()}, storage.ObjectOriginPause, in.GetFilesystemOnly(), deferRootfsExport, false)
1016+
res, err := s.snapshotAndCacheSandbox(ctx, sbx, in.GetBuildId(), map[string]string{storage.ObjectMetadataTemplateID: in.GetTemplateId()}, storage.ObjectOriginPause, in.GetFilesystemOnly(), deferRootfsExport, false, preserveOnFailure)
10101017
if err != nil {
10111018
telemetry.ReportCriticalError(ctx, "error snapshotting sandbox", err, telemetry.WithSandboxID(in.GetSandboxId()))
10121019

1020+
// With preserveOnFailure, sbx.Pause (WithResumeOnFailure) resumed the
1021+
// guest in place on any error short of ErrSandboxLost, so the VM is
1022+
// still alive. Put it back in the live map — MarkStopping removed it
1023+
// before the snapshot — and tell the API the sandbox was preserved via
1024+
// FailedPrecondition, so it restores the store record and route instead
1025+
// of removing them. ErrSandboxLost means the resume itself failed and
1026+
// Pause already tore the VM down: fall through to the destroy path.
1027+
if preserveOnFailure && !errors.Is(err, sandbox.ErrSandboxLost) {
1028+
if markErr := s.sandboxFactory.Sandboxes.MarkRunning(ctx, sbx); markErr != nil {
1029+
// Could not re-register the resumed VM; it would be an
1030+
// unrouteable orphan. Stop it and report the original error.
1031+
sbxlogger.E(sbx).Error(ctx, "failed to restore resumed sandbox to live map after snapshot failure", zap.Error(markErr))
1032+
sbx.SetStopReason(sandbox.StopReasonKilled)
1033+
s.stopSandboxAsync(context.WithoutCancel(ctx), sbx)
1034+
1035+
return nil, status.Errorf(codes.Internal, "error snapshotting sandbox '%s': %s", in.GetSandboxId(), err)
1036+
}
1037+
1038+
return nil, status.Errorf(codes.FailedPrecondition, "sandbox preserved after snapshot failure for '%s': %s", in.GetSandboxId(), err)
1039+
}
1040+
1041+
// Default (flag off) or the resume itself failed (ErrSandboxLost): the
1042+
// VM is frozen or already gone, so stop it as before.
1043+
s.stopSandboxAsync(context.WithoutCancel(ctx), sbx)
1044+
10131045
return nil, status.Errorf(codes.Internal, "error snapshotting sandbox '%s': %s", in.GetSandboxId(), err)
10141046
}
10151047

1048+
// Snapshot succeeded: stop the old sandbox in background after we're done.
1049+
// Armed here rather than before the snapshot so a preserved failure above
1050+
// does not also stop the sandbox it just resumed.
1051+
defer s.stopSandboxAsync(context.WithoutCancel(ctx), sbx)
1052+
10161053
s.uploadSnapshotAsync(ctx, sbx, res)
10171054

10181055
// Best-effort: the local snapshot is now in the cache and the remote upload
@@ -1283,7 +1320,8 @@ func (s *Server) checkpointInPlace(ctx context.Context, sbx *sandbox.Sandbox, in
12831320
storage.ObjectOriginSnapshotTemplate,
12841321
false, // filesystemOnly: full-memory checkpoint (fs-only in-place is a follow-up)
12851322
deferRootfsExport,
1286-
true, // maintainSandbox: resume in place
1323+
true, // maintainSandbox: resume in place
1324+
false, // resumeOnFailure: maintainSandbox already resumes on every outcome
12871325
)
12881326
if err != nil {
12891327
telemetry.ReportCriticalError(ctx, "error snapshotting sandbox for checkpoint", err, telemetry.WithSandboxID(in.GetSandboxId()))
@@ -1368,7 +1406,7 @@ func (s *Server) checkpointResumeFresh(ctx context.Context, sbx *sandbox.Sandbox
13681406
// Checkpoint resumes a fresh sandbox from the new build immediately, so the
13691407
// diff must be materialized synchronously — never defer the rootfs export
13701408
// here, and never maintain the paused sandbox.
1371-
res, err := s.snapshotAndCacheSandbox(ctx, sbx, in.GetBuildId(), in.GetMetadata(), storage.ObjectOriginSnapshotTemplate, false, false, false)
1409+
res, err := s.snapshotAndCacheSandbox(ctx, sbx, in.GetBuildId(), in.GetMetadata(), storage.ObjectOriginSnapshotTemplate, false, false, false, false)
13721410
if err != nil {
13731411
telemetry.ReportCriticalError(ctx, "error snapshotting sandbox for checkpoint", err, telemetry.WithSandboxID(in.GetSandboxId()))
13741412

@@ -1552,6 +1590,7 @@ func (s *Server) snapshotAndCacheSandbox(
15521590
filesystemOnly bool,
15531591
deferRootfsExport bool,
15541592
maintainSandbox bool,
1593+
resumeOnFailure bool,
15551594
) (*snapshotResult, error) {
15561595
meta, err := sbx.Template.Metadata()
15571596
if err != nil {
@@ -1574,6 +1613,9 @@ func (s *Server) snapshotAndCacheSandbox(
15741613
if maintainSandbox {
15751614
pauseOpts = append(pauseOpts, sandbox.WithMaintainSandbox())
15761615
}
1616+
if resumeOnFailure {
1617+
pauseOpts = append(pauseOpts, sandbox.WithResumeOnFailure())
1618+
}
15771619

15781620
snapshot, err := sbx.Pause(ctx, meta, sandbox.SnapshotUseCasePause, pauseOpts...)
15791621
if err != nil {

0 commit comments

Comments
 (0)