From 16390cfd7610e092b5d2caf28976175f2dd7470c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E7=B4=AB=E8=BE=B0?= <155808914+hxaxd@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:37:23 +0800 Subject: [PATCH 1/2] fix(sdk): clean up sandbox when MCP gateway startup fails (#1547) ## 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/quiet-sandboxes-clean.md | 6 ++++ packages/js-sdk/src/sandbox/index.ts | 25 +++++++------ packages/js-sdk/tests/sandbox/create.test.ts | 35 ++++++++++++++++++- packages/python-sdk/e2b/sandbox_async/main.py | 21 +++++++---- packages/python-sdk/e2b/sandbox_sync/main.py | 21 +++++++---- .../tests/async/sandbox_async/test_create.py | 26 ++++++++++++++ .../tests/sync/sandbox_sync/test_create.py | 26 ++++++++++++++ 7 files changed, 135 insertions(+), 25 deletions(-) create mode 100644 .changeset/quiet-sandboxes-clean.md diff --git a/.changeset/quiet-sandboxes-clean.md b/.changeset/quiet-sandboxes-clean.md new file mode 100644 index 0000000000..83334e7eb4 --- /dev/null +++ b/.changeset/quiet-sandboxes-clean.md @@ -0,0 +1,6 @@ +--- +"e2b": patch +"@e2b/python-sdk": patch +--- + +Kill newly created sandboxes when MCP gateway startup fails. diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 56caea6300..6f79576b2b 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -320,17 +320,22 @@ export class Sandbox extends SandboxApi { 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 } } diff --git a/packages/js-sdk/tests/sandbox/create.test.ts b/packages/js-sdk/tests/sandbox/create.test.ts index 69251c8a5a..87fa079b4b 100644 --- a/packages/js-sdk/tests/sandbox/create.test.ts +++ b/packages/js-sdk/tests/sandbox/create.test.ts @@ -1,4 +1,4 @@ -import { assert, test } from 'vitest' +import { assert, expect, test } from 'vitest' import { Sandbox } from '../../src' import { template, isDebug } from '../setup.js' @@ -32,3 +32,36 @@ test.skipIf(isDebug)('metadata', async () => { await sbx.kill() } }) + +test.skipIf(isDebug)( + 'MCP gateway start failure kills the created sandbox', + async () => { + const metadata = { mcpGatewayCleanupTestId: crypto.randomUUID() } + const query = { state: ['running' as const], metadata } + let remainingSandboxes: Awaited< + ReturnType['nextItems']> + > = [] + + try { + await expect( + Sandbox.create({ + timeoutMs: 60_000, + metadata, + mcp: { invalid_server: {} } as never, + }) + ).rejects.toThrow('Failed to start MCP gateway') + + remainingSandboxes = await Sandbox.list({ query }).nextItems() + expect(remainingSandboxes).toEqual([]) + } finally { + remainingSandboxes = await Sandbox.list({ query }) + .nextItems() + .catch(() => remainingSandboxes) + await Promise.all( + remainingSandboxes.map((sandbox) => + Sandbox.kill(sandbox.sandboxId).catch(() => false) + ) + ) + } + } +) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 791b9b360d..ccc90e4b75 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -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 + raise return sandbox diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index df73f2b086..36ee20fa85 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -228,13 +228,20 @@ def create( token = str(uuid.uuid4()) sandbox._mcp_token = token - res = 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 = 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: + sandbox.kill() + except BaseException: + pass + raise return sandbox diff --git a/packages/python-sdk/tests/async/sandbox_async/test_create.py b/packages/python-sdk/tests/async/sandbox_async/test_create.py index aa17c2c52f..6c922f7cb0 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_create.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_create.py @@ -1,5 +1,6 @@ import asyncio from typing import Any, cast +from uuid import uuid4 import httpx import pytest @@ -36,6 +37,31 @@ async def test_metadata(async_sandbox_factory): assert False, "Sandbox not found" +@pytest.mark.skip_debug() +async def test_mcp_gateway_start_failure_kills_created_sandbox(): + metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())} + query = SandboxQuery(state=[SandboxState.RUNNING], metadata=metadata) + remaining_sandboxes = [] + + try: + with pytest.raises(Exception, match="Failed to start MCP gateway"): + await AsyncSandbox.create( + timeout=60, + metadata=metadata, + mcp=cast(Any, {"invalid_server": {}}), + ) + + remaining_sandboxes = await AsyncSandbox.list(query=query).next_items() + assert remaining_sandboxes == [] + finally: + try: + remaining_sandboxes = await AsyncSandbox.list(query=query).next_items() + except Exception: + pass + for sandbox in remaining_sandboxes: + await AsyncSandbox.kill(sandbox.sandbox_id) + + def test_create_payload_serializes_auto_resume_enabled(): body = NewSandbox( template_id="template-id", diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py index 737e346d1a..86c945455a 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py @@ -1,5 +1,6 @@ from time import sleep from typing import Any, cast +from uuid import uuid4 import httpx import pytest @@ -37,6 +38,31 @@ def test_metadata(sandbox_factory): assert False, "Sandbox not found" +@pytest.mark.skip_debug() +def test_mcp_gateway_start_failure_kills_created_sandbox(): + metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())} + query = SandboxQuery(state=[SandboxState.RUNNING], metadata=metadata) + remaining_sandboxes = [] + + try: + with pytest.raises(Exception, match="Failed to start MCP gateway"): + Sandbox.create( + timeout=60, + metadata=metadata, + mcp=cast(Any, {"invalid_server": {}}), + ) + + remaining_sandboxes = Sandbox.list(query=query).next_items() + assert remaining_sandboxes == [] + finally: + try: + remaining_sandboxes = Sandbox.list(query=query).next_items() + except Exception: + pass + for sandbox in remaining_sandboxes: + Sandbox.kill(sandbox.sandbox_id) + + def test_create_payload_serializes_auto_resume_enabled(): body = NewSandbox( template_id="template-id", From 89c902b3c5363305f0d015b0da896ebbb20e3960 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:55:30 +0200 Subject: [PATCH 2/2] test(sdk): force a real MCP gateway startup failure in cleanup tests 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 --- packages/js-sdk/tests/sandbox/create.test.ts | 8 +++++--- .../python-sdk/tests/async/sandbox_async/test_create.py | 9 ++++++--- .../python-sdk/tests/sync/sandbox_sync/test_create.py | 9 ++++++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/js-sdk/tests/sandbox/create.test.ts b/packages/js-sdk/tests/sandbox/create.test.ts index 87fa079b4b..d98c7d616b 100644 --- a/packages/js-sdk/tests/sandbox/create.test.ts +++ b/packages/js-sdk/tests/sandbox/create.test.ts @@ -1,6 +1,6 @@ import { assert, expect, test } from 'vitest' -import { Sandbox } from '../../src' +import { CommandExitError, Sandbox } from '../../src' import { template, isDebug } from '../setup.js' test.skipIf(isDebug)('create', async () => { @@ -43,13 +43,15 @@ test.skipIf(isDebug)( > = [] try { + // The base template has no mcp-gateway binary, so gateway startup + // reliably fails after the sandbox has been allocated. await expect( - Sandbox.create({ + Sandbox.create(template, { timeoutMs: 60_000, metadata, mcp: { invalid_server: {} } as never, }) - ).rejects.toThrow('Failed to start MCP gateway') + ).rejects.toThrow(CommandExitError) remainingSandboxes = await Sandbox.list({ query }).nextItems() expect(remainingSandboxes).toEqual([]) diff --git a/packages/python-sdk/tests/async/sandbox_async/test_create.py b/packages/python-sdk/tests/async/sandbox_async/test_create.py index 6c922f7cb0..50e94aa1e3 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_create.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_create.py @@ -5,7 +5,7 @@ import httpx import pytest -from e2b import AsyncSandbox, SandboxQuery, SandboxState +from e2b import AsyncSandbox, CommandExitException, SandboxQuery, SandboxState from e2b.api.client.models import ( NewSandbox, SandboxAutoResumeConfig, @@ -38,14 +38,17 @@ async def test_metadata(async_sandbox_factory): @pytest.mark.skip_debug() -async def test_mcp_gateway_start_failure_kills_created_sandbox(): +async def test_mcp_gateway_start_failure_kills_created_sandbox(template): metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())} query = SandboxQuery(state=[SandboxState.RUNNING], metadata=metadata) remaining_sandboxes = [] try: - with pytest.raises(Exception, match="Failed to start MCP gateway"): + # The base template has no mcp-gateway binary, so gateway startup + # reliably fails after the sandbox has been allocated. + with pytest.raises(CommandExitException): await AsyncSandbox.create( + template, timeout=60, metadata=metadata, mcp=cast(Any, {"invalid_server": {}}), diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py index 86c945455a..aeb0776c14 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py @@ -5,7 +5,7 @@ import httpx import pytest -from e2b import Sandbox, SandboxState +from e2b import CommandExitException, Sandbox, SandboxState from e2b.api.client.models import ( NewSandbox, SandboxAutoResumeConfig, @@ -39,14 +39,17 @@ def test_metadata(sandbox_factory): @pytest.mark.skip_debug() -def test_mcp_gateway_start_failure_kills_created_sandbox(): +def test_mcp_gateway_start_failure_kills_created_sandbox(template): metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())} query = SandboxQuery(state=[SandboxState.RUNNING], metadata=metadata) remaining_sandboxes = [] try: - with pytest.raises(Exception, match="Failed to start MCP gateway"): + # The base template has no mcp-gateway binary, so gateway startup + # reliably fails after the sandbox has been allocated. + with pytest.raises(CommandExitException): Sandbox.create( + template, timeout=60, metadata=metadata, mcp=cast(Any, {"invalid_server": {}}),