-
Notifications
You must be signed in to change notification settings - Fork 965
fix(sdk): clean up sandbox when MCP gateway startup fails #1548
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "e2b": patch | ||
| "@e2b/python-sdk": patch | ||
| --- | ||
|
|
||
| Kill newly created sandboxes when MCP gateway startup fails. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -242,13 +242,20 @@ async def create( | |
| token = str(uuid.uuid4()) | ||
| sandbox._mcp_token = token | ||
|
|
||
| res = await sandbox.commands.run( | ||
| f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", | ||
| user="root", | ||
| envs={"GATEWAY_ACCESS_TOKEN": token}, | ||
| ) | ||
| if res.exit_code != 0: | ||
| raise Exception(f"Failed to start MCP gateway: {res.stderr}") | ||
| try: | ||
| res = await sandbox.commands.run( | ||
| f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}", | ||
| user="root", | ||
| envs={"GATEWAY_ACCESS_TOKEN": token}, | ||
| ) | ||
| if res.exit_code != 0: | ||
| raise Exception(f"Failed to start MCP gateway: {res.stderr}") | ||
| except BaseException: | ||
| try: | ||
| await sandbox.kill() | ||
| except BaseException: | ||
| pass | ||
|
Comment on lines
+256
to
+257
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the async create task is cancelled while this best-effort Useful? React with 👍 / 👎. |
||
| raise | ||
|
|
||
| return sandbox | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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()withoutbackground: trueresolves viaCommandHandle.wait(), which already throwsCommandExitErrorwhenever the exit code is non-zero, before ever returning a result object (same pattern in both Python SDKs withCommandExitException).Extended reasoning...
The bug.
Commands.run()in foreground mode (packages/js-sdk/src/sandbox/commands/index.ts:423) returnsproc.wait(), not a raw result.CommandHandle.wait()(commandHandle.ts:166-181) checksif (this.result.exitCode !== 0) throw new CommandExitError(this.result)and only returnsthis.resultwhen the exit code is zero. So by the timeresis assigned inSandbox.create()(packages/js-sdk/src/sandbox/index.ts:325-334),res.exitCodecan only ever be0— the subsequentif (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 throughCommandHandle.wait()(command_handle.py:151-194), and that method raisesCommandExitExceptionon 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) andpytest.raises(Exception, match='Failed to start MCP gateway')(test_create.py, sync and async), usingmcp: { 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 andpytest.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) executesreturn 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) seesthis.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 doesawait sandbox.kill().catch(() => undefined); throw error— so the cleanup does correctly run, buterroris aCommandExitError, not the intendedError('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 envderrorfield, 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.