Skip to content

fix(sdk): clean up sandbox when MCP gateway startup fails#1548

Open
mishushakov wants to merge 2 commits into
mainfrom
mcp-gateway-startup-cleanup
Open

fix(sdk): clean up sandbox when MCP gateway startup fails#1548
mishushakov wants to merge 2 commits into
mainfrom
mcp-gateway-startup-cleanup

Conversation

@mishushakov

Copy link
Copy Markdown
Member

Problem

Fixes #1498.

Sandbox.create allocates a remote sandbox before starting mcp-gateway. If gateway startup returns a non-zero exit code or throws an exception, creation fails before the sandbox object is returned. As a result, the caller has no sandbox ID to clean up, and the orphaned sandbox continues consuming resources until it times out.

This state transition exists in synchronous Python, asynchronous Python, and JavaScript/TypeScript.

Changes

  • Add a rollback boundary around MCP gateway startup in all three SDK implementations.
  • Attempt to kill the newly allocated sandbox when gateway startup fails.
  • Keep cleanup best-effort so a failed or cancelled kill does not replace the original gateway error.
  • Add real integration coverage for synchronous Python, asynchronous Python, and TypeScript.
  • Add a patch changeset for e2b and @e2b/python-sdk.

Usage Behavior

No API changes are required. Existing code continues to receive the original startup error, but a failed creation will normally no longer leave a sandbox behind:

try {
  const sandbox = await Sandbox.create({ mcp: { ... } })
} catch (err) {
  // gateway startup failed — the allocated sandbox has already been killed,
  // no orphaned sandbox is left running until timeout
}

Validation

Integration tests have been added and verified successfully.

Notes

Supersedes #1547 by @hxaxd (squash-merged into this branch to preserve attribution).

🤖 Generated with Claude Code

## Problem

Fixes #1498.

`Sandbox.create` allocates a remote sandbox before starting
`mcp-gateway`. If gateway startup returns a non-zero exit code or throws
an exception, creation fails before the sandbox object is returned. As a
result, the caller has no sandbox ID to clean up, and the orphaned
sandbox continues consuming resources until it times out.

This state transition exists in synchronous Python, asynchronous Python,
and JavaScript/TypeScript.

## Changes

- Add a rollback boundary around MCP gateway startup in all three SDK
implementations.
- Attempt to kill the newly allocated sandbox when gateway startup
fails.
- Keep cleanup best-effort so a failed or cancelled `kill` does not
replace the original gateway error.
- Add real integration coverage for synchronous Python, asynchronous
Python, and TypeScript.
- Add a patch changeset for `e2b` and `@e2b/python-sdk`.

## Usage Behavior

No API changes are required. Existing code continues to receive the
original startup error, but a failed creation will normally no longer
leave a sandbox behind.

## Validation

Integration tests have been added and verified successfully.

## Notes

PR #1499 is a friendly competing implementation for the same issue. The
maintainer requested that its mocked tests be replaced with real
integration tests, but the PR received no further updates for several
days. The maintainer subsequently confirmed that an alternative
contribution could be submitted as long as it was implemented
independently from scratch, so this PR was prepared only after that
confirmation.
@changeset-bot

changeset-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 89c902b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
e2b Patch
@e2b/python-sdk Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cursor

cursor Bot commented Jul 13, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Narrow rollback on the MCP create path only; cleanup is best-effort and preserves the original error. Successful MCP creates are unchanged.

Overview
When Sandbox.create is called with MCP options, the remote sandbox is allocated before mcp-gateway runs. If that startup fails (non-zero exit or thrown error), creation used to throw without tearing down the VM, leaving an orphan until timeout.

JS, sync Python, and async Python now wrap MCP gateway startup in try/catch and best-effort kill() on the new sandbox before rethrowing the original error; failed kills are swallowed so they do not mask the gateway failure. No public API changes.

