fix(sdk): clean up sandbox when MCP gateway startup fails#1548
fix(sdk): clean up sandbox when MCP gateway startup fails#1548mishushakov wants to merge 2 commits into
Conversation
## 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 detectedLatest commit: 89c902b The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
PR SummaryLow Risk Overview JS, sync Python, and async Python now wrap MCP gateway startup in try/catch and best-effort Integration tests create with MCP on a template that lacks Reviewed by Cursor Bugbot for commit 89c902b. Bugbot is set up for automated code reviews on this repo. Configure here. |
Package ArtifactsBuilt from e113e4a. Download artifacts from this workflow run. JS SDK ( npm install ./e2b-2.33.1-mcp-gateway-startup-cleanup.0.tgzCLI ( npm install ./e2b-cli-2.13.3-mcp-gateway-startup-cleanup.0.tgzPython SDK ( pip install ./e2b-2.32.0+mcp.gateway.startup.cleanup-py3-none-any.whl |
There was a problem hiding this comment.
💡 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".
| except BaseException: | ||
| pass |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
|
|
||
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 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>
Problem
Fixes #1498.
Sandbox.createallocates a remote sandbox before startingmcp-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
killdoes not replace the original gateway error.e2band@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
Supersedes #1547 by @hxaxd (squash-merged into this branch to preserve attribution).
🤖 Generated with Claude Code