fix(sdk): clean up sandbox when MCP gateway startup fails (#1548)
## Problem Fixes #1498. `Sandbox.create` allocates a remote sandbox before starting `mcp-gateway`. If gateway startup fails, creation throws 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: on failure, best-effort kill the newly allocated sandbox, then re-raise. - Surface gateway startup failure as `SandboxError` (JS) / `SandboxException` (Python) with a `Failed to start MCP gateway: <stderr>` message. Previously the intended message was unreachable dead code — foreground `commands.run` already throws on non-zero exit — so callers got a bare `CommandExitError`/`CommandExitException`. - In async Python, re-raise `asyncio.CancelledError` from the best-effort `kill()` so caller cancellation (e.g. `asyncio.timeout`) is honored; only ordinary cleanup failures are suppressed and never mask the original error. - Add integration coverage for synchronous Python, asynchronous Python, and TypeScript. The tests pin the sandbox to the base template (which has no `mcp-gateway` binary) so gateway startup genuinely fails after allocation. - Add a patch changeset for `e2b` and `@e2b/python-sdk`. ## Usage Behavior No API changes. A failed creation no longer leaves a sandbox behind, and the error is now descriptive: ```ts try { const sandbox = await Sandbox.create({ mcp: { ... } }) } catch (err) { // err is SandboxError: "Failed to start MCP gateway: <stderr>" // the allocated sandbox has already been killed — no orphan is left running } ``` ## Validation All three integration tests verified against real infra: creation rejects with the documented error and no sandbox remains. ## Notes Supersedes #1547 by @hxaxd (squash-merged into this branch to preserve attribution). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: 苏紫辰 <155808914+hxaxd@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"e2b": patch
|
||||
"@e2b/python-sdk": patch
|
||||
---
|
||||
|
||||
Kill newly created sandboxes when MCP gateway startup fails. The failure now surfaces as `SandboxError` (JS) / `SandboxException` (Python) with a `Failed to start MCP gateway: <stderr>` message instead of a bare command exit error.
|
||||
@@ -11,6 +11,7 @@ import { EnvdApiClient, handleEnvdApiError } from '../envd/api'
|
||||
import { createEnvdFetch, createEnvdRpcFetch } from '../envd/http2'
|
||||
import { createRpcLogger } from '../logs'
|
||||
import { Commands, Pty } from './commands'
|
||||
import { CommandExitError } from './commands/commandHandle'
|
||||
import { Filesystem } from './filesystem'
|
||||
import { Git } from './git'
|
||||
import {
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
} from './sandboxApi'
|
||||
import { getSignature } from './signature'
|
||||
import { compareVersions } from 'compare-versions'
|
||||
import { InvalidArgumentError, TemplateError } from '../errors'
|
||||
import { InvalidArgumentError, SandboxError, TemplateError } from '../errors'
|
||||
import { ENVD_DEBUG_FALLBACK, ENVD_DEFAULT_USER } from '../envd/versions'
|
||||
import { shellQuote } from '../utils'
|
||||
|
||||
@@ -321,17 +322,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 {
|
||||
await sandbox.commands.run(
|
||||
`mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`,
|
||||
{
|
||||
user: 'root',
|
||||
envs: {
|
||||
GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '',
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
await sandbox.kill().catch(() => undefined)
|
||||
if (error instanceof CommandExitError) {
|
||||
throw new SandboxError(`Failed to start MCP gateway: ${error.stderr}`)
|
||||
}
|
||||
)
|
||||
if (res.exitCode !== 0) {
|
||||
throw new Error(`Failed to start MCP gateway: ${res.stderr}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,38 @@ 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<ReturnType<typeof Sandbox.list>['nextItems']>
|
||||
> = []
|
||||
|
||||
try {
|
||||
// The base template has no mcp-gateway binary, so gateway startup
|
||||
// reliably fails after the sandbox has been allocated.
|
||||
await expect(
|
||||
Sandbox.create(template, {
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
@@ -16,9 +17,11 @@ from e2b.connection_config import ApiParams, ConnectionConfig
|
||||
from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception
|
||||
from e2b.envd.versions import ENVD_DEBUG_FALLBACK
|
||||
from e2b.exceptions import (
|
||||
SandboxException,
|
||||
TemplateException,
|
||||
format_request_timeout_error,
|
||||
)
|
||||
from e2b.sandbox.commands.command_handle import CommandExitException
|
||||
from e2b.sandbox.main import SandboxOpts
|
||||
from e2b.sandbox.sandbox_api import (
|
||||
McpServer,
|
||||
@@ -239,13 +242,24 @@ class AsyncSandbox(SandboxApi):
|
||||
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:
|
||||
await sandbox.commands.run(
|
||||
f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}",
|
||||
user="root",
|
||||
envs={"GATEWAY_ACCESS_TOKEN": token},
|
||||
)
|
||||
except BaseException as e:
|
||||
try:
|
||||
await sandbox.kill()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(e, CommandExitException):
|
||||
raise SandboxException(
|
||||
f"Failed to start MCP gateway: {e.stderr}"
|
||||
) from e
|
||||
raise
|
||||
|
||||
return sandbox
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@ from e2b.connection_config import ApiParams, ConnectionConfig
|
||||
from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception
|
||||
from e2b.envd.versions import ENVD_DEBUG_FALLBACK
|
||||
from e2b.exceptions import (
|
||||
SandboxException,
|
||||
TemplateException,
|
||||
format_request_timeout_error,
|
||||
)
|
||||
from e2b.sandbox.commands.command_handle import CommandExitException
|
||||
from e2b.sandbox.main import SandboxOpts
|
||||
from e2b.sandbox.sandbox_api import (
|
||||
McpServer,
|
||||
@@ -228,13 +230,22 @@ class Sandbox(SandboxApi):
|
||||
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:
|
||||
sandbox.commands.run(
|
||||
f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}",
|
||||
user="root",
|
||||
envs={"GATEWAY_ACCESS_TOKEN": token},
|
||||
)
|
||||
except BaseException as e:
|
||||
try:
|
||||
sandbox.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(e, CommandExitException):
|
||||
raise SandboxException(
|
||||
f"Failed to start MCP gateway: {e.stderr}"
|
||||
) from e
|
||||
raise
|
||||
|
||||
return sandbox
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from e2b import AsyncSandbox, SandboxQuery, SandboxState
|
||||
from e2b import AsyncSandbox, SandboxException, SandboxQuery, SandboxState
|
||||
from e2b.api.client.models import (
|
||||
NewSandbox,
|
||||
SandboxAutoResumeConfig,
|
||||
@@ -36,6 +37,34 @@ 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(template):
|
||||
metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())}
|
||||
query = SandboxQuery(state=[SandboxState.RUNNING], metadata=metadata)
|
||||
remaining_sandboxes = []
|
||||
|
||||
try:
|
||||
# The base template has no mcp-gateway binary, so gateway startup
|
||||
# reliably fails after the sandbox has been allocated.
|
||||
with pytest.raises(SandboxException, match="Failed to start MCP gateway"):
|
||||
await AsyncSandbox.create(
|
||||
template,
|
||||
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",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from time import sleep
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from e2b import Sandbox, SandboxState
|
||||
from e2b import Sandbox, SandboxException, SandboxState
|
||||
from e2b.api.client.models import (
|
||||
NewSandbox,
|
||||
SandboxAutoResumeConfig,
|
||||
@@ -37,6 +38,34 @@ def test_metadata(sandbox_factory):
|
||||
assert False, "Sandbox not found"
|
||||
|
||||
|
||||
@pytest.mark.skip_debug()
|
||||
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:
|
||||
# The base template has no mcp-gateway binary, so gateway startup
|
||||
# reliably fails after the sandbox has been allocated.
|
||||
with pytest.raises(SandboxException, match="Failed to start MCP gateway"):
|
||||
Sandbox.create(
|
||||
template,
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user