Integration tests create with MCP on a template that lacks mcp-gateway, assert the expected error, and verify no running sandboxes remain (with cleanup in finally). A patch changeset covers e2b and @e2b/python-sdk.

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

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from e113e4a. Download artifacts from this workflow run.

JS SDK (e2b@2.33.1-mcp-gateway-startup-cleanup.0):

npm install ./e2b-2.33.1-mcp-gateway-startup-cleanup.0.tgz

CLI (@e2b/cli@2.13.3-mcp-gateway-startup-cleanup.0):

npm install ./e2b-cli-2.13.3-mcp-gateway-startup-cleanup.0.tgz

Python SDK (e2b==2.32.0+mcp.gateway.startup.cleanup):

pip install ./e2b-2.32.0+mcp.gateway.startup.cleanup-py3-none-any.whl

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16390cfd76

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +256 to +257
except BaseException:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve cancellation during cleanup

When the async create task is cancelled while this best-effort await sandbox.kill() is in flight—for example an asyncio.timeout around AsyncSandbox.create(..., mcp=...) expires after gateway startup has already failed—asyncio.CancelledError is caught here and discarded. That can abort the DELETE request, still leave the sandbox orphaned, and then raise the original gateway error instead of honoring the caller's cancellation; suppress only ordinary cleanup failures or shield cleanup while re-propagating cancellation.

Useful? React with 👍 / 👎.

@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.

