Skip to content

fix(api): don't kill sandboxes when snapshot requests are cancelled mid-flight#3302

Draft
mishushakov wants to merge 1 commit into
mainfrom
sandbox-fork-dropped-request
Draft

fix(api): don't kill sandboxes when snapshot requests are cancelled mid-flight#3302
mishushakov wants to merge 1 commit into
mainfrom
sandbox-fork-dropped-request

Conversation

@mishushakov

Copy link
Copy Markdown
Member

Description

CheckpointSandbox (fork), CreateSnapshotTemplate, and RemoveSandbox (pause/kill) ran on the client-cancellable request context past their commit point. A client disconnect — e.g. a fork request dropped mid-flight — cancelled the in-flight snapshot RPC, and the node killed the sandbox; for pause this meant losing the sandbox without a persisted snapshot.

This detaches each operation from caller cancellation (context.WithoutCancel) once its state transition is committed, so a started snapshot always runs to completion and the sandbox survives a disconnect. It also marks the snapshot build failed when the post-checkpoint status update fails (previously it dangled in Snapshotting forever) and removes the now-redundant per-call WithoutCancel workarounds.

Timeouts that fire before the pause starts (queue acquire, precondition checks) still safely restore the sandbox to Running.

🤖 Generated with Claude Code

…id-flight

Checkpoint, snapshot-template, and remove/pause operations ran on the
client-cancellable request context past their commit point, so a client
disconnect (e.g. mid-fork) aborted the snapshot on the node and the
sandbox was killed — for pause, without a persisted snapshot.

Detach from caller cancellation once the state transition is committed
so a started snapshot always runs to completion, and mark the snapshot
build failed when the post-checkpoint status update fails instead of
leaving it dangling in Snapshotting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 17, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes sandbox lifecycle and snapshot completion behavior on disconnect; mistakes could leave VMs running without store entries or alter kill/pause outcomes, though the scope is limited to orchestrator context handling and build status cleanup.

Overview
Committed snapshot and removal work now runs on a context that ignores client disconnect, so in-flight checkpoint, snapshot-template, and pause/kill flows finish instead of aborting and killing sandboxes mid-snapshot. When the VM checkpoint succeeds but persisting build status fails, the snapshot build is marked failed so it does not stay stuck in Snapshotting.

