Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/filesystem-snapshots/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/filesystem-snapshots/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/sandbox/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/vercel-sandbox/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 131 additions & 1 deletion packages/vercel-sandbox/src/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<unknown>((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;
Expand Down
48 changes: 27 additions & 21 deletions packages/vercel-sandbox/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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;
}
};

Expand All @@ -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,
Expand All @@ -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;
}
Expand Down
Loading