From e3929bb2726461b310e9baf8c5e862628923fb19 Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:58:12 -0700 Subject: [PATCH 1/3] fix release --- .github/workflows/npm-publish.yml | 18 +++++++++--------- package-lock.json | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index e991b22..a91537f 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -43,14 +43,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Build - run: npm run build - - - name: Configure git - run: | - git config --global user.name 'github-actions[bot]' - git config --global user.email 'github-actions[bot]@users.noreply.github.com' - - name: Set version from release tag run: | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then @@ -61,7 +53,15 @@ jobs: VERSION=${VERSION#refs/tags/} fi echo "Setting version to $VERSION" - npm version $VERSION --no-git-tag-version + npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Build + run: npm run build + + - name: Configure git + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' - name: Publish to npm run: | diff --git a/package-lock.json b/package-lock.json index d4e7a12..325898a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@beamcloud/beam-js", - "version": "1.0.0-rc.25", + "version": "1.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@beamcloud/beam-js", - "version": "1.0.0-rc.25", + "version": "1.0.11", "license": "MIT", "dependencies": { "archiver": "^7.0.1", From e81b28d93d47da5bc72ffc148fdd273209bf054e Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:07:45 -0400 Subject: [PATCH 2/3] Add JS sandbox e2e coverage and examples --- .gitignore | 3 +- README.md | 81 ++++-- examples/sandbox-basic.ts | 35 +++ examples/sandbox-docker.ts | 34 +++ examples/sandbox-http.ts | 45 +++ examples/sandbox-snapshot.ts | 40 +++ jest.config.ts | 6 +- lib/index.ts | 6 +- lib/resources/abstraction/image.ts | 28 ++ lib/resources/abstraction/sandbox.ts | 412 +++++++++++++++++++++++++-- lib/resources/abstraction/stub.ts | 4 + lib/types/pod.ts | 12 +- lib/types/stub.ts | 1 + package.json | 3 +- tests/image-docker.test.ts | 37 +++ tests/sandbox-filesystem.test.ts | 123 ++++++++ tests/sandbox-network.test.ts | 98 +++++++ tests/sandbox.e2e.test.ts | 261 +++++++++++++++++ 18 files changed, 1170 insertions(+), 59 deletions(-) create mode 100644 examples/sandbox-basic.ts create mode 100644 examples/sandbox-docker.ts create mode 100644 examples/sandbox-http.ts create mode 100644 examples/sandbox-snapshot.ts create mode 100644 tests/image-docker.test.ts create mode 100644 tests/sandbox-filesystem.test.ts create mode 100644 tests/sandbox.e2e.test.ts diff --git a/.gitignore b/.gitignore index 30d7aea..c08ed4e 100644 --- a/.gitignore +++ b/.gitignore @@ -133,5 +133,4 @@ dist # bin bin -/examples -.beamignore \ No newline at end of file +.beamignore diff --git a/README.md b/README.md index 16a300e..d0d506a 100644 --- a/README.md +++ b/README.md @@ -38,43 +38,86 @@ yarn add @beamcloud/beam-js@rc ## Quickstart -Run a simple Node.js server in a sandbox. +Create a sandbox, run code, write a file, and expose a small HTTP server. ```typescript import { beamOpts, Image, Sandbox } from "@beamcloud/beam-js"; beamOpts.token = process.env.BEAM_TOKEN!; -beamOpts.workspaceId = process.env.BEAM_WORKSPACE_ID!; async function main() { - const image = new Image({ - baseImage: "node:20", - commands: [ - "apt update", - "apt install -y nodejs npm", - "git clone https://github.com/beam-cloud/quickstart-node.git /app", - ], - }); - const sandbox = new Sandbox({ name: "quickstart", - image: image, - cpu: 2, - memory: 1024, + image: Image.fromRegistry("python:3.11-slim"), + cpu: 1, + memory: "512Mi", keepWarmSeconds: 300, }); const instance = await sandbox.create(); - - const process4 = await instance.exec(["sh", "-c", "cd /app && node server.js"]); - - const url = await instance.exposePort(3000); - console.log(`Server is running at ${url}`); + try { + const result = await instance.runCode("print('hello from Beam JS')"); + console.log(result); + + await instance.fs.writeText("/workspace/index.html", "hello from a sandbox"); + await instance.exec( + [ + "python3", + "-u", + "-m", + "http.server", + "8765", + "--bind", + "0.0.0.0", + ], + { cwd: "/workspace" }, + ); + + const url = await instance.exposePort(8765); + console.log(`Server is running at ${url}`); + } finally { + await instance.terminate(); + } } main(); ``` +## Sandbox examples + +Run these from a checkout of this repository: + +```bash +BEAM_TOKEN=... npx tsx examples/sandbox-basic.ts +BEAM_TOKEN=... npx tsx examples/sandbox-http.ts +BEAM_TOKEN=... npx tsx examples/sandbox-snapshot.ts +``` + +Docker-in-Docker support requires an image with Docker installed and +`dockerEnabled: true`: + +```bash +BEAM_TOKEN=... npx tsx examples/sandbox-docker.ts +``` + +The SDK defaults to Beam production (`https://app.beam.cloud`). Set +`BEAM_GATEWAY_URL` only when testing a development gateway. + +## Production sandbox e2e tests + +The normal test suite does not create cloud resources. Run the sandbox e2e tests +explicitly: + +```bash +BEAM_TOKEN=... npm run test:e2e:sandbox +``` + +Docker coverage is opt-in because it builds a Docker-enabled image: + +```bash +BEAM_TOKEN=... BEAM_SANDBOX_E2E_DOCKER=1 npm run test:e2e:sandbox +``` + ## Support - [Documentation](https://docs.beam.cloud/v2/reference/ts-sdk) diff --git a/examples/sandbox-basic.ts b/examples/sandbox-basic.ts new file mode 100644 index 0000000..c9bdc50 --- /dev/null +++ b/examples/sandbox-basic.ts @@ -0,0 +1,35 @@ +import { beamOpts, Image, Sandbox } from "../lib"; + +beamOpts.token = process.env.BEAM_TOKEN || ""; +beamOpts.gatewayUrl = process.env.BEAM_GATEWAY_URL || "https://app.beam.cloud"; + +async function main() { + const sandbox = new Sandbox({ + name: "js-sandbox-basic", + image: Image.fromRegistry("python:3.11-slim"), + cpu: 1, + memory: "512Mi", + keepWarmSeconds: 300, + }); + + const instance = await sandbox.create(); + try { + const result = await instance.runCode("print('hello from Beam JS')"); + console.log(result); + + await instance.fs.writeText("/workspace/message.txt", "hello filesystem"); + console.log(await instance.fs.readText("/workspace/message.txt")); + + const process = await instance.exec("printf streamed-log"); + for await (const line of process.logs) { + console.log(line); + } + } finally { + await instance.terminate(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/examples/sandbox-docker.ts b/examples/sandbox-docker.ts new file mode 100644 index 0000000..9326119 --- /dev/null +++ b/examples/sandbox-docker.ts @@ -0,0 +1,34 @@ +import { beamOpts, Image, Sandbox } from "../lib"; + +beamOpts.token = process.env.BEAM_TOKEN || ""; +beamOpts.gatewayUrl = process.env.BEAM_GATEWAY_URL || "https://app.beam.cloud"; + +async function main() { + const image = new Image({ pythonVersion: "python3.11" }).withDocker(); + const sandbox = new Sandbox({ + name: "js-sandbox-docker", + image, + cpu: 2, + memory: "2Gi", + keepWarmSeconds: 300, + dockerEnabled: true, + }); + + const instance = await sandbox.create(); + try { + const version = await instance.docker.version(); + await version.wait(); + console.log(await version.stdout.read()); + + const hello = await instance.docker.run("hello-world", ["--rm"]); + await hello.wait(); + console.log(await hello.stdout.read()); + } finally { + await instance.terminate(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/examples/sandbox-http.ts b/examples/sandbox-http.ts new file mode 100644 index 0000000..71435c2 --- /dev/null +++ b/examples/sandbox-http.ts @@ -0,0 +1,45 @@ +import { beamOpts, Image, Sandbox } from "../lib"; + +beamOpts.token = process.env.BEAM_TOKEN || ""; +beamOpts.gatewayUrl = process.env.BEAM_GATEWAY_URL || "https://app.beam.cloud"; + +async function main() { + const sandbox = new Sandbox({ + name: "js-sandbox-http", + image: Image.fromRegistry("python:3.11-slim"), + cpu: 1, + memory: "512Mi", + keepWarmSeconds: 300, + }); + + const instance = await sandbox.create(); + try { + await instance.fs.writeText("/workspace/index.html", "hello from a sandbox"); + await instance.exec( + [ + "python3", + "-u", + "-m", + "http.server", + "8765", + "--bind", + "0.0.0.0", + ], + { cwd: "/workspace" }, + ); + + const url = await instance.exposePort(8765); + console.log(url); + const response = await fetch(url); + console.log(await response.text()); + } catch (error) { + throw error; + } finally { + await instance.terminate(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/examples/sandbox-snapshot.ts b/examples/sandbox-snapshot.ts new file mode 100644 index 0000000..7fc132a --- /dev/null +++ b/examples/sandbox-snapshot.ts @@ -0,0 +1,40 @@ +import { beamOpts, Image, Sandbox } from "../lib"; + +beamOpts.token = process.env.BEAM_TOKEN || ""; +beamOpts.gatewayUrl = process.env.BEAM_GATEWAY_URL || "https://app.beam.cloud"; + +async function main() { + const sandbox = new Sandbox({ + name: "js-sandbox-snapshot", + image: Image.fromRegistry("python:3.11-slim"), + cpu: 1, + memory: "512Mi", + keepWarmSeconds: 300, + }); + + const instance = await sandbox.create(); + let restored; + try { + await instance.exec([ + "python3", + "-u", + "-c", + "from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler; ThreadingHTTPServer(('0.0.0.0', 8899), SimpleHTTPRequestHandler).serve_forever()", + ]); + + const checkpointId = await instance.snapshot(); + restored = await Sandbox.createFromSnapshot(checkpointId); + const url = await restored.exposePort(8899); + console.log(url); + } finally { + if (restored) { + await restored.terminate(); + } + await instance.terminate(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/jest.config.ts b/jest.config.ts index 745667a..e41ca6d 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -95,7 +95,7 @@ const config: Config = { // moduleNameMapper: {}, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader - // modulePathIgnorePatterns: [], + modulePathIgnorePatterns: ["/dist"], // Activates notifications for test results // notify: false, @@ -163,9 +163,7 @@ const config: Config = { // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "/node_modules/" - // ], + testPathIgnorePatterns: ["/node_modules/", "/dist/"], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], diff --git a/lib/index.ts b/lib/index.ts index f7ca041..b3e936e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -3,7 +3,7 @@ import { camelCaseToSnakeCaseKeys } from "./util"; export interface BeamClientOpts { token: string; - workspaceId: string; + workspaceId?: string; gatewayUrl?: string; timeout?: number; } @@ -25,9 +25,6 @@ class BeamClient { if (!beamOpts.gatewayUrl) { throw new Error("Beam gateway URL is not set"); } - if (!beamOpts.workspaceId) { - throw new Error("Beam workspace ID is not set"); - } if (!this._client) { this._client = axios.create({ @@ -79,6 +76,7 @@ export { Sandbox, SandboxInstance, SandboxFileSystem, + SandboxDockerManager, } from "./resources/abstraction/sandbox"; // Export Image classes and types diff --git a/lib/resources/abstraction/image.ts b/lib/resources/abstraction/image.ts index 1f68fa7..8edf25d 100644 --- a/lib/resources/abstraction/image.ts +++ b/lib/resources/abstraction/image.ts @@ -64,6 +64,7 @@ export class Image { this.config.pythonPackages = this._sanitizePythonPackages(pythonPackages); this.config.commands = commands; + this.config.buildSteps = buildSteps; this.config.baseImage = baseImage; this.config.baseImageCreds = this._processCredentials(baseImageCreds); this.config.envVars = envVars; @@ -298,6 +299,7 @@ export class Image { gpu: this.config.gpu, ignorePython: this.config.ignorePython, imageId: this.config.imageId, + buildSteps: this.config.buildSteps, }; const response = await this.verifyImageBuild(request); @@ -355,6 +357,7 @@ export class Image { secrets: this.config.secrets, gpu: this.config.gpu, ignorePython: this.config.ignorePython, + buildSteps: this.config.buildSteps, }; let lastResponse: BuildImageResponse = { success: false }; @@ -553,6 +556,31 @@ export class Image { return this; } + /** + * Install Docker Engine, Docker CLI, Compose, and Buildx in the image. + * + * Use this with `new Sandbox({ image, dockerEnabled: true })`. + */ + withDocker(): Image { + const dockerInstallCommands = [ + "apt-get update && apt-get install -y ca-certificates curl gnupg lsb-release", + "mkdir -p /etc/apt/keyrings && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg", + 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null', + "apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin", + "ln -sf /usr/libexec/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose", + "docker --version && docker compose version && docker-compose version", + "apt-get clean && rm -rf /var/lib/apt/lists/*", + ]; + + this.config.buildSteps.push( + ...dockerInstallCommands.map((command) => ({ + command, + type: "shell" as const, + })), + ); + return this; + } + /** * Sync files using FileSyncer */ diff --git a/lib/resources/abstraction/sandbox.ts b/lib/resources/abstraction/sandbox.ts index a3a72af..cdfe3bf 100644 --- a/lib/resources/abstraction/sandbox.ts +++ b/lib/resources/abstraction/sandbox.ts @@ -12,8 +12,10 @@ import type { PodSandboxListFilesResponse, PodSandboxCreateDirectoryResponse, PodSandboxListUrlsResponse, + PodSandboxListProcessesResponse, PodInstanceData, ExecOptions, + ProcessInfo, } from "../../types/pod"; import beamClient from "../.."; @@ -30,6 +32,91 @@ function shellQuote(arg: string): string { return `'${arg.replace(/'/g, "'\\''")}'`; } +const SANDBOX_TERMINAL_STATUSES = new Set([ + "complete", + "completed", + "exited", + "failed", + "error", + "stopped", + "terminated", + "timeout", +]); + +function isSandboxTerminalStatus(status: string): boolean { + return SANDBOX_TERMINAL_STATUSES.has((status || "").trim().toLowerCase()); +} + +function defaultSandboxExitCode(status: string): number { + const normalized = (status || "").trim().toLowerCase(); + if (normalized === "failed" || normalized === "error" || normalized === "timeout") { + return 1; + } + if (normalized === "terminated") { + return 137; + } + return 0; +} + +const TRANSIENT_SANDBOX_ERRORS = [ + "context deadline exceeded", + "connection refused", + "failed to connect to sandbox", + "i/o timeout", + "process manager is not ready", + "waiting for connections to become ready", +]; + +const DOCKER_SANDBOX_NAMESPACE_ARGS = ["--network", "host", "--pid", "host"]; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "object" && error !== null) { + const maybeAxios = error as { + response?: { data?: { errorMsg?: string; error_msg?: string; message?: string } }; + }; + return ( + maybeAxios.response?.data?.errorMsg || + maybeAxios.response?.data?.error_msg || + maybeAxios.response?.data?.message || + String(error) + ); + } + return String(error); +} + +function isTransientSandboxError(error: unknown): boolean { + const message = errorMessage(error).toLowerCase(); + return TRANSIENT_SANDBOX_ERRORS.some((pattern) => message.includes(pattern)); +} + +async function retryTransientSandboxCall( + fn: () => Promise, + timeoutMs: number = 120_000, + delayMs: number = 1_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + return await fn(); + } catch (error) { + if (!isTransientSandboxError(error)) { + throw error; + } + lastError = error; + await sleep(delayMs); + } + } + throw lastError; +} + /** * A sandboxed container for running code or arbitrary processes. * You can use this to create isolated environments where you can execute code, @@ -57,6 +144,8 @@ function shellQuote(arg: string): string { * - allowList (string[]): CIDR ranges that are allowed for outbound network access. When specified, * all other outbound traffic is blocked. * - authorized (boolean): Ignored for sandboxes (forced to false). + * - dockerEnabled (boolean): Start Docker-in-Docker support inside the sandbox. Pair with + * `Image.withDocker()`. */ export class Sandbox extends Pod { public syncLocalDir: boolean = false; @@ -289,6 +378,7 @@ export class Sandbox extends Pod { export class SandboxInstance extends PodInstance { public stubId: string; public fs: SandboxFileSystem; + public docker: SandboxDockerManager; public processes: Record = {}; public terminated: boolean = false; @@ -296,6 +386,7 @@ export class SandboxInstance extends PodInstance { super(data, pod); this.stubId = data.stubId; this.fs = new SandboxFileSystem(this); + this.docker = new SandboxDockerManager(this); } /** @@ -348,6 +439,57 @@ export class SandboxInstance extends PodInstance { return this.containerId; } + /** Get the current sandbox status as `[exitCode, status]`. */ + public async status(): Promise<[number, string]> { + const data = await retryTransientSandboxCall(async () => { + const resp = await beamClient.request({ + method: "GET", + url: `api/v1/gateway/pods/${this.containerId}/status`, + timeout: 300000, + }); + const response = resp.data as { + ok: boolean; + errorMsg?: string; + status?: string; + exitCode?: number; + }; + if (!response.ok) { + throw new SandboxProcessError(response.errorMsg || "Failed to get status"); + } + return response; + }); + return [data.exitCode ?? -1, data.status || ""]; + } + + /** Return the sandbox exit code if it has exited, otherwise null. */ + public async poll(): Promise { + const [exitCode, status] = await this.status(); + if (!isSandboxTerminalStatus(status)) { + return null; + } + if (exitCode < 0) { + return defaultSandboxExitCode(status); + } + return exitCode; + } + + /** Wait for the sandbox to exit and return its exit code. */ + public async wait(timeoutMs?: number): Promise { + const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs; + while (true) { + const exitCode = await this.poll(); + if (exitCode !== null) { + return exitCode; + } + if (deadline !== undefined && Date.now() >= deadline) { + throw new SandboxProcessError( + `Sandbox ${this.containerId} did not exit within ${timeoutMs}ms`, + ); + } + await new Promise((r) => setTimeout(r, 100)); + } + } + /** * Update the keep warm setting of the sandbox. * @@ -355,7 +497,7 @@ export class SandboxInstance extends PodInstance { */ public async updateTtl(ttl: number): Promise { const resp = await beamClient.request({ - method: "PATCH", + method: "POST", url: `/api/v1/gateway/pods/${this.containerId}/ttl`, data: { ttl }, }); @@ -483,8 +625,18 @@ export class SandboxInstance extends PodInstance { command: string | string[], opts?: ExecOptions, ): Promise { - const commandList = Array.isArray(command) ? command : [command]; - return this._exec(commandList, opts); + if (typeof command === "string") { + return this.execShell(command, opts); + } + return this._exec(command, opts); + } + + /** Run shell text in the sandbox using `sh -lc`. */ + public async execShell( + command: string, + opts?: ExecOptions, + ): Promise { + return this._exec(["sh", "-lc", command], opts); } private async _exec( @@ -515,8 +667,47 @@ export class SandboxInstance extends PodInstance { return process; } - /** List all processes running in the sandbox. */ - public listProcesses(): SandboxProcess[] { + /** List locally-known process handles without contacting the server. */ + public localProcesses(): SandboxProcess[] { + return Object.values(this.processes); + } + + /** List all processes from server state so reconnects work. */ + public async listProcesses(): Promise { + const resp = await beamClient.request({ + method: "GET", + url: `api/v1/gateway/pods/${this.containerId}/processes`, + }); + const data = resp.data as PodSandboxListProcessesResponse; + if (!data.ok) { + throw new SandboxProcessError(data.errorMsg || "Failed to list processes"); + } + + const processInfos: ProcessInfo[] = data.processes || []; + if (!processInfos.length && data.pids?.length) { + processInfos.push( + ...data.pids.map((pid) => ({ + pid, + running: true, + exitCode: -1, + cmd: "", + cwd: "", + env: [], + })), + ); + } + + this.processes = Object.fromEntries( + processInfos.map((info) => { + const process = this.processes[info.pid] || new SandboxProcess(this, info.pid); + process.running = info.running; + process.exitCode = info.running ? -1 : Number(info.exitCode ?? 0); + process.command = info.cmd; + process.cwd = info.cwd; + process.env = info.env || []; + return [info.pid, process]; + }), + ); return Object.values(this.processes); } @@ -674,6 +865,10 @@ export class SandboxProcess { public sandbox_instance: SandboxInstance; public pid: number; public exitCode: number = -1; + public running?: boolean; + public command?: string; + public cwd?: string; + public env?: string[]; private _status: string = ""; constructor(sandboxInstance: SandboxInstance, pid: number) { @@ -705,32 +900,48 @@ export class SandboxProcess { /** Get the status of the process: [exitCode, status]. */ public async status(): Promise<[number, string]> { - const resp = await beamClient.request({ - method: "GET", - url: `api/v1/gateway/pods/${this.sandbox_instance.containerId}/status`, - params: { pid: this.pid }, - timeout: 300000, + const data = await retryTransientSandboxCall(async () => { + const resp = await beamClient.request({ + method: "GET", + url: `api/v1/gateway/pods/${this.sandbox_instance.containerId}/status`, + params: { pid: this.pid }, + timeout: 300000, + }); + const response = resp.data as { + ok: boolean; + errorMsg?: string; + status?: string; + exitCode?: number; + }; + if (!response.ok) { + throw new SandboxProcessError(response.errorMsg || "Failed to get status"); + } + return response; }); - const data = resp.data as { - ok: boolean; - errorMsg?: string; - status?: string; - exitCode?: number; - }; - if (!data.ok) - throw new SandboxProcessError(data.errorMsg || "Failed to get status"); return [data.exitCode ?? -1, data.status || ""]; } /** Get a handle to a stream of the process's stdout. */ public get stdout(): SandboxProcessStream { return new SandboxProcessStream(this, async () => { - const resp = await beamClient.request({ - method: "GET", - url: `api/v1/gateway/pods/${this.sandbox_instance.containerId}/stdout`, - params: { pid: this.pid }, + const data = await retryTransientSandboxCall(async () => { + const resp = await beamClient.request({ + method: "GET", + url: `api/v1/gateway/pods/${this.sandbox_instance.containerId}/stdout`, + params: { pid: this.pid }, + }); + const response = resp.data as { + ok: boolean; + errorMsg?: string; + stdout?: string; + }; + if (!response.ok) { + throw new SandboxProcessError( + response.errorMsg || "Failed to read stdout", + ); + } + return response; }); - const data = resp.data as { ok: boolean; stdout?: string }; return data.stdout || ""; }); } @@ -738,12 +949,24 @@ export class SandboxProcess { /** Get a handle to a stream of the process's stderr. */ public get stderr(): SandboxProcessStream { return new SandboxProcessStream(this, async () => { - const resp = await beamClient.request({ - method: "GET", - url: `api/v1/gateway/pods/${this.sandbox_instance.containerId}/stderr`, - params: { pid: this.pid }, + const data = await retryTransientSandboxCall(async () => { + const resp = await beamClient.request({ + method: "GET", + url: `api/v1/gateway/pods/${this.sandbox_instance.containerId}/stderr`, + params: { pid: this.pid }, + }); + const response = resp.data as { + ok: boolean; + errorMsg?: string; + stderr?: string; + }; + if (!response.ok) { + throw new SandboxProcessError( + response.errorMsg || "Failed to read stderr", + ); + } + return response; }); - const data = resp.data as { ok: boolean; stderr?: string }; return data.stderr || ""; }); } @@ -835,6 +1058,72 @@ export class SandboxProcess { } } +/** Thin Docker helpers for Docker-enabled sandboxes. */ +export class SandboxDockerManager { + private daemonReady = false; + + constructor(private sandboxInstance: SandboxInstance) {} + + public async ensureReady(timeoutMs: number = 60_000): Promise { + if (this.daemonReady) { + return; + } + + const deadline = Date.now() + timeoutMs; + let lastError = ""; + while (Date.now() < deadline) { + try { + const process = await this.sandboxInstance.exec(["docker", "info"]); + const exitCode = await process.wait(); + if (exitCode === 0) { + this.daemonReady = true; + return; + } + lastError = await process.stderr.read(); + } catch (error) { + lastError = errorMessage(error); + } + await sleep(1_000); + } + + throw new SandboxProcessError( + `Docker daemon did not become ready within ${timeoutMs}ms${ + lastError ? `: ${lastError}` : "" + }`, + ); + } + + public async exec(args: string[], opts?: ExecOptions): Promise { + await this.ensureReady(); + return this.sandboxInstance.exec(["docker", ...args], opts); + } + + public async run( + image: string, + args: string[] = [], + opts?: ExecOptions, + ): Promise { + return this.exec(["run", ...DOCKER_SANDBOX_NAMESPACE_ARGS, ...args, image], opts); + } + + public async compose(args: string[], opts?: ExecOptions): Promise { + await this.ensureReady(); + return this.sandboxInstance.exec(["docker", "compose", ...args], opts); + } + + public async composeStandalone( + args: string[], + opts?: ExecOptions, + ): Promise { + await this.ensureReady(); + return this.sandboxInstance.exec(["docker-compose", ...args], opts); + } + + public async version(opts?: ExecOptions): Promise { + return this.exec(["version"], opts); + } +} + /** Metadata of a file in the sandbox. */ export class SandboxFileInfo { public name: string; @@ -934,6 +1223,40 @@ export class SandboxFileSystem { ); } + /** Write bytes to a file in the sandbox. */ + public async writeBytes( + sandboxPath: string, + data: Buffer | Uint8Array | string, + mode: number = 0o644, + ): Promise { + const content = typeof data === "string" ? Buffer.from(data) : Buffer.from(data); + const resp = await beamClient.request({ + method: "POST", + url: `api/v1/gateway/pods/${this.sandbox_instance.containerId}/files/upload`, + data: { + containerPath: sandboxPath, + mode, + data: content.toString("base64"), + }, + }); + const response = resp.data as { ok: boolean; errorMsg?: string }; + if (!response.ok) { + throw new SandboxFileSystemError( + response.errorMsg || "Failed to write file", + ); + } + } + + /** Write text to a file in the sandbox. */ + public async writeText( + sandboxPath: string, + text: string, + encoding: BufferEncoding = "utf8", + mode: number = 0o644, + ): Promise { + await this.writeBytes(sandboxPath, Buffer.from(text, encoding), mode); + } + /** Download a file from the sandbox to a local path. */ public async downloadFile( sandboxPath: string, @@ -954,6 +1277,29 @@ export class SandboxFileSystem { fs.writeFileSync(localPath, buf); } + /** Read bytes from a file in the sandbox. */ + public async readBytes(sandboxPath: string): Promise { + const resp = await beamClient.request({ + method: "GET", + url: `api/v1/gateway/pods/${ + this.sandbox_instance.containerId + }/files/download/${encodeURIComponent(sandboxPath)}`, + }); + const data = resp.data as { ok: boolean; errorMsg?: string; data?: string }; + if (!data.ok || !data.data) { + throw new SandboxFileSystemError(data.errorMsg || "Failed to read file"); + } + return Buffer.from(data.data, "base64"); + } + + /** Read text from a file in the sandbox. */ + public async readText( + sandboxPath: string, + encoding: BufferEncoding = "utf8", + ): Promise { + return (await this.readBytes(sandboxPath)).toString(encoding); + } + /** Get the metadata of a file in the sandbox. */ public async statFile(sandboxPath: string): Promise { const resp = await beamClient.request({ @@ -1049,6 +1395,16 @@ export class SandboxFileSystem { ); } + /** Remove a file or directory from the sandbox. */ + public async remove(sandboxPath: string): Promise { + const info = await this.statFile(sandboxPath); + if (info.isDir) { + await this.deleteDirectory(sandboxPath); + return; + } + await this.deleteFile(sandboxPath); + } + /** Replace a string in all files in a directory. */ public async replaceInFiles( sandboxPath: string, diff --git a/lib/resources/abstraction/stub.ts b/lib/resources/abstraction/stub.ts index 1f25bfe..fd4ee5f 100644 --- a/lib/resources/abstraction/stub.ts +++ b/lib/resources/abstraction/stub.ts @@ -54,6 +54,7 @@ export interface StubConfig { tcp: boolean; blockNetwork: boolean; allowList?: string[]; + dockerEnabled: boolean; } export interface CreateStubConfig extends Partial { @@ -115,6 +116,7 @@ export class StubBuilder { tcp = false, blockNetwork = false, allowList = undefined, + dockerEnabled = false, }: CreateStubConfig) { this.config = {} as StubConfig; this.config.name = name; @@ -144,6 +146,7 @@ export class StubBuilder { this.config.outputs = outputs; this.config.blockNetwork = blockNetwork; this.config.allowList = allowList; + this.config.dockerEnabled = dockerEnabled; if (this.config.blockNetwork && this.config.allowList !== undefined) { throw new Error( @@ -362,6 +365,7 @@ export class StubBuilder { tcp: this.config.tcp, blockNetwork: this.config.blockNetwork, allowList: this.config.allowList, + dockerEnabled: this.config.dockerEnabled, }; try { diff --git a/lib/types/pod.ts b/lib/types/pod.ts index f42fd3f..604b6c5 100644 --- a/lib/types/pod.ts +++ b/lib/types/pod.ts @@ -108,7 +108,17 @@ export interface PodSandboxListProcessesRequest { export interface PodSandboxListProcessesResponse { ok: boolean; errorMsg: string; - pids: number[]; + pids?: number[]; + processes?: ProcessInfo[]; +} + +export interface ProcessInfo { + running: boolean; + pid: number; + cmd: string; + cwd: string; + env: string[]; + exitCode: number; } export interface PodSandboxUploadFileRequest { diff --git a/lib/types/stub.ts b/lib/types/stub.ts index ad3d09f..cbd5152 100644 --- a/lib/types/stub.ts +++ b/lib/types/stub.ts @@ -74,6 +74,7 @@ export interface GetOrCreateStubRequest { tcp: boolean; blockNetwork: boolean; allowList?: string[]; + dockerEnabled: boolean; } export interface GetOrCreateStubResponse { diff --git a/package.json b/package.json index db63671..3eb03f6 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "scripts": { "prebuild": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", "build": "tsc-multi && node ./scripts/make-dist-package-json.cjs > dist/package.json && cp README.md dist/README.md && cp LICENSE dist/LICENSE", - "test": "jest" + "test": "jest", + "test:e2e:sandbox": "BEAM_SANDBOX_E2E=1 jest tests/sandbox.e2e.test.ts --runInBand --detectOpenHandles --forceExit" }, "dependencies": { "archiver": "^7.0.1", diff --git a/tests/image-docker.test.ts b/tests/image-docker.test.ts new file mode 100644 index 0000000..21eed6d --- /dev/null +++ b/tests/image-docker.test.ts @@ -0,0 +1,37 @@ +import { Image } from "../lib/resources/abstraction/image"; +import { SandboxDockerManager } from "../lib/resources/abstraction/sandbox"; + +describe("Image.withDocker", () => { + test("adds Docker installation build steps", () => { + const image = new Image({ pythonVersion: "python3.11" }).withDocker(); + + expect(image.config.buildSteps.length).toBeGreaterThan(0); + expect(image.config.buildSteps.map((step) => step.type)).toEqual( + image.config.buildSteps.map(() => "shell"), + ); + expect(image.config.buildSteps.map((step) => step.command).join("\n")).toContain( + "docker-ce", + ); + }); +}); + +describe("SandboxDockerManager", () => { + test("runs inner containers with host network and pid namespace", async () => { + const exec = jest.fn().mockResolvedValue({ wait: jest.fn().mockResolvedValue(0) }); + const manager = new SandboxDockerManager({ exec } as any); + jest.spyOn(manager, "ensureReady").mockResolvedValue(undefined); + + await manager.run("hello-world", ["--rm"]); + + expect(exec).toHaveBeenCalledWith([ + "docker", + "run", + "--network", + "host", + "--pid", + "host", + "--rm", + "hello-world", + ], undefined); + }); +}); diff --git a/tests/sandbox-filesystem.test.ts b/tests/sandbox-filesystem.test.ts new file mode 100644 index 0000000..5ff166b --- /dev/null +++ b/tests/sandbox-filesystem.test.ts @@ -0,0 +1,123 @@ +import beamClient from "../lib"; +import { Sandbox, SandboxInstance } from "../lib/resources/abstraction/sandbox"; + +describe("Sandbox filesystem convenience methods", () => { + beforeEach(() => { + jest.spyOn(console, "log").mockImplementation(() => undefined); + jest.spyOn(console, "warn").mockImplementation(() => undefined); + jest.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function instance() { + return new SandboxInstance( + { + containerId: "sandbox-123", + stubId: "stub-123", + url: "", + ok: true, + errorMsg: "", + }, + new Sandbox({ name: "filesystem-sandbox" }), + ); + } + + test("writes and reads text and bytes", async () => { + const requestMock = jest + .spyOn(beamClient, "request") + .mockResolvedValueOnce({ data: { ok: true, errorMsg: "" } }) + .mockResolvedValueOnce({ data: { ok: true, errorMsg: "" } }) + .mockResolvedValueOnce({ + data: { + ok: true, + errorMsg: "", + data: Buffer.from("hello").toString("base64"), + }, + }); + + const fs = instance().fs; + await fs.writeText("/workspace/message.txt", "hello"); + await fs.writeBytes("/workspace/blob.bin", Buffer.from([0, 1])); + await expect(fs.readText("/workspace/message.txt")).resolves.toBe("hello"); + + expect(requestMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + data: expect.objectContaining({ + containerPath: "/workspace/message.txt", + data: Buffer.from("hello").toString("base64"), + }), + }), + ); + expect(requestMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + data: expect.objectContaining({ + containerPath: "/workspace/blob.bin", + data: Buffer.from([0, 1]).toString("base64"), + }), + }), + ); + }); + + test("removes files and directories", async () => { + const requestMock = jest + .spyOn(beamClient, "request") + .mockResolvedValueOnce({ + data: { + ok: true, + errorMsg: "", + fileInfo: { + name: "message.txt", + isDir: false, + size: 1, + mode: 0o644, + modTime: 0, + owner: "", + group: "", + permissions: 0o644, + }, + }, + }) + .mockResolvedValueOnce({ data: { ok: true, errorMsg: "" } }) + .mockResolvedValueOnce({ + data: { + ok: true, + errorMsg: "", + fileInfo: { + name: "data", + isDir: true, + size: 0, + mode: 0o755, + modTime: 0, + owner: "", + group: "", + permissions: 0o755, + }, + }, + }) + .mockResolvedValueOnce({ data: { ok: true, errorMsg: "" } }); + + const fs = instance().fs; + await fs.remove("/workspace/message.txt"); + await fs.remove("/workspace/data"); + + expect(requestMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "DELETE", + url: "api/v1/gateway/pods/sandbox-123/files/%2Fworkspace%2Fmessage.txt", + }), + ); + expect(requestMock).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + method: "DELETE", + url: "api/v1/gateway/pods/sandbox-123/directories/%2Fworkspace%2Fdata", + }), + ); + }); +}); diff --git a/tests/sandbox-network.test.ts b/tests/sandbox-network.test.ts index 92ae313..fd04b5f 100644 --- a/tests/sandbox-network.test.ts +++ b/tests/sandbox-network.test.ts @@ -91,6 +91,36 @@ describe("Sandbox network parity", () => { }); }); + test("updates ttl with the sandbox ttl endpoint", async () => { + const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { + ok: true, + errorMsg: "", + }, + }); + + const instance = new SandboxInstance( + { + containerId: "sandbox-123", + stubId: "stub-123", + url: "", + ok: true, + errorMsg: "", + }, + new Sandbox({ name: "networked-sandbox" }) + ); + + await expect(instance.updateTtl(120)).resolves.toBeUndefined(); + + expect(requestMock).toHaveBeenCalledWith({ + method: "POST", + url: "/api/v1/gateway/pods/sandbox-123/ttl", + data: { + ttl: 120, + }, + }); + }); + test("rejects conflicting network permission updates before making a request", async () => { const requestMock = jest.spyOn(beamClient, "request"); @@ -165,6 +195,74 @@ describe("Sandbox network parity", () => { 8080: "https://8080.example.com", }); }); + + test("lists processes from server state", async () => { + jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { + ok: true, + processes: [ + { + pid: 42, + running: true, + exitCode: -1, + cmd: "sleep 60", + cwd: "/workspace", + env: ["A=B"], + }, + ], + errorMsg: "", + }, + }); + + const instance = new SandboxInstance( + { + containerId: "sandbox-123", + stubId: "stub-123", + url: "", + ok: true, + errorMsg: "", + }, + new Sandbox({ name: "networked-sandbox" }) + ); + + const processes = await instance.listProcesses(); + expect(processes).toHaveLength(1); + expect(processes[0].pid).toBe(42); + expect(processes[0].command).toBe("sleep 60"); + expect(instance.getProcess(42)).toBe(processes[0]); + }); + + test("runs string commands through the shell", async () => { + const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { + ok: true, + pid: 7, + }, + }); + + const instance = new SandboxInstance( + { + containerId: "sandbox-123", + stubId: "stub-123", + url: "", + ok: true, + errorMsg: "", + }, + new Sandbox({ name: "networked-sandbox" }) + ); + + await instance.exec("echo hello && pwd"); + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: "/api/v1/gateway/pods/sandbox-123/exec", + data: expect.objectContaining({ + command: "'sh' '-lc' 'echo hello && pwd'", + }), + }) + ); + }); }); describe("prepareRuntime surfaces real errors via lastError", () => { diff --git a/tests/sandbox.e2e.test.ts b/tests/sandbox.e2e.test.ts new file mode 100644 index 0000000..0e3f98e --- /dev/null +++ b/tests/sandbox.e2e.test.ts @@ -0,0 +1,261 @@ +import { randomUUID } from "crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { beamOpts, Image, Sandbox } from "../lib"; + +const runE2E = process.env.BEAM_SANDBOX_E2E === "1" ? describe : describe.skip; +const runDockerE2E = + process.env.BEAM_SANDBOX_E2E_DOCKER === "1" ? test : test.skip; + +function configureBeam() { + beamOpts.token = process.env.BEAM_TOKEN || ""; + beamOpts.workspaceId = process.env.BEAM_WORKSPACE_ID || ""; + beamOpts.gatewayUrl = process.env.BEAM_GATEWAY_URL || "https://app.beam.cloud"; + beamOpts.timeout = 600_000; + + if (!beamOpts.token) { + throw new Error("BEAM_TOKEN is required for sandbox e2e tests"); + } +} + +async function expectEventually( + fn: () => Promise, + timeoutMs: number = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await fn(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } + throw lastError; +} + +runE2E("Sandbox production e2e", () => { + beforeAll(configureBeam); + + jest.setTimeout(900_000); + + test("runs code, streams logs, manages files, exposes ports, reconnects, and terminates", async () => { + const runId = randomUUID().slice(0, 8); + const sandbox = new Sandbox({ + name: `js-e2e-core-${runId}`, + app: "js-sdk-sandbox-e2e", + image: Image.fromRegistry("python:3.11-slim"), + cpu: 1, + memory: "512Mi", + keepWarmSeconds: 300, + env: { BEAM_JS_E2E_RUN: runId }, + }); + + const instance = await sandbox.create(); + let completed = false; + try { + await expect(instance.poll()).resolves.toBeNull(); + + const result = await instance.runCode( + "import os; print('run=' + os.environ['BEAM_JS_E2E_RUN'])", + ); + expect("result" in result ? result.exitCode : -1).toBe(0); + expect("result" in result ? result.stdout : "").toContain(`run=${runId}`); + + await instance.fs.createDirectory("/workspace/js-e2e"); + await instance.fs.writeText("/workspace/js-e2e/message.txt", "hello js sdk"); + await expect( + instance.fs.readText("/workspace/js-e2e/message.txt"), + ).resolves.toBe("hello js sdk"); + + const tmp = mkdtempSync(join(tmpdir(), "beam-js-e2e-")); + try { + const uploadPath = join(tmp, "upload.txt"); + const downloadPath = join(tmp, "download.txt"); + writeFileSync(uploadPath, "uploaded from node"); + await instance.fs.uploadFile(uploadPath, "/workspace/js-e2e/upload.txt"); + await instance.fs.downloadFile("/workspace/js-e2e/upload.txt", downloadPath); + expect(readFileSync(downloadPath, "utf8")).toBe("uploaded from node"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + const stat = await instance.fs.statFile("/workspace/js-e2e/message.txt"); + expect(stat.isDir).toBe(false); + expect(stat.size).toBeGreaterThan(0); + + const files = await instance.fs.listFiles("/workspace/js-e2e"); + expect(files.map((file) => file.name)).toContain("message.txt"); + + await instance.fs.replaceInFiles("/workspace/js-e2e", "hello", "goodbye"); + const matches = await instance.fs.findInFiles("/workspace/js-e2e", "goodbye"); + expect(matches.length).toBeGreaterThan(0); + await expect( + instance.fs.readText("/workspace/js-e2e/message.txt"), + ).resolves.toContain("goodbye"); + + const logProcess = await instance.exec([ + "python3", + "-u", + "-c", + [ + "import sys,time", + "print('stdout-one', flush=True)", + "print('stderr-one', file=sys.stderr, flush=True)", + "time.sleep(0.5)", + "print('stdout-two', flush=True)", + "print('stderr-two', file=sys.stderr, flush=True)", + ].join("; "), + ]); + + const streamed: string[] = []; + for await (const line of logProcess.logs) { + streamed.push(line); + } + await expect(logProcess.wait()).resolves.toBe(0); + expect(streamed.join("")).toContain("stdout-one"); + expect(streamed.join("")).toContain("stderr-two"); + + const readProcess = await instance.exec([ + "python3", + "-u", + "-c", + [ + "import sys", + "print('stdout-read', flush=True)", + "print('stderr-read', file=sys.stderr, flush=True)", + ].join("; "), + ]); + await expect(readProcess.wait()).resolves.toBe(0); + await expect(readProcess.stdout.read()).resolves.toContain("stdout-read"); + await expect(readProcess.stderr.read()).resolves.toContain("stderr-read"); + + const shellProcess = await instance.exec("printf shell-ok"); + await expect(shellProcess.wait()).resolves.toBe(0); + await expect(shellProcess.stdout.read()).resolves.toBe("shell-ok"); + + const sleepProcess = await instance.exec(["sleep", "120"]); + const listed = await instance.listProcesses(); + expect(listed.some((process) => process.pid === sleepProcess.pid)).toBe(true); + + const reconnected = await Sandbox.connect(instance.sandboxId); + const reconnectedProcesses = await reconnected.listProcesses(); + expect( + reconnectedProcesses.some((process) => process.pid === sleepProcess.pid), + ).toBe(true); + + await sleepProcess.kill(); + await expect(sleepProcess.wait()).resolves.not.toBe(0); + + const server = await instance.exec([ + "python3", + "-u", + "-m", + "http.server", + "8765", + "--bind", + "0.0.0.0", + ]); + const url = await instance.exposePort(8765); + const urls = await instance.listUrls(); + expect(urls[8765]).toBe(url); + + await expectEventually(async () => { + const response = await fetch(url); + expect(response.ok).toBe(true); + expect(await response.text()).toContain("Directory listing"); + }); + + await server.kill(); + await instance.fs.remove("/workspace/js-e2e/message.txt"); + await instance.fs.remove("/workspace/js-e2e/upload.txt"); + await instance.fs.remove("/workspace/js-e2e"); + await instance.updateTtl(120); + await expect(instance.updateNetworkPermissions(false)).resolves.toBeUndefined(); + completed = true; + } finally { + const terminated = await instance.terminate(); + if (completed) { + expect(terminated).toBe(true); + } + } + }); + + test("restores a memory snapshot and serves restored HTTP state", async () => { + const runId = randomUUID().slice(0, 8); + const sandbox = new Sandbox({ + name: `js-e2e-snapshot-${runId}`, + app: "js-sdk-sandbox-e2e", + image: Image.fromRegistry("python:3.11-slim"), + cpu: 1, + memory: "512Mi", + keepWarmSeconds: 300, + }); + + const instance = await sandbox.create(); + let restored: Awaited> | undefined; + try { + const code = [ + "from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer", + `STATE = {'value': '${runId}'}`, + "class Handler(BaseHTTPRequestHandler):", + " def do_GET(self):", + " self.send_response(200)", + " self.end_headers()", + " self.wfile.write(('state=' + STATE['value']).encode())", + " def log_message(self, *args): pass", + "ThreadingHTTPServer(('0.0.0.0', 8899), Handler).serve_forever()", + ].join("\n"); + + await instance.exec(["python3", "-u", "-c", code]); + const url = await instance.exposePort(8899); + await expectEventually(async () => { + const response = await fetch(url); + expect(await response.text()).toBe(`state=${runId}`); + }); + + const checkpointId = await instance.snapshot(); + restored = await Sandbox.createFromSnapshot(checkpointId); + const restoredUrl = await restored.exposePort(8899); + await expectEventually(async () => { + const response = await fetch(restoredUrl); + expect(await response.text()).toBe(`state=${runId}`); + }, 120_000); + } finally { + if (restored) { + await restored.terminate(); + } + await instance.terminate(); + } + }); + + runDockerE2E("runs Docker commands in a Docker-enabled sandbox", async () => { + const runId = randomUUID().slice(0, 8); + const image = new Image({ pythonVersion: "python3.11" }).withDocker(); + const sandbox = new Sandbox({ + name: `js-e2e-docker-${runId}`, + app: "js-sdk-sandbox-e2e", + image, + cpu: 2, + memory: "2Gi", + keepWarmSeconds: 300, + dockerEnabled: true, + }); + + const instance = await sandbox.create(); + try { + const version = await instance.docker.version(); + await expect(version.wait()).resolves.toBe(0); + await expect(version.stdout.read()).resolves.toContain("Client:"); + + const hello = await instance.docker.run("hello-world", ["--rm"]); + await expect(hello.wait()).resolves.toBe(0); + await expect(hello.stdout.read()).resolves.toContain("Hello from Docker"); + } finally { + await instance.terminate(); + } + }); +}); From ff848f20e1bdbd87468ea1f4ed4353121c12450b Mon Sep 17 00:00:00 2001 From: Luke Lombardi <33990301+luke-lombardi@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:05:14 -0400 Subject: [PATCH 3/3] Optimize sandbox command startup --- lib/resources/abstraction/sandbox.ts | 79 +++++++++++++++++++++++----- lib/types/pod.ts | 7 +++ package-lock.json | 4 +- package.json | 2 +- tests/sandbox-network.test.ts | 78 +++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 16 deletions(-) diff --git a/lib/resources/abstraction/sandbox.ts b/lib/resources/abstraction/sandbox.ts index cdfe3bf..c1cd395 100644 --- a/lib/resources/abstraction/sandbox.ts +++ b/lib/resources/abstraction/sandbox.ts @@ -149,6 +149,7 @@ async function retryTransientSandboxCall( */ export class Sandbox extends Pod { public syncLocalDir: boolean = false; + private runtimePreparation?: Promise; constructor(config: CreateStubConfig, syncLocalDir: boolean = false) { super(config); @@ -280,12 +281,29 @@ export class Sandbox extends Pod { const ignorePatterns = this.syncLocalDir ? undefined : ["*"]; - const prepared = await this.stub.prepareRuntime( - undefined, - EStubType.Sandbox, - true, - ignorePatterns, - ); + if (!this.runtimePreparation) { + this.runtimePreparation = this.stub.prepareRuntime( + undefined, + EStubType.Sandbox, + true, + ignorePatterns, + ); + } + + const currentPreparation = this.runtimePreparation; + let prepared: boolean; + try { + prepared = await currentPreparation; + } catch (error) { + if (this.runtimePreparation === currentPreparation) { + this.runtimePreparation = undefined; + } + throw error; + } + + if (!prepared && this.runtimePreparation === currentPreparation) { + this.runtimePreparation = undefined; + } if (!prepared) { const detail = this.stub.lastError?.message ?? "unknown reason"; throw new SandboxConnectionError(`Failed to prepare runtime: ${detail}`); @@ -602,7 +620,11 @@ export class SandboxInstance extends PodInstance { cwd?: string, env?: Record, ): Promise { - const process = await this._exec(["python3", "-c", code], { cwd, env }); + const process = await this._exec(["python3", "-c", code], { + cwd, + env, + wait: blocking, + }); if (blocking) { await process.wait(); const [stdoutStr, stderrStr] = await Promise.all([ @@ -641,7 +663,7 @@ export class SandboxInstance extends PodInstance { private async _exec( command: string[] | string, - opts?: { cwd?: string; env?: Record }, + opts?: ExecOptions, ): Promise { const commandList = Array.isArray(command) ? command : [command]; const shellCommand = commandList @@ -655,6 +677,7 @@ export class SandboxInstance extends PodInstance { command: shellCommand, cwd: opts?.cwd, env: opts?.env, + wait: opts?.wait ?? false, }, }); const data = resp.data as PodSandboxExecResponse; @@ -662,7 +685,7 @@ export class SandboxInstance extends PodInstance { throw new SandboxProcessError(data.errorMsg || "Failed to start process"); } - const process = new SandboxProcess(this, data.pid); + const process = new SandboxProcess(this, data.pid, data); this.processes[data.pid] = process; return process; } @@ -756,9 +779,15 @@ export class SandboxProcessStream { constructor( process: SandboxProcess, fetchFn: () => Promise | string, + initialOutput?: string, ) { this.process = process; this.fetch_fn = fetchFn; + if (initialOutput !== undefined) { + this._buffer = initialOutput; + this._last_output = initialOutput; + this._closed = true; + } } public [Symbol.asyncIterator](): AsyncIterableIterator { @@ -844,6 +873,9 @@ export class SandboxProcessStream { public async read(): Promise { let data = this._buffer; this._buffer = ""; + if (this._closed) { + return data; + } while (true) { const chunk = await this._fetch_next_chunk(); if (chunk) { @@ -870,18 +902,39 @@ export class SandboxProcess { public cwd?: string; public env?: string[]; private _status: string = ""; + private _inlineDone: boolean = false; + private _inlineStdout: string = ""; + private _inlineStderr: string = ""; - constructor(sandboxInstance: SandboxInstance, pid: number) { + constructor( + sandboxInstance: SandboxInstance, + pid: number, + execResponse?: PodSandboxExecResponse, + ) { this.sandbox_instance = sandboxInstance; this.pid = pid; + if (execResponse?.done) { + this._inlineDone = true; + this.exitCode = execResponse.exitCode ?? 0; + this.running = false; + this._status = "done"; + this._inlineStdout = execResponse.stdout || ""; + this._inlineStderr = execResponse.stderr || ""; + } } /** Wait for the process to complete and return the exit code. */ public async wait(): Promise { + if (this.exitCode >= 0) { + return this.exitCode; + } + [this.exitCode, this._status] = await this.status(); while (this.exitCode < 0) { [this.exitCode, this._status] = await this.status(); - await new Promise((r) => setTimeout(r, 100)); + if (this.exitCode < 0) { + await new Promise((r) => setTimeout(r, 100)); + } } return this.exitCode; } @@ -943,7 +996,7 @@ export class SandboxProcess { return response; }); return data.stdout || ""; - }); + }, this._inlineDone ? this._inlineStdout : undefined); } /** Get a handle to a stream of the process's stderr. */ @@ -968,7 +1021,7 @@ export class SandboxProcess { return response; }); return data.stderr || ""; - }); + }, this._inlineDone ? this._inlineStderr : undefined); } /** Returns a combined stream of both stdout and stderr. */ diff --git a/lib/types/pod.ts b/lib/types/pod.ts index 604b6c5..5720a6f 100644 --- a/lib/types/pod.ts +++ b/lib/types/pod.ts @@ -50,12 +50,17 @@ export interface PodSandboxExecRequest { command: string; cwd?: string; env?: Record; + wait?: boolean; } export interface PodSandboxExecResponse { ok: boolean; errorMsg?: string; pid: number; + done?: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; } export interface PodSandboxStatusRequest { containerId: string; @@ -324,6 +329,8 @@ export interface PodSandboxCreateImageFromFilesystemResponse { export interface ExecOptions { cwd?: string; env?: Record; + /** Wait briefly for completion and return inline status/output when available. */ + wait?: boolean; } // Store requests here? diff --git a/package-lock.json b/package-lock.json index 325898a..f95c370 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@beamcloud/beam-js", - "version": "1.0.11", + "version": "1.0.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@beamcloud/beam-js", - "version": "1.0.11", + "version": "1.0.13", "license": "MIT", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index 3eb03f6..a7a3cd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@beamcloud/beam-js", - "version": "1.0.11", + "version": "1.0.13", "description": "Javascript SDK to interact with Beam", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/tests/sandbox-network.test.ts b/tests/sandbox-network.test.ts index fd04b5f..f258301 100644 --- a/tests/sandbox-network.test.ts +++ b/tests/sandbox-network.test.ts @@ -263,6 +263,84 @@ describe("Sandbox network parity", () => { }) ); }); + + test("shares runtime preparation across concurrent sandbox creates", async () => { + const sandbox = new Sandbox({ name: "concurrent-sandbox" }); + let releasePreparation!: (prepared: boolean) => void; + const preparation = new Promise((resolve) => { + releasePreparation = resolve; + }); + const prepareRuntimeMock = jest + .spyOn(sandbox.stub, "prepareRuntime") + .mockReturnValue(preparation); + let nextContainer = 0; + const requestMock = jest + .spyOn(beamClient, "request") + .mockImplementation(async (config) => { + if (config.url === "api/v1/gateway/pods") { + nextContainer += 1; + return { + data: { + ok: true, + containerId: `sandbox-${nextContainer}`, + }, + }; + } + if (config.url?.endsWith("/connect")) { + return { data: { ok: true } }; + } + throw new Error(`Unexpected request: ${config.url}`); + }); + + const firstCreate = sandbox.create(); + const secondCreate = sandbox.create(); + + expect(prepareRuntimeMock).toHaveBeenCalledTimes(1); + releasePreparation(true); + + const instances = await Promise.all([firstCreate, secondCreate]); + expect(instances.map((instance) => instance.containerId)).toEqual([ + "sandbox-1", + "sandbox-2", + ]); + expect(requestMock).toHaveBeenCalledTimes(4); + }); + + test("returns inline exec results without follow-up requests", async () => { + const requestMock = jest.spyOn(beamClient, "request").mockResolvedValue({ + data: { + ok: true, + pid: 7, + done: true, + exitCode: 0, + stdout: "v20.0.0\n", + stderr: "", + }, + }); + + const instance = new SandboxInstance( + { + containerId: "sandbox-123", + stubId: "stub-123", + url: "", + ok: true, + errorMsg: "", + }, + new Sandbox({ name: "networked-sandbox" }) + ); + + const process = await instance.exec(["node", "-v"], { wait: true }); + + await expect(process.wait()).resolves.toBe(0); + await expect(process.stdout.read()).resolves.toBe("v20.0.0\n"); + await expect(process.stderr.read()).resolves.toBe(""); + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ wait: true }), + }) + ); + }); }); describe("prepareRuntime surfaces real errors via lastError", () => {