diff --git a/typescript/src/sandbox.ts b/typescript/src/sandbox.ts index 4eee955b..ac8a3252 100644 --- a/typescript/src/sandbox.ts +++ b/typescript/src/sandbox.ts @@ -4,7 +4,7 @@ import { Desktop, } from "./desktop.js"; import * as defaults from "./defaults.js"; -import { SandboxError } from "./errors.js"; +import { SandboxError, SandboxConnectionError } from "./errors.js"; import { type HttpRequestOptions, type Traced, HttpClient } from "./http.js"; import { type CheckpointOptions, @@ -719,6 +719,7 @@ export class Sandbox { const stdoutLines: string[] = []; const stderrLines: string[] = []; let exitCode = -1; + let sawExit = false; for await (const raw of parseSSEStream>(sseStream)) { if (typeof raw.line === "string") { @@ -728,6 +729,7 @@ export class Sandbox { stdoutLines.push(raw.line); } } else if ("exit_code" in raw || "signal" in raw) { + sawExit = true; if (typeof raw.exit_code === "number") { exitCode = raw.exit_code; } else if (typeof raw.signal === "number") { @@ -736,6 +738,16 @@ export class Sandbox { } } + // A completed run always terminates with an exit_code/signal event. If the + // stream ends without one, the command never finished — e.g. the sandbox was + // unreachable and the proxy closed the stream after a timeout. Surface that + // as an error instead of returning a misleading success with empty output. + if (!sawExit) { + throw new SandboxConnectionError( + `Command "${command}" did not complete: the process stream ended without an exit status.`, + ); + } + return Object.assign( { exitCode, stdout: stdoutLines.join("\n"), stderr: stderrLines.join("\n") }, { traceId }, diff --git a/typescript/tests/sandbox.test.ts b/typescript/tests/sandbox.test.ts index ead29048..7fd02264 100644 --- a/typescript/tests/sandbox.test.ts +++ b/typescript/tests/sandbox.test.ts @@ -87,6 +87,25 @@ describe("Sandbox", () => { await sbx.run("echo"); sbx.close(); }); + + it("throws when the stream ends without an exit event", async () => { + // Reproduces the unreachable-sandbox case: the proxy closes the run + // stream after a timeout without ever delivering an exit_code, so the + // command never actually executed. This must surface as an error rather + // than a silent success with empty output. + mockFetch((url) => { + if (url.includes("/api/v1/processes/run")) { + return sseResponse([{ pid: 42, started_at: 1700000000 }]); + } + return new Response("", { status: 404 }); + }); + + const sbx = makeSandbox(); + await expect(sbx.run("node", { args: ["-v"] })).rejects.toThrow( + /did not complete/, + ); + sbx.close(); + }); }); describe("listProcesses", () => {