Beyond the inline finding on the dead MCP gateway exit-code check, I looked at the exception-handling in the new rollback blocks themselves: whether the except BaseException wrapping kill() could swallow a second KeyboardInterrupt/SystemExit raised during cleanup, and whether the blocking kill() call in the async path could delay CancelledError propagation past a wait_for/timeout deadline. Both are inherent to the best-effort cleanup design here (the PR explicitly swallows cleanup failures so they don't mask the original error) rather than distinct bugs, so I'm not flagging them separately.

Extended reasoning...

Checked the sync and async except BaseException: ... finally raise rollback blocks in sandbox_sync/main.py and sandbox_async/main.py for interrupt-swallowing and cancellation-delay issues beyond the inline finding. The inner except BaseException: pass around kill() only suppresses exceptions from the cleanup call itself and still re-raises the original error afterward, and the blocking kill() during cleanup is bounded and best-effort by design, so these did not rise to separate bugs worth blocking on.

Comment on lines 320 to 340

if (sandboxOpts?.mcp) {
sandbox.mcpToken = crypto.randomUUID()
const res = await sandbox.commands.run(
`mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`,
{
user: 'root',
envs: {
GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '',
},
try {
const res = await sandbox.commands.run(
`mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`,
{
user: 'root',
envs: {
GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '',
},
}
)
if (res.exitCode !== 0) {
throw new Error(`Failed to start MCP gateway: ${res.stderr}`)
}
)
if (res.exitCode !== 0) {
throw new Error(`Failed to start MCP gateway: ${res.stderr}`)
} catch (error) {
await sandbox.kill().catch(() => undefined)
throw error
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The manual if (res.exitCode !== 0) throw new Error('Failed to start MCP gateway: ...') check is unreachable dead code: sandbox.commands.run() without background: true resolves via CommandHandle.wait(), which already throws CommandExitError whenever the exit code is non-zero, before ever returning a result object (same pattern in both Python SDKs with CommandExitException).

Extended reasoning...

The bug. Commands.run() in foreground mode (packages/js-sdk/src/sandbox/commands/index.ts:423) returns proc.wait(), not a raw result. CommandHandle.wait() (commandHandle.ts:166-181) checks if (this.result.exitCode !== 0) throw new CommandExitError(this.result) and only returns this.result when the exit code is zero. So by the time res is assigned in Sandbox.create() (packages/js-sdk/src/sandbox/index.ts:325-334), res.exitCode can only ever be 0 — the subsequent if (res.exitCode !== 0) throw new Error(\Failed to start MCP gateway: ${res.stderr}`)can never execute. The same structure exists in both Python SDKs:sandbox_sync/main.pyandsandbox_async/main.pycallcommands.run()withoutbackground=True, which resolves through CommandHandle.wait() (command_handle.py:151-194), and that method raises CommandExitExceptionon a non-zero exit code (line 188-194) before it can return a result with a non-zeroexit_codefor the manual check to catch.\n\n**Why this matters now.** This exact block is pre-existing — it was just re-indented into the newtry { ... } catch (error) { await sandbox.kill()...; throw error }wrapper this PR adds. On its own the dead code would be a minor pre-existing nit. But this PR newly depends on the exact error message it produces: the added integration tests assert.rejects.toThrow('Failed to start MCP gateway') (create.test.ts) and pytest.raises(Exception, match='Failed to start MCP gateway') (test_create.py, sync and async), using mcp: { invalid_server: {} }to trigger the realistic non-zero-exit failure mode that mcp-gateway would raise for invalid config.\n\n**What actually happens when the test runs.** On invalid config, mcp-gateway exits non-zero.sandbox.commands.run(...)never returns aCommandResultat all — it throwsCommandExitError(JS) /CommandExitException(Python) straight out of thetryblock, and the manual exit-code check inside thetryis never reached. The message onCommandExitErrorissuper(result.error)(the envderrorfield), and Python'sCommandExitException.stris'Command exited with code N and error:\n'— neither contains the substring'Failed to start MCP gateway'. So the new tests' message-match assertions fail (or, if mcp-gateway happened to exit 0, no exception is raised at all and pytest.raises/.rejects.toThrowfails for that reason instead). Either way, the tests do not exercise the code path they claim to, and the documented user-facing error textFailed to start MCP gateway: is never actually produced for this failure mode.\n\n**Step-by-step proof (JS):**\n1.Sandbox.create({ mcp: { invalid_server: {} } })callssandbox.commands.run('mcp-gateway --config ...', {...})withoutbackground: true.\n2. Commands.run() (commands/index.ts:423) executes return opts?.background ? proc : proc.wait()→ sincebackgroundis not set, it callsproc.wait().\n3. mcp-gateway process exits with code != 0 due to invalid config.\n4. CommandHandle.wait() (commandHandle.ts:177-179) sees this.result.exitCode !== 0and throwsnew CommandExitError(this.result)— the promise fromsandbox.commands.run(...)rejects here; execution never reaches theconst res = ...assignment's continuation.\n5. Thethrowpropagates directly into the surroundingcatch (error)inSandbox.create(), which does await sandbox.kill().catch(() => undefined); throw error— so the cleanup does correctly run, buterroris aCommandExitError, not the intended Error('Failed to start MCP gateway: ...').\n6. The test's .rejects.toThrow('Failed to start MCP gateway')checks the rejected error's message against that substring;CommandExitError's message comes from the envd errorfield, so the assertion fails.\n\n**Fix.** Either (a) explicitly catchCommandExitError/CommandExitExceptioninside thetryand re-raise with the intendedFailed to start MCP gateway: message (usingerror.result.stderr/ the exception's stderr), so the documented contract holds and the new tests pass as written, or (b) drop the now-provably-dead manual exit-code check and adjust the test assertions to match on the actualCommandExitError/CommandExitExceptionmessage/type instead. Option (a) is preferable since it preserves the documented, more informative error text for SDK users.\n\nThe rollback/kill behavior itself (the PR's core goal) is unaffected — thecatch/except` still fires on any exception regardless of which one it is — so this does not break the primary feature, only the error-message contract and the new tests that assert it.

The tests relied on `mcp: {invalid_server: {}}` making mcp-gateway exit
non-zero, but the gateway accepts unknown server names and starts fine,
so Sandbox.create succeeded and the expected rejection never happened.

Pin the tests to the base template, which has no mcp-gateway binary, so
gateway startup genuinely fails after the sandbox is allocated. The
failure now surfaces as CommandExitError/CommandExitException rather
than the exit-code message, so match on the exception type instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

MCP gateway startup failure leaves orphaned sandbox running

2 participants