Reviewed by Cursor Bugbot for commit ccb6d3e. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
3415 2 3413 7
View the top 2 failed test(s) by shortest run time
github.com/e2b-dev/infra/tests/integration/internal/tests/api/templates::TestTemplateBuildRUN
Stack Traces | 0s run time
=== RUN   TestTemplateBuildRUN
=== PAUSE TestTemplateBuildRUN
=== CONT  TestTemplateBuildRUN
--- FAIL: TestTemplateBuildRUN (0.00s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/templates::TestTemplateBuildRUN/Single_RUN_command
Stack Traces | 135s run time
=== RUN   TestTemplateBuildRUN/Single_RUN_command
=== PAUSE TestTemplateBuildRUN/Single_RUN_command
=== CONT  TestTemplateBuildRUN/Single_RUN_command
    build_template_test.go:133: test-ubuntu-run: [info] Building template pl1k0tdfpt6ms4e13qf7/c219a150-3e86-465f-8421-d19cac6b8064
    build_template_test.go:133: test-ubuntu-run: [info] [base] FROM ubuntu:22.04 [f9f564014e009a9561a82bf8c84f9314242971e833fb019936654ecba452f184]
    build_template_test.go:133: test-ubuntu-run: [info] Base Docker image size: 30 MB
    build_template_test.go:133: test-ubuntu-run: [info] Creating file system and pulling Docker image
    build_template_test.go:133: test-ubuntu-run: [info] Uncompressing layer sha256:d6834b4a794c03efa2c998853e64969fa8851b11b2ade63292268872a37759d0 30 MB
    build_template_test.go:133: test-ubuntu-run: [info] Uncompressing layer sha256:2cf796442148929c5e3538fa5ecbd10587c7b0493dcc4e0edcc23aac64504ccf 13 MB
    build_template_test.go:133: test-ubuntu-run: [info] Uncompressing layer sha256:8c4b1b28875140ed3abacaf16ad0d696f6bef912f52d2148f261a23e3349465b 168 B
    build_template_test.go:133: test-ubuntu-run: [info] Layers extracted
    build_template_test.go:133: test-ubuntu-run: [info] Root filesystem structure: bin, boot, dev, etc, home, lib, lib32, lib64, libx32, media, mnt, opt, proc, root, run, sbin, srv, sys, tmp, usr, var
    build_template_test.go:133: test-ubuntu-run: [info] Provisioning sandbox template
    build_template_test.go:133: test-ubuntu-run: [info] Provisioning was successful, cleaning up
    build_template_test.go:133: test-ubuntu-run: [info] Sandbox template provisioned
    build_template_test.go:133: test-ubuntu-run: [info] [base] DEFAULT USER user [49e586c2171254c6bc4a09e84eedac32dbcf113a158c24248129af2f49cbed74]
    build_template_test.go:133: test-ubuntu-run: [info] [builder 1/1] RUN echo 'Hello, World!' [c72b4f813c2a16b0fc1a1c5da7b1365a304cbac516b22dc304a71f70aae48ac0]
    build_template_test.go:133: test-ubuntu-run: [info] [builder 1/1] [stdout]: Hello, World!
    build_template_test.go:133: test-ubuntu-run: [info] [finalize] Finalizing template build [92c524e30533398ebb41ce04c2596130f0cdecc9aa328e28fdb16a1b11f61d62]
    build_template_test.go:133: test-ubuntu-run: [error] Build failed: An internal error occurred. Please try again or contact support with the build ID.
    build_template_test.go:166: Build failed: {<nil> An internal error occurred. Please try again or contact support with the build ID. <nil>}
--- FAIL: TestTemplateBuildRUN/Single_RUN_command (134.85s)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request detaches orchestrator operations (checkpointing, snapshotting, and sandbox removal) from the caller's cancellation context once committed, ensuring they run to completion. It also adds logic to fail snapshot builds if status updates fail. The review feedback highlights a critical compilation error in the failSnapshotBuild helper method and recommends wrapping the detached contexts with safety timeouts to prevent the orchestrator from hanging indefinitely if a node or database becomes unresponsive.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

// The sandbox itself resumed fine (finish(nil) restores it to Running),
// but without a success status the snapshot is unusable: mark the build
// failed so it doesn't dangle in Snapshotting forever.
o.failSnapshotBuild(ctx, upsertResult.BuildID, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The helper method failSnapshotBuild (defined in snapshot_template.go at line 143) contains a compilation error at line 146: FinishedAt: new(time.Now()). In Go, new() takes a type argument, not a value expression. This will cause the build to fail.

Please fix failSnapshotBuild in snapshot_template.go by passing a pointer to a local time.Time variable instead:

func (o *Orchestrator) failSnapshotBuild(ctx context.Context, buildID uuid.UUID, cause error) {
	now := time.Now()
	err := o.sqlcDB.UpdateEnvBuildStatus(ctx, queries.UpdateEnvBuildStatusParams{
		Status:     types.BuildStatusFailed,
		FinishedAt: &now,
		Reason:     types.BuildReason{Message: cause.Error()},
		BuildID:    buildID,
	})
	if err != nil {
		telemetry.ReportError(ctx, "error failing build", err)
	}
}

Comment on lines +47 to +51
// Past this point the checkpoint is committed: the sandbox will be paused
// on its node, and cancelling the checkpoint mid-snapshot kills it. Detach
// from the caller's cancellation (e.g. client disconnect mid-fork) so the
// checkpoint always runs to completion once started.
ctx = context.WithoutCancel(ctx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Detaching the context from the caller's cancellation using context.WithoutCancel also strips any associated deadlines or timeouts. Without a safety timeout, the subsequent gRPC and database calls could hang indefinitely if the node or network becomes unresponsive. It is highly recommended to wrap the detached context with a reasonable safety timeout.

Suggested change
// Past this point the checkpoint is committed: the sandbox will be paused
// on its node, and cancelling the checkpoint mid-snapshot kills it. Detach
// from the caller's cancellation (e.g. client disconnect mid-fork) so the
// checkpoint always runs to completion once started.
ctx = context.WithoutCancel(ctx)
// Past this point the checkpoint is committed: the sandbox will be paused
// on its node, and cancelling the checkpoint mid-snapshot kills it. Detach
// from the caller's cancellation (e.g. client disconnect mid-fork) so the
// checkpoint always runs to completion once started.
// Use a safety timeout to prevent the orchestrator from hanging indefinitely if the node is unresponsive.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
defer cancel()

Comment on lines +55 to +59
// Past this point the snapshot is committed: the sandbox will be paused
// on its node, and cancelling the checkpoint mid-snapshot kills it. Detach
// from the caller's cancellation (e.g. client disconnect) so the snapshot
// always runs to completion once started.
ctx = context.WithoutCancel(ctx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Detaching the context from the caller's cancellation using context.WithoutCancel also strips any associated deadlines or timeouts. Without a safety timeout, the subsequent gRPC and database calls could hang indefinitely if the node or network becomes unresponsive. It is highly recommended to wrap the detached context with a reasonable safety timeout.

Suggested change
// Past this point the snapshot is committed: the sandbox will be paused
// on its node, and cancelling the checkpoint mid-snapshot kills it. Detach
// from the caller's cancellation (e.g. client disconnect) so the snapshot
// always runs to completion once started.
ctx = context.WithoutCancel(ctx)
// Past this point the snapshot is committed: the sandbox will be paused
// on its node, and cancelling the checkpoint mid-snapshot kills it. Detach
// from the caller's cancellation (e.g. client disconnect) so the snapshot
// always runs to completion once started.
// Use a safety timeout to prevent the orchestrator from hanging indefinitely if the node is unresponsive.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
defer cancel()

// Once we start the removal process, we want to make sure it gets removed from the store
defer o.sandboxStore.Remove(context.WithoutCancel(ctx), teamID, sandboxID)
defer o.sandboxStore.Remove(ctx, teamID, sandboxID)
err = o.removeSandboxFromNode(ctx, sbx, opts.Action, opts.Reason, opts.FilesystemOnly)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since ctx has been detached from caller cancellation and has no timeout, calling removeSandboxFromNode could block indefinitely if the node is unresponsive. To prevent this, wrap the context passed to removeSandboxFromNode with a safety timeout. This keeps the other cleanup operations (like store removal and analytics) safe from early cancellation while ensuring the node call doesn't hang forever.

nodeCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
err = o.removeSandboxFromNode(nodeCtx, sbx, opts.Action, opts.Reason, opts.FilesystemOnly)

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but this changes cancellation semantics on the sandbox pause/kill/snapshot commit paths (removing deadlines as well as cancellation once committed), so a human should weigh in given how critical this code is to sandbox lifecycle correctness.

Extended reasoning...

Overview

The PR moves context.WithoutCancel(ctx) earlier in CheckpointSandbox, CreateSnapshotTemplate, and RemoveSandbox so that once a sandbox's state transition is committed (pause/kill/snapshot has started), the remaining work — snapshot upload, node RPCs, DB status updates, store cleanup — is detached from the caller's cancellation. It also adds a failSnapshotBuild call when the post-checkpoint UpdateEnvBuildStatus write fails, so a build doesn't dangle in Snapshotting forever.

Security risks

None identified. No new external input handling, auth, or crypto is touched. The change is purely about context propagation and error handling within already-authenticated internal orchestration calls.

Level of scrutiny

This is production-critical orchestration code governing sandbox pause/kill/snapshot lifecycle — a wrong cancellation change here can leak running sandboxes, leave builds stuck, or (in the other direction) cause requests to hang since context.WithoutCancel also strips any deadline from the parent context, not just cancellation. I verified the call sites (throttledUpsertSnapshot's semaphore acquire, node.GetClient/GetSandboxDeleteCtx) don't impose their own timeout, so any deadline carried on the original request context is now gone for the committed portion of these flows. That's consistent with the PR's stated intent (let committed operations run to completion), but it's a real behavioral trade-off — a hung downstream node call after commit no longer times out via the caller's context — that deserves a human sign-off rather than an automatic approval.

Other factors

The diff is small, well-commented, and the description accurately matches the code. The bug-hunting pass found no defects, and I additionally checked the deferred cleanup ordering in RemoveSandbox (store removal → analytics → finish) and confirmed it's unchanged from before the PR. Given the criticality of the code path, I'm deferring rather than auto-approving.

@mishushakov
mishushakov marked this pull request as draft July 17, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant