From a1d27340990f3b5cfb53dfa6e9bcf88b97be7f29 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Tue, 10 Mar 2026 16:34:32 +0000 Subject: [PATCH 1/4] fix(sdk): handle unhandled promise rejections on logs error (#82) Running a command today might throw an unhandled promise rejection if fetching the logs returns a non-ok status code (e.g. when the sandbox is being stopped) This happens because `getLogs` creates a floating promise without any catch handler. `command.logs` may throw an error if the response is not ok, in which case we would get an unhandled promise rejection error. By default in Node.js, this kills the whole process, unless you [attach a unhandled promise rejection handler](https://nodejs.org/api/process.html#event-unhandledrejection) --- .changeset/wicked-bats-attend.md | 5 + packages/vercel-sandbox/src/sandbox.test.ts | 128 +++++++++++++++++++- packages/vercel-sandbox/src/session.ts | 48 ++++---- 3 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 .changeset/wicked-bats-attend.md diff --git a/.changeset/wicked-bats-attend.md b/.changeset/wicked-bats-attend.md new file mode 100644 index 0000000..b4ebe36 --- /dev/null +++ b/.changeset/wicked-bats-attend.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": patch +--- + +Fix unhandled promise rejection when running a command while the sandbox is stopping diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index 53ea87b..ba013af 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -1,7 +1,9 @@ -import { it, beforeEach, afterEach, expect, describe } from "vitest"; +import { it, beforeEach, afterEach, expect, describe, vi } from "vitest"; +import { PassThrough } from "stream"; import { consumeReadable } from "./utils/consume-readable"; import { Sandbox } from "./sandbox"; import { APIError } from "./api-client/api-error"; +import type { APIClient, CommandData, SandboxMetaData } from "./api-client"; import { mkdtemp, readFile, rm } from "fs/promises"; import { tmpdir } from "os"; import { join, resolve } from "path"; @@ -61,6 +63,130 @@ describe("downloadFile validation", () => { }); }); +const makeSandboxMetadata = (): SandboxMetaData => ({ + id: "sbx_123", + memory: 2048, + vcpus: 1, + region: "iad1", + runtime: "node24", + timeout: 300_000, + status: "running", + requestedAt: 1, + createdAt: 1, + cwd: "/", + updatedAt: 1, +}); + +const makeCommand = (): CommandData => ({ + id: "cmd_123", + name: "echo", + args: ["hello"], + cwd: "/", + sandboxId: "sbx_123", + exitCode: null, + startedAt: 1, +}); + +describe("_runCommand error handling", () => { + it("rejects non-detached runCommand when log streaming fails", async () => { + const command = makeCommand(); + const logsError = new APIError(new Response("failed", { status: 500 }), { + message: "Failed to stream logs", + sandboxId: "sbx_123", + }); + + const runCommandMock = vi.fn(async ({ wait }: { wait?: boolean }) => { + if (wait) { + return { + command, + finished: Promise.resolve({ ...command, exitCode: 0 }), + }; + } + + return { json: { command } }; + }); + + const getLogsMock = vi.fn(() => + (async function* () { + throw logsError; + })(), + ); + + const sandbox = new Sandbox({ + client: { + runCommand: runCommandMock, + getLogs: getLogsMock, + } as unknown as APIClient, + routes: [], + sandbox: makeSandboxMetadata(), + }); + + await expect( + sandbox.runCommand({ + cmd: "echo", + args: ["hello"], + stdout: new PassThrough(), + }), + ).rejects.toBe(logsError); + }); + + it("emits detached log streaming errors on the provided output stream", async () => { + const command = makeCommand(); + const logsError = new APIError(new Response("failed", { status: 500 }), { + message: "Failed to stream logs", + sandboxId: "sbx_123", + }); + + const runCommandMock = vi.fn(async ({ wait }: { wait?: boolean }) => { + if (wait) { + return { + command, + finished: Promise.resolve({ ...command, exitCode: 0 }), + }; + } + + return { json: { command } }; + }); + + const getLogsMock = vi.fn(() => + (async function* () { + throw logsError; + })(), + ); + + const sandbox = new Sandbox({ + client: { + runCommand: runCommandMock, + getLogs: getLogsMock, + } as unknown as APIClient, + routes: [], + sandbox: makeSandboxMetadata(), + }); + + const stdout = new PassThrough(); + const errorEvent = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Expected stdout error event")), + 100, + ); + stdout.once("error", (err) => { + clearTimeout(timer); + resolve(err); + }); + }); + + const detached = await sandbox.runCommand({ + cmd: "echo", + args: ["hello"], + detached: true, + stdout, + }); + + expect(detached.cmdId).toBe("cmd_123"); + await expect(errorEvent).resolves.toBe(logsError); + }); +}); + describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")("Sandbox", () => { const PORTS = [3000, 4000]; let sandbox: Sandbox; diff --git a/packages/vercel-sandbox/src/session.ts b/packages/vercel-sandbox/src/session.ts index 98f852d..6e80cc0 100644 --- a/packages/vercel-sandbox/src/session.ts +++ b/packages/vercel-sandbox/src/session.ts @@ -320,24 +320,24 @@ export class Session { */ async _runCommand(params: RunCommandParams) { const wait = params.detached ? false : true; - const getLogs = (command: Command) => { - if (params.stdout || params.stderr) { - (async () => { - try { - for await (const log of command.logs({ signal: params.signal })) { - if (log.stream === "stdout") { - params.stdout?.write(log.data); - } else if (log.stream === "stderr") { - params.stderr?.write(log.data); - } - } - } catch (err) { - if (params.signal?.aborted) { - return; - } - throw err; + const pipeLogs = async (command: Command): Promise => { + if (!params.stdout && !params.stderr) { + return; + } + + try { + for await (const log of command.logs({ signal: params.signal })) { + if (log.stream === "stdout") { + params.stdout?.write(log.data); + } else if (log.stream === "stderr") { + params.stderr?.write(log.data); } - })(); + } + } catch (err) { + if (params.signal?.aborted) { + return; + } + throw err; } }; @@ -359,9 +359,10 @@ export class Session { cmd: commandStream.command, }); - getLogs(command); - - const finished = await commandStream.finished; + const [finished] = await Promise.all([ + commandStream.finished, + pipeLogs(command), + ]); return new CommandFinished({ client: this.client, sandboxId: this.session.id, @@ -386,7 +387,12 @@ export class Session { cmd: commandResponse.json.command, }); - getLogs(command); + void pipeLogs(command).catch((err) => { + if (params.signal?.aborted) { + return; + } + (params.stderr ?? params.stdout)?.emit('error', err) + }); return command; } From f10189ff3ba3436046044e67c90910cb1c276884 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:47:15 +0000 Subject: [PATCH 2/4] Version Packages (#83) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. - Updated dependencies \[[`ac49096ea505d658d6e255780c663765e7a309af`](https://github.com/vercel/sandbox/commit/ac49096ea505d658d6e255780c663765e7a309af)]: - @vercel/sandbox@1.8.1 - Fix unhandled promise rejection when running a command while the sandbox is stopping ([#82](https://github.com/vercel/sandbox/pull/82)) - Updated dependencies \[[`ac49096ea505d658d6e255780c663765e7a309af`](https://github.com/vercel/sandbox/commit/ac49096ea505d658d6e255780c663765e7a309af)]: - @vercel/sandbox@1.8.1 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Tom Lienard --- .changeset/wicked-bats-attend.md | 5 ----- examples/filesystem-snapshots/CHANGELOG.md | 7 +++++++ examples/filesystem-snapshots/package.json | 2 +- packages/sandbox/CHANGELOG.md | 7 +++++++ packages/vercel-sandbox/CHANGELOG.md | 6 ++++++ 5 files changed, 21 insertions(+), 6 deletions(-) delete mode 100644 .changeset/wicked-bats-attend.md diff --git a/.changeset/wicked-bats-attend.md b/.changeset/wicked-bats-attend.md deleted file mode 100644 index b4ebe36..0000000 --- a/.changeset/wicked-bats-attend.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@vercel/sandbox": patch ---- - -Fix unhandled promise rejection when running a command while the sandbox is stopping diff --git a/examples/filesystem-snapshots/CHANGELOG.md b/examples/filesystem-snapshots/CHANGELOG.md index 924d265..f44bec2 100644 --- a/examples/filesystem-snapshots/CHANGELOG.md +++ b/examples/filesystem-snapshots/CHANGELOG.md @@ -1,5 +1,12 @@ # sandbox-filesystem-snapshots +## 0.0.10 + +### Patch Changes + +- Updated dependencies [[`ac49096ea505d658d6e255780c663765e7a309af`](https://github.com/vercel/sandbox/commit/ac49096ea505d658d6e255780c663765e7a309af)]: + - @vercel/sandbox@1.8.1 + ## 0.0.9 ### Patch Changes diff --git a/examples/filesystem-snapshots/package.json b/examples/filesystem-snapshots/package.json index baf535f..91a91da 100644 --- a/examples/filesystem-snapshots/package.json +++ b/examples/filesystem-snapshots/package.json @@ -1,6 +1,6 @@ { "name": "sandbox-filesystem-snapshots", - "version": "0.0.9", + "version": "0.0.10", "private": true, "description": "Example demonstrating filesystem snapshotting", "main": "filesystem-snapshots.ts", diff --git a/packages/sandbox/CHANGELOG.md b/packages/sandbox/CHANGELOG.md index bfe0a28..3cad587 100644 --- a/packages/sandbox/CHANGELOG.md +++ b/packages/sandbox/CHANGELOG.md @@ -56,6 +56,13 @@ - Support named sandboxes +## 2.5.5 + +### Patch Changes + +- Updated dependencies [[`ac49096ea505d658d6e255780c663765e7a309af`](https://github.com/vercel/sandbox/commit/ac49096ea505d658d6e255780c663765e7a309af)]: + - @vercel/sandbox@1.8.1 + ## 2.5.4 ### Patch Changes diff --git a/packages/vercel-sandbox/CHANGELOG.md b/packages/vercel-sandbox/CHANGELOG.md index f82c6e3..2a2e8e2 100644 --- a/packages/vercel-sandbox/CHANGELOG.md +++ b/packages/vercel-sandbox/CHANGELOG.md @@ -30,6 +30,12 @@ - Introduce named and long-lived sandboxes ([`7407ec9ec419144ae49b0eb2704cb5cf2267b7f3`](https://github.com/vercel/sandbox/commit/7407ec9ec419144ae49b0eb2704cb5cf2267b7f3)) +## 1.8.1 + +### Patch Changes + +- Fix unhandled promise rejection when running a command while the sandbox is stopping ([#82](https://github.com/vercel/sandbox/pull/82)) + ## 1.8.0 ### Minor Changes From 16565b70d662fefa41ebc19b8c4bb5dbb2286f6f Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Fri, 13 Mar 2026 11:09:28 +0100 Subject: [PATCH 3/4] empty commit to retrigger pipeline From 405c446e2d3bde54136667e99e30a1b21113445d Mon Sep 17 00:00:00 2001 From: Marc Codina Date: Fri, 13 Mar 2026 11:15:13 +0100 Subject: [PATCH 4/4] fix tests --- packages/vercel-sandbox/src/sandbox.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index ba013af..4f83238 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -118,7 +118,9 @@ describe("_runCommand error handling", () => { getLogs: getLogsMock, } as unknown as APIClient, routes: [], - sandbox: makeSandboxMetadata(), + session: makeSandboxMetadata(), + namedSandbox: { name: "test" } as any, + projectId: "test-project", }); await expect( @@ -160,7 +162,9 @@ describe("_runCommand error handling", () => { getLogs: getLogsMock, } as unknown as APIClient, routes: [], - sandbox: makeSandboxMetadata(), + session: makeSandboxMetadata(), + namedSandbox: { name: "test" } as any, + projectId: "test-project", }); const stdout = new PassThrough();