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 diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index 53ea87b..4f83238 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,134 @@ 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: [], + session: makeSandboxMetadata(), + namedSandbox: { name: "test" } as any, + projectId: "test-project", + }); + + 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: [], + session: makeSandboxMetadata(), + namedSandbox: { name: "test" } as any, + projectId: "test-project", + }); + + 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; }