From 6522c0e5468068d554c6fa7f82ab0cac4164bc28 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 00:43:16 +0200 Subject: [PATCH 01/10] feat(microsandbox): add Micro Sandbox provider adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `MicrosandboxRuntime` against both existing ports — the orchestration-plane `SandboxRuntime` and the bootstrap-plane `WorkflowRuntime` — on top of the `microsandbox` npm SDK (0.6.x), which is an optional peer dependency imported lazily so no other consumer pulls in its platform-specific native addon. Three provider facts break assumptions the other adapters make, and each one is handled explicitly rather than papered over: - Identity is a caller-chosen NAME capped at 128 UTF-8 bytes, not a server-assigned id. `RuntimeHandle.id` carries that name, and an over-long name is rejected with a typed error rather than truncated, because a truncated name would alias two sandboxes onto one identity. - Backend selection is process-wide global state. The adapter only ever uses the scoped `withDefaultBackend`, never `setDefaultBackend`, so constructing a runtime cannot mutate the host process and two runtimes on different backends can coexist. - The builder has no create-timeout setter. `createTimeoutSeconds` is enforced as a client-side boot deadline instead of being mapped onto `maxDuration`/`idleTimeout`, which are sandbox LIFETIME budgets — that mapping would kill every long-lived sandbox at the boot deadline. Async exec is durable rather than stream-bound: the SDK's `ExecHandle` is process-local with no pollable server-side id, so runs are backgrounded behind a wrapper that captures combined output and the final exit code to guest files, and poll ticks reattach by name via `Sandbox.get` + `connect` without taking lifecycle ownership. Capabilities are declared to match what this adapter actually exposes: `warmLease` and `lifecycle` are both real here (server-side label search with cursor pagination; `start`/`stop` genuinely resume and halt a microVM), unlike the E2B adapter where lifecycle is a no-op. No infrastructure defaults or credentials are baked in: backend, image or snapshot, and home directory are all required arguments. Tests: 106 mocked/contract cases covering every claim with paired must-fire and must-not-fire assertions, six checks pinning the structural SDK model against the installed package so drift fails loudly, and a live smoke gated off unless the operator supplies an image and a backend. Session-Id: 7b968751-05d0-4140-9259-47cc78659094 Session-Id: 6d810ec8-fbbc-43f2-8ef2-bc87744ca04f --- package-lock.json | 107 ++ package.json | 7 +- src/index.ts | 12 + src/microsandbox/runtime.test.ts | 1583 ++++++++++++++++++++++++++++++ src/microsandbox/runtime.ts | 1088 ++++++++++++++++++++ 5 files changed, 2796 insertions(+), 1 deletion(-) create mode 100644 src/microsandbox/runtime.test.ts create mode 100644 src/microsandbox/runtime.ts diff --git a/package-lock.json b/package-lock.json index 2f13c5f..a03b36d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@daytonaio/sdk": "^0.180.0", "@types/node": "^22", "e2b": "^2.35.0", + "microsandbox": "^0.6.11", "tsx": "^4.20.6", "typescript": "^5.9.3" }, @@ -1988,6 +1989,91 @@ "node": ">=18.0.0" } }, + "node_modules/@superradcompany/microsandbox-darwin-arm64": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@superradcompany/microsandbox-darwin-arm64/-/microsandbox-darwin-arm64-0.6.11.tgz", + "integrity": "sha512-adKE1t4s0DaqtW31NBRStImnWMYXZrcuNCVumTmtoYrHD16ae6jOgHJjR5MrOr3/zd/erC+UCl+7YQF/dvb3qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 22" + } + }, + "node_modules/@superradcompany/microsandbox-linux-arm64-gnu": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@superradcompany/microsandbox-linux-arm64-gnu/-/microsandbox-linux-arm64-gnu-0.6.11.tgz", + "integrity": "sha512-RuGXBlNekuXaZGzj0oaCJyzlYry6cj42q12XuHk+dOTuoZmtzPYGPxDjbFzC952AE7Tpgbv2qaGY1NQ5BKZV2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 22" + } + }, + "node_modules/@superradcompany/microsandbox-linux-x64-gnu": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@superradcompany/microsandbox-linux-x64-gnu/-/microsandbox-linux-x64-gnu-0.6.11.tgz", + "integrity": "sha512-j2KooQgcLSZrESFaoHvAg4P/HYMHPtwQ+NcExSxjcmAiHUYN5Zzgr39lOBiv539dEbtfFTkbqG4cAmkgECg9Mg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 22" + } + }, + "node_modules/@superradcompany/microsandbox-win32-arm64-msvc": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@superradcompany/microsandbox-win32-arm64-msvc/-/microsandbox-win32-arm64-msvc-0.6.11.tgz", + "integrity": "sha512-oIQKLP3zX11Ix+drq7o+uQc5kp9CXZyXy9kzDS18A9OkkPe3FWVSfBFUp4qMgUVS9oiYugP+Mr1qhlV8bScIoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 22" + } + }, + "node_modules/@superradcompany/microsandbox-win32-x64-msvc": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@superradcompany/microsandbox-win32-x64-msvc/-/microsandbox-win32-x64-msvc-0.6.11.tgz", + "integrity": "sha512-tfAjWiV7WOEtQRlAUAGFtt3soZLZE9n3VMDc38brGF50biOscPoE0iJKpEqdaffdNWlDd4Yrae1pzYpiGmgQrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 22" + } + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -2962,6 +3048,27 @@ "node": ">=8.6" } }, + "node_modules/microsandbox": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/microsandbox/-/microsandbox-0.6.11.tgz", + "integrity": "sha512-HdxUDayN5IQv/rtOha0NAG9C8kuR88fDA6e4UXBDNtg2wXO60uKYkiVKCjuaryvbUl5HUjCL0HavE1sAx3YUAg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "microsandbox": "bin/microsandbox.cjs", + "msb": "bin/microsandbox.cjs" + }, + "engines": { + "node": ">= 22" + }, + "optionalDependencies": { + "@superradcompany/microsandbox-darwin-arm64": "0.6.11", + "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.11", + "@superradcompany/microsandbox-linux-x64-gnu": "0.6.11", + "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.11", + "@superradcompany/microsandbox-win32-x64-msvc": "0.6.11" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", diff --git a/package.json b/package.json index d48f977..468e611 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ }, "peerDependencies": { "@daytonaio/sdk": ">=0.180.0 <0.181.0", - "e2b": ">=2.35.0 <3.0.0" + "e2b": ">=2.35.0 <3.0.0", + "microsandbox": ">=0.6.11 <0.7.0" }, "peerDependenciesMeta": { "@daytonaio/sdk": { @@ -34,12 +35,16 @@ }, "e2b": { "optional": true + }, + "microsandbox": { + "optional": true } }, "devDependencies": { "@daytonaio/sdk": "^0.180.0", "@types/node": "^22", "e2b": "^2.35.0", + "microsandbox": "^0.6.11", "tsx": "^4.20.6", "typescript": "^5.9.3" }, diff --git a/src/index.ts b/src/index.ts index 5270b22..07b86b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,18 @@ export type { E2BUploadBundleOptions, } from "./e2b/runtime.js"; +export { + MicrosandboxCreateTimeoutError, + MicrosandboxNameTooLongError, + MicrosandboxRuntime, +} from "./microsandbox/runtime.js"; +export type { + MicrosandboxBackend, + MicrosandboxRuntimeOptions, + MicrosandboxSdk, + MicrosandboxStatus, +} from "./microsandbox/runtime.js"; + export { LocalSandboxRuntime } from "./local/runtime.js"; export type { LocalSandboxRuntimeOptions } from "./local/runtime.js"; diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts new file mode 100644 index 0000000..0de7798 --- /dev/null +++ b/src/microsandbox/runtime.test.ts @@ -0,0 +1,1583 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { after, describe, it } from "node:test"; + +import * as pkg from "../index.js"; +import { + MicrosandboxCreateTimeoutError, + MicrosandboxNameTooLongError, + MicrosandboxRuntime, + resolveSandboxRuntimeCapabilities, +} from "../index.js"; +import type { + MicrosandboxBackend, + MicrosandboxRuntimeOptions, + MicrosandboxSdk, + SandboxRuntime, +} from "../index.js"; + +// --------------------------------------------------------------------------- +// Fake SDK harness. +// +// Every SDK call the adapter can make is recorded into one ordered log, so a +// test can assert both that something DID fire (with the right arguments) and +// that something did NOT — the second half being where the interesting +// regressions live. +// --------------------------------------------------------------------------- + +const HOME_DIR = "/home/agent"; +const IMAGE = "registry.example/test-image:1"; +const CLOUD_BACKEND: MicrosandboxBackend = { kind: "cloud", apiKey: "k-test-not-a-real-key" }; + +type LogEntry = { fn: string; args: unknown[] }; + +type ExecOutcome = { code?: number; stdout?: string; stderr?: string }; + +type SandboxStatus = "running" | "stopped" | "crashed" | "draining"; + +type HandleSpec = { + name: string; + status?: SandboxStatus; + createdAt?: Date | null; + updatedAt?: Date | null; + onStop?: () => Promise; + onKill?: () => Promise; + onRemove?: () => Promise; + onStart?: () => Promise; + onConnect?: () => Promise; +}; + +type HarnessConfig = { + /** Resolve exec output from the argv the adapter built. */ + exec?: (argv: string[]) => ExecOutcome | Promise; + /** Pages returned by successive `Sandbox.listWith` calls. */ + pages?: Array<{ sandboxes: HandleSpec[]; nextCursor?: string }>; + /** `Sandbox.get` resolution, by name. */ + get?: (name: string) => HandleSpec | null | Promise; + /** Delay/hang injected into `builder.create()`. */ + onCreate?: () => Promise; + /** Filesystem behaviour overrides. */ + fs?: { + onMkdir?: (path: string) => Promise; + onWrite?: (path: string, data: Uint8Array | string) => Promise; + onCopyFromHost?: (hostPath: string, guestPath: string) => Promise; + onCopyToHost?: (guestPath: string, hostPath: string) => Promise; + onRead?: (path: string) => Promise; + }; +}; + +function createHarness(config: HarnessConfig = {}) { + const log: LogEntry[] = []; + const rec = (fn: string, ...args: unknown[]) => { + log.push({ fn, args }); + }; + + let pageIndex = 0; + + const makeExecBuilder = () => { + const builder = { + args(args: string[]) { + rec("exec.args", args); + return builder; + }, + cwd(cwd: string) { + rec("exec.cwd", cwd); + return builder; + }, + envs(vars: Record) { + rec("exec.envs", vars); + return builder; + }, + timeout(ms: number) { + rec("exec.timeout", ms); + return builder; + }, + tty(enabled: boolean) { + rec("exec.tty", enabled); + return builder; + }, + }; + return builder; + }; + + const makeFs = () => ({ + async write(path: string, data: Uint8Array | string) { + rec("fs.write", path, data); + await config.fs?.onWrite?.(path, data); + }, + async read(path: string) { + rec("fs.read", path); + return (await config.fs?.onRead?.(path)) ?? new Uint8Array([1, 2, 3]); + }, + async readToString(path: string) { + rec("fs.readToString", path); + return ""; + }, + async mkdir(path: string) { + rec("fs.mkdir", path); + await config.fs?.onMkdir?.(path); + }, + async exists(path: string) { + rec("fs.exists", path); + return true; + }, + async copyFromHost(hostPath: string, guestPath: string) { + rec("fs.copyFromHost", hostPath, guestPath); + await config.fs?.onCopyFromHost?.(hostPath, guestPath); + }, + async copyToHost(guestPath: string, hostPath: string) { + rec("fs.copyToHost", guestPath, hostPath); + await config.fs?.onCopyToHost?.(guestPath, hostPath); + }, + }); + + const makeSandbox = (name: string) => { + const sandbox = { + name, + async exec(cmd: string, args?: Iterable) { + rec("sandbox.exec", cmd, args ? [...args] : undefined); + return makeOutput({}); + }, + async execWith( + cmd: string, + configure: (b: ReturnType) => ReturnType, + ) { + rec("sandbox.execWith", cmd); + const before = log.length; + configure(makeExecBuilder()); + const argv = (log + .slice(before) + .find((entry) => entry.fn === "exec.args")?.args[0] ?? []) as string[]; + const outcome = (await config.exec?.(argv)) ?? {}; + return makeOutput(outcome); + }, + fs: () => makeFs(), + }; + return sandbox; + }; + + const makeHandle = (spec: HandleSpec) => { + const handle = { + name: spec.name, + status: spec.status ?? ("running" as SandboxStatus), + createdAt: spec.createdAt ?? null, + updatedAt: spec.updatedAt ?? null, + async connect() { + rec("handle.connect", spec.name); + await spec.onConnect?.(); + return makeSandbox(spec.name); + }, + async connectWithTimeout(timeoutMs: number) { + rec("handle.connectWithTimeout", spec.name, timeoutMs); + await spec.onConnect?.(); + return makeSandbox(spec.name); + }, + async start() { + rec("handle.start", spec.name); + await spec.onStart?.(); + return makeSandbox(spec.name); + }, + async startDetached() { + rec("handle.startDetached", spec.name); + return makeSandbox(spec.name); + }, + async stop() { + rec("handle.stop", spec.name); + await spec.onStop?.(); + }, + async kill() { + rec("handle.kill", spec.name); + await spec.onKill?.(); + }, + async remove() { + rec("handle.remove", spec.name); + await spec.onRemove?.(); + }, + }; + return handle; + }; + + const makeSandboxBuilder = (name: string) => { + const builder = { + image(image: string) { + rec("builder.image", image); + return builder; + }, + fromSnapshot(pathOrName: string) { + rec("builder.fromSnapshot", pathOrName); + return builder; + }, + cpus(n: number) { + rec("builder.cpus", n); + return builder; + }, + memory(mib: number) { + rec("builder.memory", mib); + return builder; + }, + workdir(path: string) { + rec("builder.workdir", path); + return builder; + }, + envs(vars: Record) { + rec("builder.envs", vars); + return builder; + }, + labels(labels: Record) { + rec("builder.labels", labels); + return builder; + }, + detached(enabled: boolean) { + rec("builder.detached", enabled); + return builder; + }, + idleTimeout(secs: number) { + rec("builder.idleTimeout", secs); + return builder; + }, + maxDuration(secs: number) { + rec("builder.maxDuration", secs); + return builder; + }, + replace() { + rec("builder.replace"); + return builder; + }, + async create() { + rec("builder.create", name); + await config.onCreate?.(); + return makeSandbox(name); + }, + }; + return builder; + }; + + const sdk: MicrosandboxSdk = { + Sandbox: { + builder(name: string) { + rec("Sandbox.builder", name); + return makeSandboxBuilder(name) as never; + }, + async get(name: string) { + rec("Sandbox.get", name); + const spec = await config.get?.(name); + return spec ? (makeHandle(spec) as never) : null; + }, + async listWith(configure) { + const listBuilder = { + limit(limit: number) { + rec("list.limit", limit); + return listBuilder; + }, + cursor(cursor: string) { + rec("list.cursor", cursor); + return listBuilder; + }, + labels(labels: Record) { + rec("list.labels", labels); + return listBuilder; + }, + }; + rec("Sandbox.listWith"); + configure(listBuilder as never); + const page = config.pages?.[pageIndex] ?? { sandboxes: [] }; + pageIndex += 1; + return { + sandboxes: page.sandboxes.map((spec) => makeHandle(spec)) as never, + ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}), + }; + }, + }, + async withDefaultBackend(backend, fn) { + rec("withDefaultBackend:enter", backend); + try { + return await fn(); + } finally { + rec("withDefaultBackend:exit", backend); + } + }, + }; + + // `setDefaultBackend` is deliberately present on the fake so a test can prove + // the adapter never reaches for it. The adapter's typed SDK slice does not + // include it, so calling it would be a code change, not an accident. + const sdkWithGlobalSetter = Object.assign(sdk, { + setDefaultBackend(backend: MicrosandboxBackend) { + rec("setDefaultBackend", backend); + }, + }); + + return { sdk: sdkWithGlobalSetter, log }; +} + +function makeOutput(outcome: ExecOutcome) { + const code = outcome.code ?? 0; + return { + code, + success: code === 0, + stdout: () => outcome.stdout ?? "", + stderr: () => outcome.stderr ?? "", + }; +} + +function names(log: LogEntry[]): string[] { + return log.map((entry) => entry.fn); +} + +function called(log: LogEntry[], fn: string): boolean { + return log.some((entry) => entry.fn === fn); +} + +function argsFor(log: LogEntry[], fn: string): unknown[][] { + return log.filter((entry) => entry.fn === fn).map((entry) => entry.args); +} + +function firstArgs(log: LogEntry[], fn: string): unknown[] { + const found = log.find((entry) => entry.fn === fn); + assert.ok(found, `expected a "${fn}" call in: ${names(log).join(", ")}`); + return found.args; +} + +function makeRuntime( + sdk: MicrosandboxSdk, + overrides: Partial = {}, +): MicrosandboxRuntime { + return new MicrosandboxRuntime({ + backend: CLOUD_BACKEND, + image: IMAGE, + homeDir: HOME_DIR, + sdk, + ...overrides, + }); +} + +/** An SDK-shaped error carrying the SDK's typed `MicrosandboxErrorCode`. */ +function sdkError(code: string, message: string): Error & { code: string } { + return Object.assign(new Error(message), { code }); +} + +// --------------------------------------------------------------------------- + +describe("public barrel", () => { + it("exports the runtime and its typed errors", () => { + assert.equal(typeof pkg.MicrosandboxRuntime, "function"); + assert.equal(typeof pkg.MicrosandboxNameTooLongError, "function"); + assert.equal(typeof pkg.MicrosandboxCreateTimeoutError, "function"); + }); + + it("is structurally assignable to the SandboxRuntime port", () => { + const { sdk } = createHarness(); + const runtime: SandboxRuntime = makeRuntime(sdk); + assert.equal(runtime.id, "microsandbox"); + }); +}); + +describe("construction", () => { + it("requires a rootfs source", () => { + const { sdk } = createHarness(); + assert.throws( + () => new MicrosandboxRuntime({ backend: CLOUD_BACKEND, homeDir: HOME_DIR, sdk }), + /requires either `image` or `snapshot`/, + ); + }); + + it("rejects image and snapshot together", () => { + const { sdk } = createHarness(); + assert.throws( + () => + new MicrosandboxRuntime({ + backend: CLOUD_BACKEND, + image: IMAGE, + snapshot: "snap-1", + homeDir: HOME_DIR, + sdk, + }), + /not both/, + ); + }); + + it("touches no SDK surface at construction time", () => { + const { sdk, log } = createHarness(); + makeRuntime(sdk); + assert.deepEqual(log, [], "constructing a runtime must not call the SDK"); + }); +}); + +describe("backend scoping", () => { + it("runs every SDK call inside withDefaultBackend, with the configured backend", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launch({ name: "s1" }); + + const enter = log.findIndex((e) => e.fn === "withDefaultBackend:enter"); + const create = log.findIndex((e) => e.fn === "builder.create"); + const exit = log.findIndex((e) => e.fn === "withDefaultBackend:exit"); + assert.ok(enter >= 0 && create > enter && exit > create, names(log).join(", ")); + assert.deepEqual(firstArgs(log, "withDefaultBackend:enter"), [CLOUD_BACKEND]); + }); + + it("never mutates the process-wide default backend", async () => { + const { sdk, log } = createHarness({ + get: (name) => ({ name, status: "running" }), + }); + const runtime = makeRuntime(sdk); + await runtime.launch({ name: "s1" }); + await runtime.runScript({ id: "s1" }, { command: "true" }); + await runtime.destroy({ id: "s1" }); + + assert.equal( + called(log, "setDefaultBackend"), + false, + "setDefaultBackend would leak this runtime's backend onto the whole process", + ); + }); + + it("leaves the backend scope even when the SDK call throws", async () => { + const { sdk, log } = createHarness({ + onCreate: async () => { + throw new Error("boom"); + }, + }); + await assert.rejects(makeRuntime(sdk).launch({ name: "s1" }), /boom/); + assert.equal(called(log, "withDefaultBackend:exit"), true); + }); + + it("keeps two runtimes on separate backends", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk, { backend: "local" }).launch({ name: "a" }); + await makeRuntime(sdk, { backend: CLOUD_BACKEND }).launch({ name: "b" }); + + const entered = argsFor(log, "withDefaultBackend:enter").map((args) => args[0]); + assert.deepEqual(entered, ["local", CLOUD_BACKEND]); + }); +}); + +describe("sandbox naming (identity is a name, not a server id)", () => { + it("uses the caller's name as the handle id", async () => { + const { sdk, log } = createHarness(); + const handle = await makeRuntime(sdk).launch({ name: "issue-greeter" }); + assert.equal(handle.id, "issue-greeter"); + assert.deepEqual(firstArgs(log, "Sandbox.builder"), ["issue-greeter"]); + }); + + it("generates a name when the caller supplies none, honouring namePrefix", async () => { + const { sdk, log } = createHarness(); + const handle = await makeRuntime(sdk, { namePrefix: "agent-" }).launch(); + assert.match(handle.id, /^agent-[0-9a-f-]{36}$/); + assert.deepEqual(firstArgs(log, "Sandbox.builder"), [handle.id]); + }); + + it("rejects a name over the 128-byte cap instead of truncating it", async () => { + const { sdk, log } = createHarness(); + const tooLong = "n".repeat(129); + await assert.rejects( + makeRuntime(sdk).launch({ name: tooLong }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxNameTooLongError); + assert.equal(error.byteLength, 129); + assert.equal(error.maxByteLength, 128); + return true; + }, + ); + assert.equal( + called(log, "Sandbox.builder"), + false, + "an over-long name must be rejected before any SDK call", + ); + }); + + it("counts UTF-8 BYTES, not characters", async () => { + const { sdk } = createHarness(); + // 65 two-byte characters = 130 bytes but only 65 JS characters, so a + // length check on `.length` would wrongly let this through. + const multibyte = "é".repeat(65); + assert.equal(multibyte.length, 65); + assert.equal(Buffer.byteLength(multibyte, "utf8"), 130); + await assert.rejects( + makeRuntime(sdk).launch({ name: multibyte }), + MicrosandboxNameTooLongError, + ); + }); + + it("accepts a name at exactly the 128-byte boundary", async () => { + const { sdk } = createHarness(); + const exact = "n".repeat(128); + const handle = await makeRuntime(sdk).launch({ name: exact }); + assert.equal(handle.id, exact); + }); +}); + +describe("launch", () => { + it("boots from the configured image and never from a snapshot", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launch({ name: "s1" }); + assert.deepEqual(firstArgs(log, "builder.image"), [IMAGE]); + assert.equal(called(log, "builder.fromSnapshot"), false); + }); + + it("boots from a snapshot and never from an image when configured that way", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk, { image: undefined, snapshot: "snap-7" }).launch({ name: "s1" }); + assert.deepEqual(firstArgs(log, "builder.fromSnapshot"), ["snap-7"]); + assert.equal(called(log, "builder.image"), false); + }); + + it("applies every configured resource knob", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk, { + cpus: 4, + memoryMiB: 2048, + idleTimeoutSeconds: 300, + maxDurationSeconds: 3600, + workdir: "/workspace", + }).launch({ name: "s1", env: { A: "1" }, labels: { purpose: "test" } }); + + assert.deepEqual(firstArgs(log, "builder.cpus"), [4]); + assert.deepEqual(firstArgs(log, "builder.memory"), [2048]); + assert.deepEqual(firstArgs(log, "builder.idleTimeout"), [300]); + assert.deepEqual(firstArgs(log, "builder.maxDuration"), [3600]); + assert.deepEqual(firstArgs(log, "builder.workdir"), ["/workspace"]); + assert.deepEqual(firstArgs(log, "builder.envs"), [{ A: "1" }]); + assert.deepEqual(firstArgs(log, "builder.labels"), [{ purpose: "test" }]); + }); + + it("omits every knob the caller did not configure", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launch({ name: "s1", env: {}, labels: {} }); + for (const fn of [ + "builder.cpus", + "builder.memory", + "builder.idleTimeout", + "builder.maxDuration", + "builder.workdir", + "builder.envs", + "builder.labels", + ]) { + assert.equal(called(log, fn), false, `${fn} must not fire when unconfigured`); + } + }); + + it("folds LaunchOptions.label into the label set", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launch({ name: "s1", label: "greeter", labels: { team: "a" } }); + assert.deepEqual(firstArgs(log, "builder.labels"), [{ team: "a", label: "greeter" }]); + }); + + it("does not replace a same-named sandbox by default", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launch({ name: "s1" }); + assert.equal( + called(log, "builder.replace"), + false, + "replacing on a name collision would destroy a sandbox this caller may not own", + ); + }); + + it("replaces only when explicitly opted in", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk, { replaceExisting: true }).launch({ name: "s1" }); + assert.equal(called(log, "builder.replace"), true); + }); + + it("returns a handle carrying the injected home directory", async () => { + const { sdk } = createHarness(); + const handle = await makeRuntime(sdk, { workdir: "/workspace" }).launch({ name: "s1" }); + assert.deepEqual(handle, { + id: "s1", + state: "STARTED", + homeDir: HOME_DIR, + workdir: "/workspace", + }); + }); +}); + +describe("launchDetached", () => { + it("sets detached(true)", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launchDetached({ name: "s1" }); + assert.deepEqual(firstArgs(log, "builder.detached"), [true]); + }); + + it("plain launch does NOT set detached", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launch({ name: "s1" }); + assert.equal(called(log, "builder.detached"), false); + }); +}); + +describe("create deadline", () => { + it("fails with a typed error when the create deadline elapses", async () => { + const { sdk } = createHarness({ + onCreate: () => new Promise(() => {}), + }); + await assert.rejects( + makeRuntime(sdk).launch({ name: "slow", createTimeoutSeconds: 0.02 }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxCreateTimeoutError); + assert.equal(error.sandboxName, "slow"); + assert.equal(error.timeoutMs, 20); + return true; + }, + ); + }); + + it("never maps the create deadline onto a sandbox LIFETIME budget", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launch({ name: "s1", createTimeoutSeconds: 120 }); + // maxDuration/idleTimeout cap how long the sandbox may LIVE. Mapping a + // boot deadline onto either would kill every long-lived sandbox the moment + // the boot deadline elapsed. + assert.equal(called(log, "builder.maxDuration"), false); + assert.equal(called(log, "builder.idleTimeout"), false); + }); + + it("keeps the configured lifetime budgets untouched by the create deadline", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk, { maxDurationSeconds: 3600 }).launch({ + name: "s1", + createTimeoutSeconds: 5, + }); + assert.deepEqual(firstArgs(log, "builder.maxDuration"), [3600]); + }); + + it("does not impose a deadline when the caller supplies none", async () => { + const { sdk } = createHarness({ + onCreate: async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }, + }); + const handle = await makeRuntime(sdk).launch({ name: "s1" }); + assert.equal(handle.id, "s1"); + }); +}); + +describe("label lookup", () => { + it("drains every cursor page", async () => { + const { sdk, log } = createHarness({ + pages: [ + { sandboxes: [{ name: "a" }], nextCursor: "c1" }, + { sandboxes: [{ name: "b" }], nextCursor: "c2" }, + { sandboxes: [{ name: "c" }] }, + ], + }); + const handles = await makeRuntime(sdk).findAllByLabels({ team: "a" }); + assert.deepEqual(handles.map((h) => h.id), ["a", "b", "c"]); + assert.deepEqual(argsFor(log, "list.cursor"), [["c1"], ["c2"]]); + assert.deepEqual(firstArgs(log, "list.labels"), [{ team: "a" }]); + }); + + it("keeps only running sandboxes under the default state filter", async () => { + const { sdk } = createHarness({ + pages: [ + { + sandboxes: [ + { name: "run", status: "running" }, + { name: "stop", status: "stopped" }, + { name: "crash", status: "crashed" }, + { name: "drain", status: "draining" }, + ], + }, + ], + }); + const handles = await makeRuntime(sdk).findAllByLabels({ team: "a" }); + assert.deepEqual(handles.map((h) => h.id), ["run"]); + }); + + it("treats a draining sandbox as unusable rather than warm", async () => { + const { sdk } = createHarness({ + pages: [{ sandboxes: [{ name: "drain", status: "draining" }] }], + }); + const found = await makeRuntime(sdk).findByLabels({ team: "a" }); + assert.equal(found, null, "a draining sandbox is on its way down, not a warm lease"); + }); + + it("returns every state when states is null", async () => { + const { sdk } = createHarness({ + pages: [ + { + sandboxes: [ + { name: "run", status: "running" }, + { name: "stop", status: "stopped" }, + ], + }, + ], + }); + const handles = await makeRuntime(sdk).findAllByLabels({ team: "a" }, { states: null }); + assert.deepEqual(handles.map((h) => h.id), ["run", "stop"]); + }); + + it("normalizes SDK statuses onto the STARTED/STOPPED vocabulary", async () => { + const { sdk } = createHarness({ + pages: [ + { + sandboxes: [ + { name: "run", status: "running" }, + { name: "stop", status: "stopped" }, + { name: "crash", status: "crashed" }, + { name: "drain", status: "draining" }, + ], + }, + ], + }); + const handles = await makeRuntime(sdk).findAllByLabels({}, { states: null }); + assert.deepEqual( + handles.map((h) => [h.id, h.state]), + [["run", "STARTED"], ["stop", "STOPPED"], ["crash", "STOPPED"], ["drain", "STOPPED"]], + ); + }); + + it("surfaces timestamps as ISO strings when the SDK provides them", async () => { + const created = new Date("2026-01-02T03:04:05.000Z"); + const { sdk } = createHarness({ + pages: [{ sandboxes: [{ name: "a", createdAt: created, updatedAt: created }] }], + }); + const [handle] = await makeRuntime(sdk).findAllByLabels({}); + assert.equal(handle?.createdAt, "2026-01-02T03:04:05.000Z"); + assert.equal(handle?.updatedAt, "2026-01-02T03:04:05.000Z"); + }); + + it("stops paging once the limit is reached", async () => { + const { sdk, log } = createHarness({ + pages: [ + { sandboxes: [{ name: "a" }, { name: "b" }], nextCursor: "c1" }, + { sandboxes: [{ name: "c" }] }, + ], + }); + const handles = await makeRuntime(sdk).findAllByLabels({}, { limit: 1 }); + assert.deepEqual(handles.map((h) => h.id), ["a"]); + assert.equal( + argsFor(log, "Sandbox.listWith").length, + 1, + "a satisfied limit must not fetch the next page", + ); + }); + + it("excludes ids the caller has already claimed", async () => { + const { sdk } = createHarness({ + pages: [{ sandboxes: [{ name: "a" }, { name: "b" }] }], + }); + const found = await makeRuntime(sdk).findByLabels({}, { excludeIds: ["a"] }); + assert.equal(found?.id, "b"); + }); + + it("answers a zero maxCount without any SDK call", async () => { + const { sdk, log } = createHarness({ pages: [{ sandboxes: [{ name: "a" }] }] }); + assert.equal(await makeRuntime(sdk).countByLabels({}, { maxCount: 0 }), 0); + assert.equal(called(log, "Sandbox.listWith"), false); + }); + + it("caps countByLabels at maxCount", async () => { + const { sdk } = createHarness({ + pages: [{ sandboxes: [{ name: "a" }, { name: "b" }, { name: "c" }] }], + }); + assert.equal(await makeRuntime(sdk).countByLabels({}, { maxCount: 2 }), 2); + }); + + it("omits the label filter entirely when no labels are given", async () => { + const { sdk, log } = createHarness({ pages: [{ sandboxes: [] }] }); + await makeRuntime(sdk).findAllByLabels({}); + assert.equal(called(log, "list.labels"), false); + }); +}); + +describe("getById", () => { + it("resolves a sandbox by name", async () => { + const { sdk } = createHarness({ get: (name) => ({ name, status: "running" }) }); + const handle = await makeRuntime(sdk).getById("s1"); + assert.equal(handle?.id, "s1"); + assert.equal(handle?.state, "STARTED"); + }); + + it("returns a stopped sandbox when the caller did not filter states", async () => { + const { sdk } = createHarness({ get: (name) => ({ name, status: "stopped" }) }); + const handle = await makeRuntime(sdk).getById("s1"); + assert.equal(handle?.state, "STOPPED"); + }); + + it("filters out a sandbox that fails the requested state filter", async () => { + const { sdk } = createHarness({ get: (name) => ({ name, status: "stopped" }) }); + assert.equal(await makeRuntime(sdk).getById("s1", { states: ["STARTED"] }), null); + }); + + it("returns null when the SDK resolves nothing", async () => { + const { sdk } = createHarness({ get: () => null }); + assert.equal(await makeRuntime(sdk).getById("s1"), null); + }); + + it("returns null on the SDK's typed sandboxNotFound error", async () => { + const { sdk } = createHarness({ + get: () => { + throw sdkError("sandboxNotFound", "sandbox \"s1\" not found"); + }, + }); + assert.equal(await makeRuntime(sdk).getById("s1"), null); + }); + + it("propagates an unrelated SDK error instead of reporting it as absent", async () => { + const { sdk } = createHarness({ + get: () => { + throw sdkError("runtime", "hypervisor unavailable"); + }, + }); + await assert.rejects(makeRuntime(sdk).getById("s1"), /hypervisor unavailable/); + }); + + it("trusts the typed code over a misleading message", async () => { + const { sdk } = createHarness({ + get: () => { + // A transport failure whose text happens to contain "not found" must + // NOT be laundered into "this sandbox does not exist" — that is how a + // live sandbox gets orphaned and a duplicate launched in its place. + throw sdkError("http", "upstream route not found"); + }, + }); + await assert.rejects(makeRuntime(sdk).getById("s1"), /upstream route not found/); + }); + + it("still recognises a not-found error carrying no typed code", async () => { + const { sdk } = createHarness({ + get: () => { + throw Object.assign(new Error("sandbox not found"), { + name: "SandboxNotFoundError", + }); + }, + }); + assert.equal(await makeRuntime(sdk).getById("s1"), null); + }); + + it("carries caller-supplied homeDir and workdir onto the handle", async () => { + const { sdk } = createHarness({ get: (name) => ({ name }) }); + const handle = await makeRuntime(sdk).getById("s1", { + homeDir: "/home/other", + workdir: "/w", + }); + assert.equal(handle?.homeDir, "/home/other"); + assert.equal(handle?.workdir, "/w"); + }); +}); + +describe("runScript", () => { + it("runs the command through the configured shell with -c", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).runScript({ id: "s1" }, { command: "echo hi" }); + assert.deepEqual(firstArgs(log, "sandbox.execWith"), ["/bin/sh"]); + assert.deepEqual(firstArgs(log, "exec.args"), [["-c", "echo hi"]]); + }); + + it("honours a custom shell", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk, { shell: "/bin/bash" }).runScript({ id: "s1" }, { command: "echo hi" }); + assert.deepEqual(firstArgs(log, "sandbox.execWith"), ["/bin/bash"]); + }); + + it("applies cwd, env and timeout when supplied", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).runScript( + { id: "s1" }, + { command: "true", cwd: "/app", env: { A: "1" }, timeoutMs: 5_000 }, + ); + assert.deepEqual(firstArgs(log, "exec.cwd"), ["/app"]); + assert.deepEqual(firstArgs(log, "exec.envs"), [{ A: "1" }]); + assert.deepEqual(firstArgs(log, "exec.timeout"), [5_000]); + }); + + it("falls back to the handle workdir when no cwd is given", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).runScript({ id: "s1", workdir: "/handle-wd" }, { command: "true" }); + assert.deepEqual(firstArgs(log, "exec.cwd"), ["/handle-wd"]); + }); + + it("omits cwd, env and timeout when not supplied", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).runScript({ id: "s1" }, { command: "true", env: {}, timeoutMs: 0 }); + assert.equal(called(log, "exec.cwd"), false); + assert.equal(called(log, "exec.envs"), false); + assert.equal(called(log, "exec.timeout"), false); + }); + + it("never allocates a tty", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).runScript({ id: "s1" }, { command: "true" }); + assert.equal( + called(log, "exec.tty"), + false, + "capabilities.pty is declared false, so no exec path may allocate one", + ); + }); + + it("returns a non-zero exit as a result rather than throwing", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ code: 42, stdout: "out", stderr: "err" }), + }); + const result = await makeRuntime(sdk).runScript({ id: "s1" }, { command: "false" }); + assert.equal(result.exitCode, 42); + assert.equal(result.stdout, "out"); + assert.equal(result.stderr, "err"); + assert.equal(result.output, "out\nerr"); + }); + + it("omits empty stdout/stderr keys", async () => { + const { sdk } = createHarness({ get: (name) => ({ name }), exec: () => ({ code: 0 }) }); + const result = await makeRuntime(sdk).runScript({ id: "s1" }, { command: "true" }); + assert.equal("stdout" in result, false); + assert.equal("stderr" in result, false); + assert.equal(result.output, ""); + }); + + it("does not insert a separator when stdout already ends in a newline", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ stdout: "out\n", stderr: "err" }), + }); + const result = await makeRuntime(sdk).runScript({ id: "s1" }, { command: "true" }); + assert.equal(result.output, "out\nerr"); + }); + + it("throws when the sandbox is gone", async () => { + const { sdk } = createHarness({ get: () => null }); + await assert.rejects( + makeRuntime(sdk).runScript({ id: "ghost" }, { command: "true" }), + /no longer available/, + ); + }); +}); + +describe("exec (bootstrap plane)", () => { + it("narrows runScript onto the ExecResult shape", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ code: 3, stdout: "hello" }), + }); + const result = await makeRuntime(sdk).exec({ id: "s1" }, "echo hello"); + assert.deepEqual(result, { output: "hello", exitCode: 3 }); + }); +}); + +describe("startScript (durable async exec)", () => { + it("backgrounds the run and captures output and exit code to guest files", async () => { + const { sdk, log } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ stdout: "4242\n" }), + }); + const started = await makeRuntime(sdk).startScript( + { id: "s1" }, + { command: "long-job", sessionId: "sess-1" }, + ); + assert.deepEqual(started, { sessionId: "sess-1", commandId: "4242" }); + + const [argv] = firstArgs(log, "exec.args") as [string[]]; + const script = argv[1] ?? ""; + // The inner script is single-quoted for `sh -c`, so its own quoting shows + // up in the POSIX `'\''` escaped form. + assert.ok(script.startsWith("mkdir -p '/tmp/microsandbox-run/sess-1';")); + assert.ok(script.includes("nohup '/bin/sh' -c ")); + assert.ok(script.includes(`> '\\''/tmp/microsandbox-run/sess-1/out'\\'' 2>&1`)); + assert.ok(script.includes(`echo $? > '\\''/tmp/microsandbox-run/sess-1/exit'\\''`)); + assert.ok(script.endsWith("> /dev/null 2>&1 & echo $!")); + }); + + it("honours a custom run state directory", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "1" }) }); + await makeRuntime(sdk, { runStateDir: "/var/run/msb/" }).startScript( + { id: "s1" }, + { command: "job", sessionId: "sess-1" }, + ); + const [argv] = firstArgs(log, "exec.args") as [string[]]; + assert.match(argv[1] ?? "", /^mkdir -p '\/var\/run\/msb\/sess-1';/); + }); + + it("sanitizes a session id before using it as a path segment", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "1" }) }); + await makeRuntime(sdk).startScript( + { id: "s1" }, + { command: "job", sessionId: "../../etc/passwd" }, + ); + const [argv] = firstArgs(log, "exec.args") as [string[]]; + assert.match(argv[1] ?? "", /'\/tmp\/microsandbox-run\/______etc_passwd'/); + assert.doesNotMatch(argv[1] ?? "", /\.\./); + }); + + it("generates a session id when the caller supplies none", async () => { + const { sdk } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "9" }) }); + const started = await makeRuntime(sdk).startScript({ id: "s1" }, { command: "job" }); + assert.match(started.sessionId, /^run-s1-[0-9a-f-]{36}$/); + }); + + it("bounds only the submit call with the caller's timeout", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "1" }) }); + await makeRuntime(sdk).startScript( + { id: "s1" }, + { command: "long-job", sessionId: "sess-1", timeoutMs: 2_000 }, + ); + assert.deepEqual(firstArgs(log, "exec.timeout"), [2_000]); + // The backgrounded command must NOT inherit that deadline: `nohup ... &` + // detaches it, and nothing in the wrapper caps its lifetime. + const [argv] = firstArgs(log, "exec.args") as [string[]]; + const script = argv[1] ?? ""; + assert.doesNotMatch(script, /timeout 2/); + assert.ok(script.includes("nohup ")); + assert.ok(script.endsWith("& echo $!")); + }); + + it("escapes a command containing single quotes", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "1" }) }); + await makeRuntime(sdk).startScript( + { id: "s1" }, + { command: "echo 'hi'", sessionId: "sess-1" }, + ); + const [argv] = firstArgs(log, "exec.args") as [string[]]; + assert.match(argv[1] ?? "", /'\\''hi'\\''/); + }); +}); + +describe("getScriptStatus", () => { + const statusRuntime = (exitFileContents: string) => + createHarness({ + get: (name) => ({ name }), + exec: (argv) => { + const script = argv[1] ?? ""; + return script.includes("/exit") ? { stdout: exitFileContents } : { stdout: "" }; + }, + }); + + it("reports still-running while the exit file is absent", async () => { + const { sdk } = statusRuntime(""); + assert.deepEqual( + await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + { exitCode: null }, + ); + }); + + it("reports a zero exit", async () => { + const { sdk } = statusRuntime("0\n"); + assert.deepEqual( + await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + { exitCode: 0 }, + ); + }); + + it("reports a non-zero exit", async () => { + const { sdk } = statusRuntime("137\n"); + assert.deepEqual( + await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + { exitCode: 137 }, + ); + }); + + it("never invents an exit code from unparseable contents", async () => { + const { sdk } = statusRuntime("garbage\n"); + assert.deepEqual( + await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + { exitCode: null }, + ); + }); + + it("reads through a bounded tail rather than an unbounded file read", async () => { + const { sdk, log } = statusRuntime("0"); + await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"); + const [argv] = firstArgs(log, "exec.args") as [string[]]; + assert.match(argv[1] ?? "", /^tail -c 64 /); + assert.equal(called(log, "fs.readToString"), false); + }); + + it("degrades to still-running when the read itself fails", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => { + throw new Error("transport reset"); + }, + }); + assert.deepEqual( + await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + { exitCode: null }, + ); + }); +}); + +describe("getScriptLogs", () => { + it("returns captured output and defers the exit code to getScriptStatus", async () => { + const { sdk, log } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ stdout: "captured output" }), + }); + const result = await makeRuntime(sdk).getScriptLogs({ id: "s1" }, "sess-1", "cmd-9"); + assert.equal(result.output, "captured output"); + assert.equal(result.exitCode, null, "status is the single source of truth for the exit code"); + assert.equal(result.cmdId, "cmd-9"); + const [argv] = firstArgs(log, "exec.args") as [string[]]; + assert.match(argv[1] ?? "", /^tail -c 200000 '\/tmp\/microsandbox-run\/sess-1\/out'/); + }); +}); + +describe("file transfer", () => { + it("treats a string source as a HOST PATH", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).uploadFile({ id: "s1" }, "./local.txt", "/app/local.txt"); + assert.deepEqual(firstArgs(log, "fs.copyFromHost"), ["./local.txt", "/app/local.txt"]); + assert.equal(called(log, "fs.write"), false); + }); + + it("treats a Buffer source as file CONTENT", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).uploadFile({ id: "s1" }, Buffer.from("hello"), "/app/f.txt"); + const [path, data] = firstArgs(log, "fs.write"); + assert.equal(path, "/app/f.txt"); + assert.ok(data instanceof Uint8Array); + assert.equal(Buffer.from(data as Uint8Array).toString("utf8"), "hello"); + assert.equal(called(log, "fs.copyFromHost"), false); + }); + + it("creates the destination's parent directory first", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).uploadFile({ id: "s1" }, Buffer.from("x"), "/app/nested/f.txt"); + assert.deepEqual(firstArgs(log, "fs.mkdir"), ["/app/nested"]); + assert.ok( + names(log).indexOf("fs.mkdir") < names(log).indexOf("fs.write"), + "mkdir must precede the write", + ); + }); + + it("does not fail the upload when the parent directory already exists", async () => { + const { sdk, log } = createHarness({ + get: (name) => ({ name }), + fs: { + onMkdir: async () => { + throw new Error("EEXIST: file exists"); + }, + }, + }); + await makeRuntime(sdk).uploadFile({ id: "s1" }, Buffer.from("x"), "/app/f.txt"); + assert.equal(called(log, "fs.write"), true); + }); + + it("skips mkdir for a root-level destination", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).uploadFile({ id: "s1" }, Buffer.from("x"), "/f.txt"); + assert.equal(called(log, "fs.mkdir"), false); + }); + + it("uploads every file in a bundle", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).uploadBundle({ id: "s1" }, { + files: [ + { source: Buffer.from("a"), destination: "/app/a" }, + { source: "./b", destination: "/app/b" }, + ], + }); + assert.deepEqual(argsFor(log, "fs.write").map((a) => a[0]), ["/app/a"]); + assert.deepEqual(argsFor(log, "fs.copyFromHost").map((a) => a[1]), ["/app/b"]); + }); + + it("downloads to a host path when one is given", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const result = await makeRuntime(sdk).downloadFile({ id: "s1" }, "/app/out", "./out"); + assert.equal(result, undefined); + assert.deepEqual(firstArgs(log, "fs.copyToHost"), ["/app/out", "./out"]); + assert.equal(called(log, "fs.read"), false); + }); + + it("returns a Buffer when no host path is given", async () => { + const { sdk, log } = createHarness({ + get: (name) => ({ name }), + fs: { onRead: async () => new Uint8Array([104, 105]) }, + }); + const result = await makeRuntime(sdk).downloadFile({ id: "s1" }, "/app/out"); + assert.ok(Buffer.isBuffer(result)); + assert.equal((result as Buffer).toString("utf8"), "hi"); + assert.equal(called(log, "fs.copyToHost"), false); + }); +}); + +describe("getHomeDir", () => { + it("prefers the handle's own home directory", async () => { + const { sdk } = createHarness(); + assert.equal( + await makeRuntime(sdk).getHomeDir({ id: "s1", homeDir: "/home/other" }), + "/home/other", + ); + }); + + it("falls back to the injected default", async () => { + const { sdk } = createHarness(); + assert.equal(await makeRuntime(sdk).getHomeDir({ id: "s1" }), HOME_DIR); + }); +}); + +describe("lifecycle", () => { + it("start resumes the sandbox and reports it STARTED", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name, status: "stopped" }) }); + const handle = await makeRuntime(sdk).start({ id: "s1", state: "STOPPED" }); + assert.equal(handle.state, "STARTED"); + assert.equal(called(log, "handle.start"), true); + }); + + it("start on a vanished sandbox is an error, not a silent no-op", async () => { + const { sdk } = createHarness({ get: () => null }); + await assert.rejects(makeRuntime(sdk).start({ id: "ghost" }), /no longer available/); + }); + + it("stop halts the sandbox", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).stop({ id: "s1" }); + assert.deepEqual(firstArgs(log, "handle.stop"), ["s1"]); + }); + + it("stop on a vanished sandbox is idempotent", async () => { + const { sdk, log } = createHarness({ get: () => null }); + await makeRuntime(sdk).stop({ id: "ghost" }); + assert.equal(called(log, "handle.stop"), false); + }); +}); + +describe("destroy", () => { + it("kills and then removes, so the name is reusable", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).destroy({ id: "s1" }); + const order = names(log).filter((n) => n === "handle.kill" || n === "handle.remove"); + assert.deepEqual(order, ["handle.kill", "handle.remove"]); + }); + + it("is a no-op for a sandbox that is already gone", async () => { + const { sdk, log } = createHarness({ get: () => null }); + await makeRuntime(sdk).destroy({ id: "ghost" }); + assert.equal(called(log, "handle.kill"), false); + assert.equal(called(log, "handle.remove"), false); + }); + + it("still removes when the kill fails because it was already stopped", async () => { + const { sdk, log } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + throw new Error("sandbox is already stopped"); + }, + }), + }); + await makeRuntime(sdk).destroy({ id: "s1" }); + assert.equal( + called(log, "handle.remove"), + true, + "a stopped sandbox still holds its name until it is removed", + ); + }); + + it("does not remove after an unexplained kill failure", async () => { + const { sdk, log } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + throw sdkError("runtime", "hypervisor wedged"); + }, + }), + }); + await assert.rejects(makeRuntime(sdk).destroy({ id: "s1" }), /hypervisor wedged/); + assert.equal( + called(log, "handle.remove"), + false, + "removing the record of a sandbox that may still be running would orphan the microVM", + ); + }); + + it("tolerates a remove that races another destroy", async () => { + const { sdk } = createHarness({ + get: (name) => ({ + name, + onRemove: async () => { + throw sdkError("sandboxNotFound", "already removed"); + }, + }), + }); + await makeRuntime(sdk).destroy({ id: "s1" }); + }); + + it("drops the cached instance so a later call re-resolves by name", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.launch({ name: "s1" }); + await runtime.destroy({ id: "s1" }); + await runtime.runScript({ id: "s1" }, { command: "true" }); + assert.equal(called(log, "handle.connectWithTimeout"), true); + }); +}); + +describe("reattach", () => { + it("reuses the instance cached by launch without a round trip", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const handle = await runtime.launch({ name: "s1" }); + await runtime.runScript(handle, { command: "true" }); + assert.equal(called(log, "Sandbox.get"), false); + }); + + it("re-resolves by name from a cold runtime, without taking lifecycle ownership", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).runScript({ id: "s1" }, { command: "true" }); + assert.deepEqual(firstArgs(log, "Sandbox.get"), ["s1"]); + assert.deepEqual(firstArgs(log, "handle.connectWithTimeout"), ["s1", 10_000]); + assert.equal( + called(log, "handle.start"), + false, + "reattaching must not implicitly boot a stopped sandbox", + ); + }); + + it("honours a configured connect timeout", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk, { connectTimeoutMs: 2_500 }).runScript({ id: "s1" }, { command: "true" }); + assert.deepEqual(firstArgs(log, "handle.connectWithTimeout"), ["s1", 2_500]); + }); +}); + +describe("capabilities", () => { + it("declares the bootstrap-plane capability set this adapter actually implements", () => { + const { sdk } = createHarness(); + assert.deepEqual(makeRuntime(sdk).capabilities, { + pty: false, + snapshots: true, + isolation: "strong", + persistentHandle: true, + streamingLogs: false, + }); + }); + + it("declares warm-lease and lifecycle support", () => { + const { sdk } = createHarness(); + assert.deepEqual(makeRuntime(sdk).declaredCapabilities, { + warmLease: true, + lifecycle: true, + }); + }); + + it("resolves to the full orchestration-plane capability set", () => { + const { sdk } = createHarness(); + assert.deepEqual(resolveSandboxRuntimeCapabilities(makeRuntime(sdk)), { + asyncExec: true, + reattach: true, + detachedLaunch: true, + warmLease: true, + lifecycle: true, + }); + }); + + it("backs its asyncExec claim with all four required methods", () => { + const { sdk } = createHarness(); + const runtime = makeRuntime(sdk); + for (const method of ["startScript", "getById", "getScriptStatus", "getScriptLogs"] as const) { + assert.equal(typeof runtime[method], "function", `${method} is required by asyncExec`); + } + }); + + it("backs its snapshots claim with a real snapshot boot path", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk, { image: undefined, snapshot: "snap-1" }).launch({ name: "s1" }); + assert.equal(called(log, "builder.fromSnapshot"), true); + }); +}); + +// --------------------------------------------------------------------------- +// Real-SDK contract. +// +// The adapter binds to a structurally-modeled slice of `microsandbox` rather +// than to the SDK's type graph, which keeps unit tests credential-free but +// would let the model silently drift from the package. These checks pin the +// model against the installed SDK: they need no API key, no backend and no +// hypervisor, because every assertion is about module shape or about +// `withDefaultBackend`, which is pure process-local state. +// +// Skipped rather than failed when the platform-specific native addon cannot +// load, so the suite stays green on an unsupported platform. +// --------------------------------------------------------------------------- + +type RealSdkModule = Record & { + Sandbox: Record; + SandboxStatuses: readonly string[]; + SandboxNotFoundError: new (message: string) => Error; + withDefaultBackend: (backend: unknown, fn: () => T | Promise) => Promise; + defaultBackendKind: () => string; +}; + +let realSdk: RealSdkModule | null = null; +let realSdkLoadError = ""; +try { + realSdk = (await import("microsandbox")) as unknown as RealSdkModule; +} catch (error) { + realSdkLoadError = error instanceof Error ? error.message : String(error); +} +const REAL_SDK_SKIP = realSdk + ? false + : `microsandbox native addon unavailable: ${realSdkLoadError}`; + +describe("real-SDK contract", () => { + it("exposes every Sandbox static the adapter calls", { skip: REAL_SDK_SKIP }, () => { + const statics = realSdk!.Sandbox; + for (const name of ["builder", "get", "listWith"]) { + assert.equal(typeof statics[name], "function", `Sandbox.${name} is gone`); + } + }); + + it("exposes every builder setter the adapter calls", { skip: REAL_SDK_SKIP }, () => { + const builder = (realSdk!.Sandbox["builder"] as (n: string) => Record)( + "contract-probe", + ); + for (const name of [ + "image", + "fromSnapshot", + "cpus", + "memory", + "workdir", + "envs", + "labels", + "detached", + "idleTimeout", + "maxDuration", + "replace", + "create", + ]) { + assert.equal(typeof builder[name], "function", `SandboxBuilder.${name} is gone`); + } + }); + + it("exposes every list-builder setter the adapter calls", { skip: REAL_SDK_SKIP }, () => { + const listBuilder = (realSdk!["SandboxListBuilder"] as { prototype: Record }) + .prototype; + for (const name of ["limit", "cursor", "labels"]) { + assert.equal(typeof listBuilder[name], "function", `SandboxListBuilder.${name} is gone`); + } + }); + + it("still uses the status vocabulary this adapter normalizes", { skip: REAL_SDK_SKIP }, () => { + // A new status would fall through `normalizeStatus` to STOPPED, silently + // excluding those sandboxes from every warm-lease lookup. + assert.deepEqual([...realSdk!.SandboxStatuses].sort(), [ + "crashed", + "draining", + "running", + "stopped", + ]); + }); + + it("still tags a missing sandbox with the code the adapter matches", { skip: REAL_SDK_SKIP }, () => { + const error = new realSdk!.SandboxNotFoundError("probe"); + assert.equal( + (error as unknown as { code: string }).code, + "sandboxNotFound", + "getById relies on this exact code to tell absent from broken", + ); + }); + + it("scopes and restores the default backend", { skip: REAL_SDK_SKIP }, async () => { + // No credential involved: this drives the real scoping primitive the + // adapter depends on, using the local backend as an inert value. + const before = realSdk!.defaultBackendKind(); + const inside = await realSdk!.withDefaultBackend("local", () => + realSdk!.defaultBackendKind(), + ); + assert.equal(inside, "local"); + assert.equal(realSdk!.defaultBackendKind(), before, "the scope must be restored"); + }); +}); + +// --------------------------------------------------------------------------- +// Live smoke. +// +// Gated OFF by default and skipped unless the operator supplies both an image +// and a backend, because there is no image or endpoint this package may assume. +// Inject the API key with `op run` so it reaches the process environment only +// and never argv: +// +// op run --env-file=./smoke.env -- \ +// env MICROSANDBOX_SMOKE_IMAGE=alpine npm test +// +// where `smoke.env` holds `MSB_API_KEY=op:////API_KEY`. Nothing +// below reads, prints, or asserts on the key's value — only on whether one is +// present. +// +// The real SDK is reached through the adapter's own lazy import, so a normal +// (skipped) test run never loads the platform-specific native addon. +// --------------------------------------------------------------------------- + +const smokeImage = process.env.MICROSANDBOX_SMOKE_IMAGE?.trim(); +const smokeApiKeyPresent = Boolean(process.env.MSB_API_KEY?.trim()); +const smokeWantsLocal = process.env.MICROSANDBOX_SMOKE_BACKEND?.trim() === "local"; +const smokeHomeDir = process.env.MICROSANDBOX_SMOKE_HOME_DIR?.trim() ?? "/root"; +const HAS_MICROSANDBOX = Boolean(smokeImage) && (smokeApiKeyPresent || smokeWantsLocal); +const SMOKE_SKIP_REASON = !smokeImage + ? "MICROSANDBOX_SMOKE_IMAGE is not set" + : "neither MSB_API_KEY nor MICROSANDBOX_SMOKE_BACKEND=local is set"; +const SMOKE_LABEL = "microsandbox-runner-smoke"; + +describe("MicrosandboxRuntime smoke", { concurrency: false }, () => { + let runtime: MicrosandboxRuntime | undefined; + let handle: { id: string } | undefined; + + if (HAS_MICROSANDBOX) { + const backend: MicrosandboxBackend = smokeWantsLocal + ? "local" + // Read straight out of the process environment at the moment of use. The + // value is never logged, never interpolated into a command, and never + // written anywhere. + : { kind: "cloud", apiKey: process.env.MSB_API_KEY as string }; + runtime = new MicrosandboxRuntime({ + backend, + image: smokeImage as string, + homeDir: smokeHomeDir, + }); + } + + it( + "launches, execs, round-trips a file, polls an async run, and tears down", + { skip: HAS_MICROSANDBOX ? false : SMOKE_SKIP_REASON, timeout: 300_000 }, + async () => { + assert.ok(runtime, "runtime should be constructed when the smoke gate is open"); + + handle = await runtime.launch({ + labels: { purpose: SMOKE_LABEL }, + createTimeoutSeconds: 180, + }); + assert.ok(handle.id.length > 0); + + const sync = await runtime.runScript(handle, { command: "echo ok" }); + assert.equal(sync.exitCode, 0, `expected exit 0, got ${sync.exitCode}: ${sync.output}`); + assert.match(sync.output, /\bok\b/); + + const nonZero = await runtime.runScript(handle, { command: "exit 7" }); + assert.equal(nonZero.exitCode, 7, "a non-zero exit must arrive as a result, not a throw"); + + await runtime.uploadFile(handle, Buffer.from("round-trip"), "/tmp/smoke/f.txt"); + const downloaded = await runtime.downloadFile(handle, "/tmp/smoke/f.txt"); + assert.equal(Buffer.from(downloaded as Buffer).toString("utf8"), "round-trip"); + + const started = await runtime.startScript(handle, { + command: "sleep 1; echo async-done; exit 3", + }); + let status = await runtime.getScriptStatus(handle, started.sessionId, started.commandId); + for (let attempt = 0; attempt < 60 && status.exitCode === null; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + status = await runtime.getScriptStatus(handle, started.sessionId, started.commandId); + } + assert.equal(status.exitCode, 3, "the durable exit file must carry the real exit code"); + const logs = await runtime.getScriptLogs(handle, started.sessionId, started.commandId); + assert.match(logs.output, /async-done/); + + const found = await runtime.findAllByLabels({ purpose: SMOKE_LABEL }); + assert.ok( + found.some((entry) => entry.id === handle?.id), + "server-side label search must find the sandbox this test launched", + ); + + const byId = await runtime.getById(handle.id); + assert.equal(byId?.id, handle.id); + assert.equal(byId?.state, "STARTED"); + }, + ); + + after(async () => { + if (runtime && handle) { + try { + await runtime.destroy(handle); + } catch { + // Best-effort cleanup; a leaked sandbox surfaces in the provider console. + } + } + }); +}); diff --git a/src/microsandbox/runtime.ts b/src/microsandbox/runtime.ts new file mode 100644 index 0000000..138be98 --- /dev/null +++ b/src/microsandbox/runtime.ts @@ -0,0 +1,1088 @@ +import { Buffer } from "node:buffer"; +import { randomUUID } from "node:crypto"; + +import type { + AsyncExecStartResult, + AsyncExecStatus, + ExecOptions, + ExecResult, + LaunchOptions, + RuntimeCapabilities, + RuntimeHandle, + WorkflowRuntime, +} from "../types.js"; +import type { + AsyncRunStartResult, + AsyncRunStatus, + RunScriptResult, + SandboxCountOptions, + SandboxLookupOptions, + SandboxRuntime, +} from "../port.js"; + +// --------------------------------------------------------------------------- +// Microsandbox provider for the SandboxRuntime + WorkflowRuntime contracts. +// +// The `microsandbox` npm package is imported LAZILY (`await import(...)`), +// never at module top level, so consumers on another provider neither bundle it +// nor need its platform-specific native addon installed. The SDK surface is +// modeled structurally below (read from microsandbox@0.6.11 `dist/*.d.ts`) so +// typecheck and unit tests do not hard-couple to the SDK type graph, and a fake +// SDK can drive every path without a live backend or an API key. +// +// Three provider facts shape this adapter and are worth stating up front, +// because each one breaks an assumption the other adapters in this package are +// allowed to make: +// +// 1. IDENTITY IS A NAME, NOT A SERVER-ASSIGNED ID. `Sandbox.builder(name)`, +// `Sandbox.get(name)` and `Sandbox.remove(name)` all address a sandbox by a +// caller-chosen name capped at 128 UTF-8 bytes. `RuntimeHandle.id` +// therefore carries that name, and an over-long name is rejected rather +// than truncated — truncating would silently alias two distinct sandboxes +// onto one identity. +// +// 2. BACKEND SELECTION IS PROCESS-WIDE GLOBAL STATE. The SDK exposes +// `setDefaultBackend(backend)` (permanent) and `withDefaultBackend(backend, +// fn)` (scoped, restored in a `finally`). This adapter only ever uses the +// scoped form, so constructing a runtime never mutates the host process and +// two runtimes pointed at different backends can coexist. The SDK documents +// that the scope is not task-local: concurrent work in the same process can +// observe the temporary backend while the callback runs. That caveat is the +// SDK's, not something this adapter can fix, and it is why the wrapped +// region is kept as small as one SDK call. +// +// 3. THERE IS NO CREATE-TIMEOUT SETTER ON THE BUILDER. `maxDuration` and +// `idleTimeout` are sandbox LIFETIME budgets. Mapping the caller's +// `createTimeoutSeconds` onto either would kill every long-lived sandbox +// the moment the boot deadline elapsed, so the create deadline is enforced +// client-side instead (see `launch`). +// --------------------------------------------------------------------------- + +/** Max sandbox name length the SDK accepts, in UTF-8 bytes. */ +const MAX_SANDBOX_NAME_BYTES = 128; +const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; +const DEFAULT_LIST_PAGE_SIZE = 100; +const SCRIPT_LOG_READ_MAX_BYTES = 200_000; + +/** + * Where the async-exec wrapper parks its `out`/`exit` files inside the guest. + * + * `/tmp` is a POSIX guarantee of the guest filesystem, not a fact about any + * particular deployment, so it is a safe default rather than baked-in + * infrastructure. Consumers whose image mounts `/tmp` read-only override it + * with `runStateDir`. + */ +const DEFAULT_RUN_STATE_DIR = "/tmp/microsandbox-run"; + +/** POSIX shell used to interpret a `runScript`/`startScript` command string. */ +const DEFAULT_SHELL = "/bin/sh"; + +// --- structural microsandbox SDK surface (microsandbox@0.6.11) -------------- + +/** `SandboxStatus` — the SDK's own status vocabulary. */ +export type MicrosandboxStatus = "running" | "stopped" | "crashed" | "draining"; + +type MsbExecOutput = { + readonly code: number; + readonly success: boolean; + stdout(): string; + stderr(): string; +}; + +type MsbExecOptionsBuilder = { + args(args: string[]): MsbExecOptionsBuilder; + cwd(cwd: string): MsbExecOptionsBuilder; + envs(vars: Record): MsbExecOptionsBuilder; + timeout(ms: number): MsbExecOptionsBuilder; + tty(enabled: boolean): MsbExecOptionsBuilder; +}; + +type MsbFsOps = { + write(path: string, data: Uint8Array | string): Promise; + read(path: string): Promise; + readToString(path: string): Promise; + mkdir(path: string): Promise; + exists(path: string): Promise; + copyFromHost(hostPath: string, guestPath: string): Promise; + copyToHost(guestPath: string, hostPath: string): Promise; +}; + +type MsbSandbox = { + readonly name: string; + exec(cmd: string, args?: Iterable): Promise; + execWith( + cmd: string, + configure: (b: MsbExecOptionsBuilder) => MsbExecOptionsBuilder, + ): Promise; + fs(): MsbFsOps; + detach?(): Promise; +}; + +type MsbSandboxHandle = { + readonly name: string; + readonly status: MicrosandboxStatus; + readonly createdAt?: Date | null; + readonly updatedAt?: Date | null; + connect(): Promise; + connectWithTimeout(timeoutMs: number): Promise; + start(): Promise; + startDetached(): Promise; + stop(): Promise; + kill(): Promise; + remove(): Promise; +}; + +type MsbSandboxPage = { + sandboxes: MsbSandboxHandle[]; + nextCursor?: string; +}; + +type MsbSandboxListBuilder = { + limit(limit: number): MsbSandboxListBuilder; + cursor(cursor: string): MsbSandboxListBuilder; + labels(labels: Record): MsbSandboxListBuilder; +}; + +type MsbSandboxBuilder = { + image(image: string): MsbSandboxBuilder; + fromSnapshot(pathOrName: string): MsbSandboxBuilder; + cpus(n: number): MsbSandboxBuilder; + memory(mib: number): MsbSandboxBuilder; + workdir(path: string): MsbSandboxBuilder; + envs(vars: Record): MsbSandboxBuilder; + labels(labels: Record): MsbSandboxBuilder; + detached(enabled: boolean): MsbSandboxBuilder; + idleTimeout(secs: number): MsbSandboxBuilder; + maxDuration(secs: number): MsbSandboxBuilder; + replace(): MsbSandboxBuilder; + create(): Promise; +}; + +type MsbSandboxStatics = { + builder(name: string): MsbSandboxBuilder; + get(name: string): Promise; + listWith( + configure: (b: MsbSandboxListBuilder) => MsbSandboxListBuilder, + ): Promise; +}; + +/** + * The slice of the `microsandbox` module this adapter binds to. + * + * Exported so a consumer can supply a double (tests, a proxy that adds + * tracing) through {@link MicrosandboxRuntimeOptions.sdk} without depending on + * the SDK's internal type graph. + */ +export type MicrosandboxSdk = { + Sandbox: MsbSandboxStatics; + withDefaultBackend( + backend: MicrosandboxBackend, + fn: () => Promise | T, + ): Promise; +}; + +/** + * Backend routing for one runtime instance — mirrors the SDK's + * `DefaultBackend`. + * + * There is deliberately no default: `"local"` would silently run workloads on + * the caller's host, and a cloud URL is deployment-specific. The API key is + * accepted here as a value the caller supplies at construction from its own + * secret store; this package never reads an environment variable, never logs + * the key, and never writes it to disk. + */ +export type MicrosandboxBackend = + | "local" + | { + kind: "cloud"; + /** Hosted API endpoint. Omit to use the SDK's own default endpoint. */ + url?: string; + apiKey: string; + } + | { kind: "cloud"; profile: string }; + +export type MicrosandboxRuntimeOptions = { + /** + * Backend this runtime routes every SDK call through. Required: see + * {@link MicrosandboxBackend}. + */ + backend: MicrosandboxBackend; + /** + * OCI image to boot. Required unless {@link snapshot} is given: images are + * registry- and consumer-specific, so no default is correct for anyone else. + */ + image?: string; + /** + * Snapshot to boot from instead of an image. Mutually exclusive with + * {@link image}. + */ + snapshot?: string; + /** + * Home directory inside the guest, reported by `getHomeDir`. Required: it is + * a property of the image, which this package does not choose. + */ + homeDir: string; + /** Default working directory for launched sandboxes. */ + workdir?: string; + /** vCPU count applied at create time. Omit to accept the image default. */ + cpus?: number; + /** Guest memory in MiB applied at create time. */ + memoryMiB?: number; + /** Idle-shutdown budget in seconds, applied at create time. */ + idleTimeoutSeconds?: number; + /** Hard sandbox lifetime in seconds, applied at create time. */ + maxDurationSeconds?: number; + /** + * Replace a same-named sandbox at create time. Defaults to `false`: names are + * the identity here, so replacing on collision would destroy a sandbox this + * caller may not own. + */ + replaceExisting?: boolean; + /** Prefix for generated sandbox names when the caller supplies none. */ + namePrefix?: string; + /** Guest directory holding async-run state files. Defaults to `/tmp/microsandbox-run`. */ + runStateDir?: string; + /** Shell used to interpret command strings. Defaults to `/bin/sh`. */ + shell?: string; + /** Timeout for `connect` to an already-running sandbox. Defaults to 10s. */ + connectTimeoutMs?: number; + /** Page size used when draining label listings. Defaults to 100. */ + listPageSize?: number; + /** Injection seam for tests / a wrapping adapter — defaults to lazy `import("microsandbox")`. */ + sdk?: MicrosandboxSdk; +}; + +/** + * A sandbox name exceeded the SDK's 128 UTF-8 byte cap. + * + * Raised instead of truncating: the name IS the identity, so a truncated name + * would collide two distinct sandboxes onto one addressable handle. + */ +export class MicrosandboxNameTooLongError extends Error { + override readonly name = "MicrosandboxNameTooLongError"; + readonly sandboxName: string; + readonly byteLength: number; + readonly maxByteLength = MAX_SANDBOX_NAME_BYTES; + + constructor(sandboxName: string, byteLength: number) { + super( + `Microsandbox sandbox name is ${byteLength} UTF-8 bytes, exceeding the ${MAX_SANDBOX_NAME_BYTES}-byte limit`, + ); + this.sandboxName = sandboxName; + this.byteLength = byteLength; + } +} + +/** + * The create deadline supplied by the caller elapsed before the sandbox + * finished booting. + * + * The sandbox may still be booting under {@link sandboxName}; it is + * deterministically addressable, so a caller can reattach with `getById` or + * reclaim it with `destroy`. + */ +export class MicrosandboxCreateTimeoutError extends Error { + override readonly name = "MicrosandboxCreateTimeoutError"; + readonly sandboxName: string; + readonly timeoutMs: number; + + constructor(sandboxName: string, timeoutMs: number) { + super( + `Microsandbox sandbox "${sandboxName}" did not finish creating within ${timeoutMs}ms`, + ); + this.sandboxName = sandboxName; + this.timeoutMs = timeoutMs; + } +} + +export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { + readonly id = "microsandbox"; + + /** + * Bootstrap-plane capabilities. These describe what THIS ADAPTER exposes + * through the port, not everything the SDK can do — the same convention the + * Daytona adapter follows. + * + * - `pty: false` — the SDK's `ExecOptionsBuilder.tty(true)` and + * `ExecHandle.resize()` are real, but the port has no pty method and this + * adapter never allocates one. + * - `snapshots: true` — `launch` can source a sandbox from a snapshot via + * the builder's `fromSnapshot`. + * - `isolation: 'strong'` — microsandbox boots each sandbox as a microVM + * with its own guest Linux kernel (hardware virtualization), per the + * provider's published requirements: KVM on Linux, Apple Silicon on + * macOS, WHP on Windows. + * - `persistentHandle: true` — a sandbox is re-resolvable by name from a + * fresh process via `Sandbox.get(name)` + `connect()`, and + * `launchDetached` sets `detached(true)` so it outlives this process. + * - `streamingLogs: false` — the SDK ships `logStream({follow:true})` and + * `execStream`, but this adapter's log path is a durable file read, so + * claiming a streaming capability here would be claiming a code path that + * does not exist. + */ + readonly capabilities: RuntimeCapabilities = { + pty: false, + snapshots: true, + isolation: "strong", + persistentHandle: true, + streamingLogs: false, + }; + + /** + * Both true, and declared rather than left to default so the reasoning is on + * the record. `warmLease`: `Sandbox.listWith(b => b.labels(...))` is a real + * server-side label query with cursor pagination, so a warm-lease lookup is + * meaningful. `lifecycle`: `start`/`stop` map onto `SandboxHandle.start()` / + * `SandboxHandle.stop()`, which genuinely resume and halt a microVM — unlike + * the E2B adapter, where both are no-ops. + */ + readonly declaredCapabilities = { warmLease: true, lifecycle: true } as const; + + private readonly backend: MicrosandboxBackend; + private readonly image?: string; + private readonly snapshot?: string; + private readonly homeDir: string; + private readonly defaultWorkdir?: string; + private readonly cpus?: number; + private readonly memoryMiB?: number; + private readonly idleTimeoutSeconds?: number; + private readonly maxDurationSeconds?: number; + private readonly replaceExisting: boolean; + private readonly namePrefix: string; + private readonly runStateDir: string; + private readonly shell: string; + private readonly connectTimeoutMs: number; + private readonly listPageSize: number; + private readonly injectedSdk?: MicrosandboxSdk; + private sdkPromise?: Promise; + + // Live `Sandbox` instances resolved in this process. A cross-request access + // (an async-exec poll tick) that misses this cache re-resolves by name via + // `Sandbox.get(name).connect()` — the reattach that lets a run outlive the + // request that started it. + private readonly sandboxes = new Map(); + + constructor(options: MicrosandboxRuntimeOptions) { + if (!options.image && !options.snapshot) { + throw new Error( + "MicrosandboxRuntime requires either `image` or `snapshot`: neither has a default that is correct for another consumer", + ); + } + if (options.image && options.snapshot) { + throw new Error( + "MicrosandboxRuntime accepts `image` or `snapshot`, not both: a sandbox has exactly one rootfs source", + ); + } + this.backend = options.backend; + if (options.image !== undefined) { + this.image = options.image; + } + if (options.snapshot !== undefined) { + this.snapshot = options.snapshot; + } + this.homeDir = options.homeDir; + if (options.workdir !== undefined) { + this.defaultWorkdir = options.workdir; + } + if (options.cpus !== undefined) { + this.cpus = options.cpus; + } + if (options.memoryMiB !== undefined) { + this.memoryMiB = options.memoryMiB; + } + if (options.idleTimeoutSeconds !== undefined) { + this.idleTimeoutSeconds = options.idleTimeoutSeconds; + } + if (options.maxDurationSeconds !== undefined) { + this.maxDurationSeconds = options.maxDurationSeconds; + } + this.replaceExisting = options.replaceExisting ?? false; + this.namePrefix = options.namePrefix ?? ""; + this.runStateDir = (options.runStateDir ?? DEFAULT_RUN_STATE_DIR).replace(/\/+$/, ""); + this.shell = options.shell ?? DEFAULT_SHELL; + this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + this.listPageSize = options.listPageSize ?? DEFAULT_LIST_PAGE_SIZE; + if (options.sdk !== undefined) { + this.injectedSdk = options.sdk; + } + } + + // --- lookup -------------------------------------------------------------- + + async findByLabels( + labels: Record, + options: SandboxLookupOptions = {}, + ): Promise { + const handles = await this.findAllByLabels(labels, options); + const excluded = new Set(options.excludeIds ?? []); + return handles.find((handle) => !excluded.has(handle.id)) ?? null; + } + + async findAllByLabels( + labels: Record, + options: SandboxLookupOptions = {}, + ): Promise { + const states = options.states === undefined ? ["STARTED"] : options.states; + const cap = options.limit ?? options.pageSize; + return this.collectByLabels(labels, states, cap, options.pageSize); + } + + async countByLabels( + labels: Record, + options: SandboxCountOptions = {}, + ): Promise { + const maxCount = options.maxCount === undefined + ? Number.POSITIVE_INFINITY + : Math.max(0, Math.floor(options.maxCount)); + if (maxCount === 0) { + // Short-circuit: a cap of zero can be answered without a network call. + return 0; + } + const states = options.states === undefined ? ["STARTED"] : options.states; + const cap = Number.isFinite(maxCount) ? maxCount : undefined; + const handles = await this.collectByLabels(labels, states, cap, options.pageSize); + return handles.length; + } + + /** + * Drain the cursor-paginated, server-side label listing, keep the entries + * matching `states`, and stop as soon as `cap` handles are collected. + */ + private async collectByLabels( + labels: Record, + states: readonly string[] | null, + cap: number | undefined, + pageSize: number | undefined, + ): Promise { + const limit = pageSize ?? this.listPageSize; + const handles: RuntimeHandle[] = []; + let cursor: string | undefined; + // Bounded so a backend that keeps handing back the same cursor cannot spin + // this loop forever. + for (let page = 0; page < 1_000; page += 1) { + const cursorForPage = cursor; + const result = await this.withBackend((sdk) => + sdk.Sandbox.listWith((builder) => { + let configured = builder.limit(limit); + if (hasEntries(labels)) { + configured = configured.labels(labels); + } + if (cursorForPage) { + configured = configured.cursor(cursorForPage); + } + return configured; + }), + ); + for (const entry of result.sandboxes ?? []) { + if (!matchesState(entry.status, states)) { + continue; + } + handles.push(handleFromSandboxHandle(entry)); + if (cap !== undefined && handles.length >= cap) { + return handles; + } + } + if (!result.nextCursor) { + return handles; + } + cursor = result.nextCursor; + } + return handles; + } + + async getById( + id: string, + options: { + states?: readonly string[] | null; + owned?: boolean; + homeDir?: string; + workdir?: string; + } = {}, + ): Promise { + const entry = await this.lookupHandle(id); + if (!entry) { + return null; + } + // `undefined` means "the caller did not filter"; `null` means "any state". + const states = options.states === undefined ? null : options.states; + if (!matchesState(entry.status, states)) { + return null; + } + const handle = handleFromSandboxHandle(entry); + if (options.homeDir !== undefined) { + handle.homeDir = options.homeDir; + } + if (options.workdir !== undefined) { + handle.workdir = options.workdir; + } + return handle; + } + + // --- launch -------------------------------------------------------------- + + async launch(options: LaunchOptions = {}): Promise { + return this.createSandbox(options, false); + } + + async launchDetached(options: LaunchOptions = {}): Promise { + // A detached sandbox keeps running after this Node process exits, so a + // short-lived request context can return before the workload finishes and a + // later tick can reattach by name. + return this.createSandbox(options, true); + } + + private async createSandbox( + options: LaunchOptions, + detached: boolean, + ): Promise { + const name = this.resolveName(options.name); + const workdir = options.workdir ?? this.defaultWorkdir; + const create = this.withBackend(async (sdk) => { + let builder = sdk.Sandbox.builder(name); + builder = this.snapshot + ? builder.fromSnapshot(this.snapshot) + : builder.image(this.image as string); + if (this.cpus !== undefined) { + builder = builder.cpus(this.cpus); + } + if (this.memoryMiB !== undefined) { + builder = builder.memory(this.memoryMiB); + } + if (this.idleTimeoutSeconds !== undefined) { + builder = builder.idleTimeout(this.idleTimeoutSeconds); + } + if (this.maxDurationSeconds !== undefined) { + builder = builder.maxDuration(this.maxDurationSeconds); + } + if (workdir !== undefined) { + builder = builder.workdir(workdir); + } + if (hasEntries(options.env)) { + builder = builder.envs(options.env); + } + const labels = mergeLabels(options.labels, options.label); + if (hasEntries(labels)) { + builder = builder.labels(labels); + } + if (detached) { + builder = builder.detached(true); + } + if (this.replaceExisting) { + builder = builder.replace(); + } + return builder.create(); + }); + + // The builder has no create-timeout setter, and `maxDuration` is a sandbox + // LIFETIME budget — mapping the caller's boot deadline onto it would kill + // every long-lived sandbox the moment that deadline elapsed. So the create + // deadline is enforced here instead, and the still-booting sandbox stays + // addressable under its deterministic name. + const sandbox = options.createTimeoutSeconds + ? await withDeadline(create, options.createTimeoutSeconds * 1000, name) + : await create; + + this.sandboxes.set(name, sandbox); + const handle: RuntimeHandle = { id: name, state: "STARTED", homeDir: this.homeDir }; + if (workdir !== undefined) { + handle.workdir = workdir; + } + return handle; + } + + private resolveName(requested?: string): string { + const name = requested ?? `${this.namePrefix}${randomUUID()}`; + const byteLength = Buffer.byteLength(name, "utf8"); + if (byteLength > MAX_SANDBOX_NAME_BYTES) { + throw new MicrosandboxNameTooLongError(name, byteLength); + } + return name; + } + + // --- exec ---------------------------------------------------------------- + + /** Bootstrap-plane exec. Same call as `runScript`, narrower result shape. */ + async exec( + handle: RuntimeHandle, + command: string, + options: ExecOptions = {}, + ): Promise { + const result = await this.runScript(handle, { + command, + ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + }); + return { output: result.output, exitCode: result.exitCode ?? 0 }; + } + + async runScript(handle: RuntimeHandle, options: { + command: string; + sessionId?: string; + cwd?: string; + timeoutMs?: number; + env?: Record; + }): Promise { + const sandbox = await this.requireSandbox(handle); + const cwd = options.cwd ?? handle.workdir; + // `shell(script)` takes no options, so a configurable POSIX shell with + // `-c` is what carries cwd / env / timeout onto a command string. + const output = await this.withBackend(() => + sandbox.execWith(this.shell, (builder) => { + let configured = builder.args(["-c", options.command]); + if (cwd !== undefined) { + configured = configured.cwd(cwd); + } + if (hasEntries(options.env)) { + configured = configured.envs(options.env); + } + if (options.timeoutMs !== undefined && options.timeoutMs > 0) { + configured = configured.timeout(options.timeoutMs); + } + return configured; + }), + ); + const stdout = output.stdout(); + const stderr = output.stderr(); + // A non-zero exit is a RESULT here, not an exception: the SDK resolves + // `ExecOutput` with `code`/`success` rather than throwing. + return { + output: combineOutput(stdout, stderr), + ...(stdout ? { stdout } : {}), + ...(stderr ? { stderr } : {}), + exitCode: typeof output.code === "number" ? output.code : null, + }; + } + + async startScript(handle: RuntimeHandle, options: { + command: string; + sessionId?: string; + cwd?: string; + timeoutMs?: number; + env?: Record; + suppressInputEcho?: boolean; + }): Promise { + const sandbox = await this.requireSandbox(handle); + const sessionId = options.sessionId ?? `run-${handle.id}-${randomUUID()}`; + const dir = this.scriptRunDir(sessionId); + const outPath = `${dir}/out`; + const exitPath = `${dir}/exit`; + const cwd = options.cwd ?? handle.workdir; + // Durable-file wrapper: the SDK's streaming `ExecHandle` is process-local + // and has no server-side id to poll from a fresh process, so combined + // output and the final exit code are captured to guest files that survive + // disconnect and reconnect across poll ticks. `nohup ... &` detaches the + // run from the submit call so the submit returns immediately. + const wrapped = + `mkdir -p ${shellSingleQuote(dir)}; ` + + `nohup ${shellSingleQuote(this.shell)} -c ` + + shellSingleQuote( + `{ ${options.command}\n} > ${shellSingleQuote(outPath)} 2>&1; ` + + `echo $? > ${shellSingleQuote(exitPath)}`, + ) + + ` > /dev/null 2>&1 & echo $!`; + const output = await this.withBackend(() => + sandbox.execWith(this.shell, (builder) => { + let configured = builder.args(["-c", wrapped]); + if (cwd !== undefined) { + configured = configured.cwd(cwd); + } + if (hasEntries(options.env)) { + configured = configured.envs(options.env); + } + // The caller's `timeoutMs` bounds the SUBMIT call only. It is + // deliberately NOT applied to the backgrounded command: the command's + // lifetime is the sandbox's, and applying the submit deadline here + // would kill every long-running run the moment submission completed. + if (options.timeoutMs !== undefined && options.timeoutMs > 0) { + configured = configured.timeout(options.timeoutMs); + } + return configured; + }), + ); + return { sessionId, commandId: output.stdout().trim() }; + } + + async getScriptStatus( + handle: RuntimeHandle, + sessionId: string, + _commandId: string, + ): Promise { + const sandbox = await this.requireSandbox(handle); + const exitPath = `${this.scriptRunDir(sessionId)}/exit`; + // The durable exit file is the source of truth: missing or empty means the + // run has not finished, never "finished with an unknown code". + const raw = await this.readFileBestEffort(sandbox, exitPath, 64); + const trimmed = raw.trim(); + if (!trimmed) { + return { exitCode: null }; + } + const parsed = Number.parseInt(trimmed, 10); + return { exitCode: Number.isFinite(parsed) ? parsed : null }; + } + + async getScriptLogs( + handle: RuntimeHandle, + sessionId: string, + commandId: string, + ): Promise { + const sandbox = await this.requireSandbox(handle); + const outPath = `${this.scriptRunDir(sessionId)}/out`; + const output = await this.readFileBestEffort(sandbox, outPath, SCRIPT_LOG_READ_MAX_BYTES); + // exitCode stays null: `getScriptStatus` is the single source of truth for + // the exit code, matching the Daytona, E2B and local adapters. + return { output, exitCode: null, cmdId: commandId }; + } + + // --- bootstrap-plane async exec aliases ---------------------------------- + + async startExec( + handle: RuntimeHandle, + command: string, + options: ExecOptions & { sessionId?: string } = {}, + ): Promise { + return this.startScript(handle, { command, ...options }); + } + + async getExecStatus( + handle: RuntimeHandle, + sessionId: string, + commandId: string, + ): Promise { + return this.getScriptStatus(handle, sessionId, commandId); + } + + async getExecLogs( + handle: RuntimeHandle, + sessionId: string, + commandId: string, + ): Promise { + const result = await this.getScriptLogs(handle, sessionId, commandId); + return { output: result.output, exitCode: result.exitCode ?? 0 }; + } + + // --- files --------------------------------------------------------------- + + /** + * Put one file into the guest. + * + * A `string` source is a HOST PATH and a `Buffer` source is file CONTENT — + * the same split the Daytona adapter uses, so this class's `uploadFile` and + * `uploadBundle` cannot disagree with each other. + */ + async uploadFile( + handle: RuntimeHandle, + source: string | Buffer, + destination: string, + ): Promise { + const sandbox = await this.requireSandbox(handle); + const fs = sandbox.fs(); + await this.ensureParentDir(fs, destination); + if (typeof source === "string") { + await this.withBackend(() => fs.copyFromHost(source, destination)); + return; + } + await this.withBackend(() => fs.write(destination, toUint8Array(source))); + } + + async uploadBundle(handle: RuntimeHandle, options: { + files: Array<{ source: string | Buffer; destination: string }>; + }): Promise { + for (const file of options.files) { + await this.uploadFile(handle, file.source, file.destination); + } + } + + async downloadFile( + handle: RuntimeHandle, + source: string, + destination?: string, + ): Promise { + const sandbox = await this.requireSandbox(handle); + const fs = sandbox.fs(); + if (destination) { + await this.withBackend(() => fs.copyToHost(source, destination)); + return; + } + const bytes = await this.withBackend(() => fs.read(source)); + return Buffer.from(bytes); + } + + async getHomeDir(handle: RuntimeHandle): Promise { + // The guest home directory is a property of the image, which this package + // does not choose, so it is injected rather than probed. + return handle.homeDir ?? this.homeDir; + } + + // --- lifecycle ----------------------------------------------------------- + + async start(handle: RuntimeHandle): Promise { + const entry = await this.lookupHandle(handle.id); + if (!entry) { + throw new Error(`Microsandbox sandbox "${handle.id}" is no longer available`); + } + const sandbox = await this.withBackend(() => entry.start()); + this.sandboxes.set(handle.id, sandbox); + return { ...handle, state: "STARTED" }; + } + + async stop(handle: RuntimeHandle): Promise { + const entry = await this.lookupHandle(handle.id); + if (!entry) { + // Already gone: stopping is idempotent, so this is success, not an error. + return; + } + this.sandboxes.delete(handle.id); + await this.withBackend(() => entry.stop()); + } + + /** + * Halt the sandbox AND drop its database record. + * + * Both halves matter: the name is the identity, so leaving a stopped record + * behind would make the next `launch` under that name collide. + */ + async destroy(handle: RuntimeHandle): Promise { + const entry = await this.lookupHandle(handle.id); + this.sandboxes.delete(handle.id); + if (!entry) { + return; + } + try { + await this.withBackend(() => entry.kill()); + } catch (error) { + // `remove` requires a stopped sandbox; a kill that failed because it was + // already stopped must not block the removal that frees the name. + if (!isSandboxNotFound(error) && !isAlreadyStopped(error)) { + throw error; + } + } + try { + await this.withBackend(() => entry.remove()); + } catch (error) { + if (!isSandboxNotFound(error)) { + throw error; + } + } + } + + // --- internals ----------------------------------------------------------- + + private async sdk(): Promise { + if (this.injectedSdk) { + return this.injectedSdk; + } + if (!this.sdkPromise) { + // Lazy — keeps `microsandbox` and its platform-specific native addon out + // of every non-microsandbox consumer. + this.sdkPromise = import("microsandbox").then( + (mod) => mod as unknown as MicrosandboxSdk, + ); + } + return this.sdkPromise; + } + + /** + * Run one SDK call with this runtime's backend in scope. + * + * `withDefaultBackend` restores the previous backend in a `finally`, so + * constructing a runtime never mutates process-wide state — the reason + * `setDefaultBackend` is never called from this adapter. + */ + private async withBackend(fn: (sdk: MicrosandboxSdk) => Promise | T): Promise { + const sdk = await this.sdk(); + return sdk.withDefaultBackend(this.backend, () => fn(sdk)); + } + + private async lookupHandle(name: string): Promise { + try { + return (await this.withBackend((sdk) => sdk.Sandbox.get(name))) ?? null; + } catch (error) { + if (isSandboxNotFound(error)) { + return null; + } + throw error; + } + } + + private async requireSandbox(handle: RuntimeHandle): Promise { + const cached = this.sandboxes.get(handle.id); + if (cached) { + return cached; + } + const entry = await this.lookupHandle(handle.id); + if (!entry) { + throw new Error(`Microsandbox sandbox "${handle.id}" is no longer available`); + } + // `connect` attaches WITHOUT taking lifecycle ownership, so a poll tick + // that reattaches cannot accidentally stop a sandbox it did not launch. + const sandbox = await this.withBackend(() => + entry.connectWithTimeout(this.connectTimeoutMs), + ); + this.sandboxes.set(handle.id, sandbox); + return sandbox; + } + + private async ensureParentDir(fs: MsbFsOps, destination: string): Promise { + const parent = parentDir(destination); + if (!parent) { + return; + } + try { + await this.withBackend(() => fs.mkdir(parent)); + } catch { + // Best effort: an already-present directory is the common case and must + // not fail the upload that follows. + } + } + + private async readFileBestEffort( + sandbox: MsbSandbox, + path: string, + maxBytes: number, + ): Promise { + // Read through a bounded `tail -c` rather than `fs.readToString`: it yields + // "" instead of throwing while the file is still absent, and caps the bytes + // pulled back to the caller. + try { + const output = await this.withBackend(() => + sandbox.execWith(this.shell, (builder) => + builder.args([ + "-c", + `tail -c ${maxBytes} ${shellSingleQuote(path)} 2>/dev/null || true`, + ]), + ), + ); + return output.stdout() ?? ""; + } catch { + return ""; + } + } + + private scriptRunDir(sessionId: string): string { + const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "_"); + return `${this.runStateDir}/${safe}`; + } +} + +// --- helpers --------------------------------------------------------------- + +/** + * Normalize an SDK status onto the `STARTED`/`STOPPED` vocabulary the delivery + * path reasons about. + * + * `draining` reads as STOPPED on purpose: a draining sandbox is on its way + * down, so handing it back as a warm lease would hand a caller a sandbox that + * is about to disappear underneath it. + */ +function normalizeStatus(status: MicrosandboxStatus | undefined): string { + return status === "running" ? "STARTED" : "STOPPED"; +} + +function matchesState( + status: MicrosandboxStatus | undefined, + states: readonly string[] | null, +): boolean { + if (states === null) { + return true; + } + return states.includes(normalizeStatus(status)); +} + +function handleFromSandboxHandle(entry: MsbSandboxHandle): RuntimeHandle { + return { + id: entry.name, + state: normalizeStatus(entry.status), + ...(entry.createdAt ? { createdAt: entry.createdAt.toISOString() } : {}), + ...(entry.updatedAt ? { updatedAt: entry.updatedAt.toISOString() } : {}), + }; +} + +function mergeLabels( + labels: Record | undefined, + label: string | undefined, +): Record { + return { + ...(labels ?? {}), + ...(label ? { label } : {}), + }; +} + +function combineOutput(stdout: string, stderr: string): string { + if (stdout && stderr) { + return stdout.endsWith("\n") || stderr.startsWith("\n") + ? `${stdout}${stderr}` + : `${stdout}\n${stderr}`; + } + return stdout || stderr || ""; +} + +/** Exact when the SDK's typed error code is present; textual only as a fallback. */ +function isSandboxNotFound(error: unknown): boolean { + if (typeof error !== "object" || error === null) { + return false; + } + const code = (error as { code?: unknown }).code; + if (typeof code === "string") { + // The SDK tags every error with a `MicrosandboxErrorCode`, so when a code is + // present it is authoritative and no message sniffing happens at all. + return code === "sandboxNotFound"; + } + const name = (error as { name?: unknown }).name; + if (name === "SandboxNotFoundError") { + return true; + } + const message = (error as { message?: unknown }).message; + return typeof message === "string" && /sandbox\s+not\s+found/i.test(message); +} + +function isAlreadyStopped(error: unknown): boolean { + if (typeof error !== "object" || error === null) { + return false; + } + const message = (error as { message?: unknown }).message; + return typeof message === "string" && /already\s+stopped|not\s+running/i.test(message); +} + +async function withDeadline( + promise: Promise, + timeoutMs: number, + sandboxName: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new MicrosandboxCreateTimeoutError(sandboxName, timeoutMs)), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +function parentDir(path: string): string | null { + const index = path.lastIndexOf("/"); + if (index <= 0) { + return null; + } + return path.slice(0, index); +} + +function toUint8Array(buffer: Buffer): Uint8Array { + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); +} + +function hasEntries(record?: Record): record is Record { + return !!record && Object.keys(record).length > 0; +} + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} From 3fce8507a370c737b6f7d17a5354f74705e307e6 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 02:32:02 +0200 Subject: [PATCH 02/10] fix(microsandbox): repair ownership, admission, lookup and backend scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repairs the review findings on the Micro Sandbox adapter. Every fix below is covered by tests that fail against e19e59d; a throwaway probe reproduced 8 of the 9 defects directly against that commit before any of them were fixed. Ownership. The adapter registered nothing about who a sandbox belongs to, so `getById(id, { owned: false })` followed by `destroy` deleted a microVM the caller had only borrowed. A registry now records ownership per name — claimed by `launch`/`launchDetached`, by `getById(id, { owned: true })`, and by a lookup that explicitly claims what it finds — and `destroy`, `stop` and `start` make no remote call at all for an unowned or unknown handle, matching the Daytona adapter. Ownership is sticky-true, so a later unclaimed attach cannot demote a sandbox this process launched and strand it. Late creates. The SDK cannot cancel an in-flight create, so a create that finished after `createTimeoutSeconds` used to leave a running microVM nobody was waiting for, holding a name the next launch needed. The timed-out create is now watched: a late success is reclaimed (kill + remove), a late failure is consumed, and a relaunch of the same name waits for the reclamation instead of racing it. Lookup. Exclusions were applied to an already-capped page, so a first page full of already-claimed sandboxes answered "nothing warm available" while the next page held a free one; they are now applied during the drain. A listing that cannot be read or cannot advance — an unreadable page body, a non-string cursor, a cursor identical to the one just used — now fails closed rather than returning a short list the caller cannot tell apart from a complete one. Every drain is bounded by a deadline (`options.timeoutMs`, default 10s). Request size resolves as `limit ?? pageSize ?? listPageSize`, parity with the other runtimes, while `limit` still caps results. Backend scoping. `withDefaultBackend` swaps one process-wide slot and is documented as not task-local, so two overlapping calls on different backends could send one of them to the wrong place. Default-dependent statics now run behind one process-global gate: same-backend calls share the open scope and still run concurrently, a different backend queues until the scope has closed, and a scope that cannot be entered fails the call closed instead of running it on whatever the process default happens to hold. Bound `Sandbox`/`SandboxHandle ` calls stay off the gate — they read no global state, and holding it across a long exec would block every other backend for the run's lifetime. Async runs. The durable-file wrapper is replaced by a guest protocol that takes the command as an argument rather than as interpolated script text: - admission claims the session directory with an atomic `mkdir`, so a resubmit of the same command adopts the existing run (`reconciled: true`) instead of starting a second one, and a resubmit of a different command is refused without overwriting anything; - the command runs in a CHILD shell, so an `exit 7` inside it no longer skips the exit-code record and leaves the caller polling forever; - a run whose process is gone without an exit code — killed, out of memory, or interrupted by a sandbox restart, detected by pid liveness and boot id — ends the poll with `MicrosandboxRunLostError`; - session ids are encoded reversibly, so `a/b` and `a_b` can no longer share one run directory and report each other's exit codes; - `getExecLogs` reads the authoritative status instead of defaulting the log read's null exit code to 0, which reported every unfinished run as success. Truthfulness. The SDK's Node 22+ floor, its native addon, and its hardware-virtualization requirement are documented in the README and wrapped into the lazy import's failure message; a test pins the SDK's declared engines so the claim cannot go stale. The guest protocol is exercised against a real /bin/sh, and the SDK-contract checks skip rather than fail where the addon cannot load. Co-Authored-By: Claude Opus 5 Session-Id: d6d3ce1d-8b64-4c3f-aaf1-da5aa8d90545 Session-Id: 6d810ec8-fbbc-43f2-8ef2-bc87744ca04f --- README.md | 20 +- src/index.ts | 5 + src/microsandbox/runtime.test.ts | 1660 +++++++++++++++++++++++++++--- src/microsandbox/runtime.ts | 1127 +++++++++++++++++--- src/port.ts | 6 + 5 files changed, 2542 insertions(+), 276 deletions(-) diff --git a/README.md b/README.md index 539d345..31ba68a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,23 @@ npm install @agent-relay/sandbox Provider SDKs are peer dependencies: install the one you intend to use. A consumer that only runs local sandboxes does not need a remote provider SDK. +### Provider constraints + +Each adapter inherits its provider SDK's requirements, and they are not all the +same as this package's: + +| Adapter | Peer dependency | Requirements beyond this package's | +| --- | --- | --- | +| `DaytonaRuntime` | `@daytonaio/sdk` | — | +| `E2BSandboxRuntime` | `e2b` | — | +| `MicrosandboxRuntime` | `microsandbox` | **Node.js 22+**, a platform-specific native addon (macOS arm64, Linux x64/arm64, Windows x64/arm64), and — for its `local` backend — hardware virtualization: KVM on Linux, Apple Silicon on macOS, or WHP on Windows 10+ | +| `LocalSandboxRuntime` | — | A reachable local sandbox service | + +The package itself keeps a Node 20 floor, because a consumer that never touches +the microsandbox adapter never loads that SDK: it is imported lazily, at first +use, and a load failure is reported with the constraint that most often +explains it. + ## Design Two pieces, deliberately kept apart: @@ -77,7 +94,8 @@ npm run typecheck npm test # node:test ``` -Requires Node.js 20 or newer. +Requires Node.js 20 or newer. The microsandbox adapter's own tests need Node 22+ +to load the real SDK; without it, its SDK-contract checks skip rather than fail. ## Releasing diff --git a/src/index.ts b/src/index.ts index 07b86b9..78922bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,8 +75,13 @@ export type { export { MicrosandboxCreateTimeoutError, + MicrosandboxLookupTimeoutError, MicrosandboxNameTooLongError, + MicrosandboxPaginationError, + MicrosandboxRunLostError, + MicrosandboxRunNotFinishedError, MicrosandboxRuntime, + MicrosandboxSessionConflictError, } from "./microsandbox/runtime.js"; export type { MicrosandboxBackend, diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts index 0de7798..141f574 100644 --- a/src/microsandbox/runtime.test.ts +++ b/src/microsandbox/runtime.test.ts @@ -1,18 +1,32 @@ import assert from "node:assert/strict"; import { Buffer } from "node:buffer"; -import { after, describe, it } from "node:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; import * as pkg from "../index.js"; import { MicrosandboxCreateTimeoutError, + MicrosandboxLookupTimeoutError, MicrosandboxNameTooLongError, + MicrosandboxPaginationError, + MicrosandboxRunLostError, + MicrosandboxRunNotFinishedError, MicrosandboxRuntime, + MicrosandboxSessionConflictError, resolveSandboxRuntimeCapabilities, } from "../index.js"; +import { + MICROSANDBOX_RUN_ADMIT_SCRIPT, + MICROSANDBOX_RUN_STATUS_SCRIPT, +} from "./runtime.js"; import type { MicrosandboxBackend, MicrosandboxRuntimeOptions, MicrosandboxSdk, + RuntimeHandle, SandboxRuntime, } from "../index.js"; @@ -52,10 +66,20 @@ type HarnessConfig = { exec?: (argv: string[]) => ExecOutcome | Promise; /** Pages returned by successive `Sandbox.listWith` calls. */ pages?: Array<{ sandboxes: HandleSpec[]; nextCursor?: string }>; + /** + * Pages returned VERBATIM, bypassing the well-formed `pages` shaping, so a + * test can hand the adapter the malformed page a real backend can emit. + * The last entry repeats once the list is exhausted. + */ + rawPages?: unknown[]; /** `Sandbox.get` resolution, by name. */ get?: (name: string) => HandleSpec | null | Promise; + /** Delay/hang injected into `Sandbox.get`. */ + onGet?: () => Promise; /** Delay/hang injected into `builder.create()`. */ onCreate?: () => Promise; + /** Delay/hang injected into `Sandbox.listWith`, before any page is returned. */ + listWith?: () => Promise; /** Filesystem behaviour overrides. */ fs?: { onMkdir?: (path: string) => Promise; @@ -66,8 +90,112 @@ type HarnessConfig = { }; }; +/** + * In-guest model of the async-run protocol. + * + * The adapter's two shell scripts are the real contract, and they are executed + * for real against `/bin/sh` further down. This model stands in for the guest + * in the adapter-level tests: it implements the same state machine — one + * atomic claim per session directory, adoption of an identical resubmit, + * refusal of a conflicting one, and an exit code that exists only once the run + * process has recorded it. + */ +type GuestRun = { + command: string; + pid: string; + bootId: string; + alive: boolean; + exit?: string; + output: string; +}; + +function createGuest() { + const runs = new Map(); + const admissions: string[] = []; + let nextPid = 1000; + let bootId = "boot-1"; + + const admit = (command: string, dir: string) => { + const existing = runs.get(dir); + if (!existing) { + const pid = String(nextPid); + nextPid += 1; + runs.set(dir, { command, pid, bootId, alive: true, output: "" }); + admissions.push(dir); + return { stdout: `ADMITTED ${pid}\n` }; + } + if (existing.command === command) { + return { stdout: `CLAIMED ${existing.pid}\n` }; + } + return { stdout: "CONFLICT\n" }; + }; + + const status = (dir: string) => { + const run = runs.get(dir); + if (!run) { + return { stdout: "MISSING\n" }; + } + if (run.exit !== undefined) { + return { stdout: `EXIT ${run.exit}\n` }; + } + if (run.bootId !== bootId) { + return { stdout: "LOST sandbox-restarted\n" }; + } + return { stdout: run.alive ? "RUNNING\n" : "LOST process-gone\n" }; + }; + + return { + runs, + admissions, + /** The run recorded an exit code, as its wrapper's final act. */ + finish(dir: string, exit: number | string) { + const run = runs.get(dir); + assert.ok(run, `no run admitted at ${dir}`); + run.alive = false; + run.exit = String(exit); + }, + /** The run's process died without recording anything. */ + kill(dir: string) { + const run = runs.get(dir); + assert.ok(run, `no run admitted at ${dir}`); + run.alive = false; + }, + /** The sandbox rebooted, so every recorded pid now belongs to someone else. */ + restartSandbox() { + bootId = `boot-${bootId.length}`; + }, + write(dir: string, output: string) { + const run = runs.get(dir); + assert.ok(run, `no run admitted at ${dir}`); + run.output = output; + }, + exec(argv: string[]): ExecOutcome { + const script = argv[1] ?? ""; + if (script === MICROSANDBOX_RUN_ADMIT_SCRIPT) { + return admit(argv[3] ?? "", argv[4] ?? ""); + } + if (script === MICROSANDBOX_RUN_STATUS_SCRIPT) { + return status(argv[3] ?? ""); + } + const tailed = /^tail -c \d+ '(.*)'/.exec(script); + if (tailed) { + const path = tailed[1] ?? ""; + for (const [dir, run] of runs) { + if (path === `${dir}/out`) { + return { stdout: run.output }; + } + } + return { stdout: "" }; + } + return {}; + }, + }; +} + function createHarness(config: HarnessConfig = {}) { const log: LogEntry[] = []; + // Unless a test pins an exact exec outcome, the guest model answers. + const guest = createGuest(); const rec = (fn: string, ...args: unknown[]) => { log.push({ fn, args }); }; @@ -148,7 +276,7 @@ function createHarness(config: HarnessConfig = {}) { const argv = (log .slice(before) .find((entry) => entry.fn === "exec.args")?.args[0] ?? []) as string[]; - const outcome = (await config.exec?.(argv)) ?? {}; + const outcome = config.exec ? await config.exec(argv) : guest.exec(argv); return makeOutput(outcome); }, fs: () => makeFs(), @@ -260,6 +388,7 @@ function createHarness(config: HarnessConfig = {}) { }, async get(name: string) { rec("Sandbox.get", name); + await config.onGet?.(); const spec = await config.get?.(name); return spec ? (makeHandle(spec) as never) : null; }, @@ -280,6 +409,12 @@ function createHarness(config: HarnessConfig = {}) { }; rec("Sandbox.listWith"); configure(listBuilder as never); + await config.listWith?.(); + if (config.rawPages) { + const raw = config.rawPages[Math.min(pageIndex, config.rawPages.length - 1)]; + pageIndex += 1; + return raw as never; + } const page = config.pages?.[pageIndex] ?? { sandboxes: [] }; pageIndex += 1; return { @@ -307,7 +442,7 @@ function createHarness(config: HarnessConfig = {}) { }, }); - return { sdk: sdkWithGlobalSetter, log }; + return { sdk: sdkWithGlobalSetter, log, guest }; } function makeOutput(outcome: ExecOutcome) { @@ -338,6 +473,17 @@ function firstArgs(log: LogEntry[], fn: string): unknown[] { return found.args; } +/** Poll a predicate that a background continuation is expected to satisfy. */ +async function waitFor(predicate: () => boolean, description: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(`timed out waiting for ${description}`); +} + function makeRuntime( sdk: MicrosandboxSdk, overrides: Partial = {}, @@ -451,6 +597,342 @@ describe("backend scoping", () => { }); }); +describe("backend gate (the scope is process-global)", () => { + /** Walk the log and prove no two backends ever hold the scope at once. */ + const assertNoOverlappingScopes = (log: LogEntry[]) => { + let open: unknown = null; + for (const entry of log) { + if (entry.fn === "withDefaultBackend:enter") { + assert.equal(open, null, "a second backend entered while one was still open"); + open = entry.args[0]; + } + if (entry.fn === "withDefaultBackend:exit") { + assert.deepEqual(entry.args[0], open, "the wrong backend left the scope"); + open = null; + } + } + }; + + it("makes a call on another backend wait instead of overlapping", async () => { + // `withDefaultBackend` mutates PROCESS-WIDE state and is documented as not + // task-local: two overlapping scopes on different backends mean one call + // runs against the wrong backend. + let releaseFirst!: () => void; + let creates = 0; + const { sdk, log } = createHarness({ + onCreate: () => { + creates += 1; + return creates > 1 + ? Promise.resolve() + : new Promise((resolve) => { + releaseFirst = resolve; + }); + }, + }); + const cloud = makeRuntime(sdk); + const local = makeRuntime(sdk, { backend: "local" }); + + const first = cloud.launch({ name: "cloud-1" }); + const second = local.launch({ name: "local-1" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.deepEqual( + argsFor(log, "withDefaultBackend:enter").map((args) => args[0]), + [CLOUD_BACKEND], + "the second backend must not open a scope while the first is held", + ); + assert.equal( + argsFor(log, "builder.create").length, + 1, + "the queued call must not reach the SDK on the wrong backend", + ); + + releaseFirst(); + await Promise.all([first, second]); + assertNoOverlappingScopes(log); + assert.deepEqual( + argsFor(log, "withDefaultBackend:enter").map((args) => args[0]), + [CLOUD_BACKEND, "local"], + ); + }); + + it("lets two calls on the same backend share one open scope", async () => { + // Serializing everything would be correct and needlessly slow: calls that + // want the backend that is already in scope cannot observe a wrong one. + let releaseFirst!: () => void; + let creates = 0; + const { sdk, log } = createHarness({ + onCreate: () => { + creates += 1; + return creates > 1 + ? Promise.resolve() + : new Promise((resolve) => { + releaseFirst = resolve; + }); + }, + }); + const runtime = makeRuntime(sdk); + const first = runtime.launch({ name: "a" }); + const second = runtime.launch({ name: "b" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal( + argsFor(log, "builder.create").length, + 2, + "a same-backend call must not queue behind an in-flight one", + ); + + releaseFirst(); + await Promise.all([first, second]); + assert.equal( + argsFor(log, "withDefaultBackend:enter").length, + 1, + "one shared scope, entered once", + ); + assertNoOverlappingScopes(log); + }); + + it("closes the scope before handing it to a different backend, even on failure", async () => { + const { sdk, log } = createHarness({ + onCreate: () => Promise.reject(new Error("boot refused")), + }); + const cloud = makeRuntime(sdk); + const local = makeRuntime(sdk, { backend: "local" }); + await assert.rejects(cloud.launch({ name: "a" }), /boot refused/); + await assert.rejects(local.launch({ name: "b" }), /boot refused/); + assertNoOverlappingScopes(log); + }); + + it("still never reaches for the permanent process-wide setter", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launch({ name: "a" }); + assert.equal(called(log, "setDefaultBackend"), false); + }); + + it("does not hold the process-global gate across a backend-bound instance call", async () => { + // `Sandbox` and `SandboxHandle` retain the backend they were resolved on + // (`backendKind` on the instance; every method delegates to that bound + // native object), so an instance call does NOT read the process default. + // Holding the gate across one would let a single long exec block every + // other backend's work in the process for the whole run. + let releaseExec!: () => void; + const { sdk } = createHarness({ + exec: () => + new Promise((resolve) => { + releaseExec = () => resolve({ code: 0, stdout: "" }); + }), + }); + const cloud = makeRuntime(sdk); + const local = makeRuntime(sdk, { backend: "local" }); + const handle = await cloud.launch({ name: "busy" }); + const running = cloud.runScript(handle, { command: "sleep 100" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const outcome = await Promise.race([ + local.launch({ name: "other" }).then(() => "launched"), + new Promise((resolve) => setTimeout(() => resolve("blocked"), 250)), + ]); + assert.equal( + outcome, + "launched", + "a backend-bound instance call must not hold the process-global gate", + ); + releaseExec(); + await running; + }); + + it("keeps interleaved two-runtime static calls on their own backend", async () => { + // The discriminating one. `withDefaultBackend` mutates ONE process-wide + // slot; this fake models exactly that slot, and every DEFAULT-DEPENDENT + // static records the value it observed when it ran. Two runtimes on + // different backends then interleave real work with an await inside every + // call, so any overlap of two scopes shows up as a call that observed the + // other runtime's backend. + let processBackend: unknown = "unset"; + let depth = 0; + let maxDepth = 0; + const observed: Array<{ call: string; owner: string; backend: unknown }> = []; + const gap = () => new Promise((resolve) => setTimeout(resolve, 1)); + const ownerOf = (name: string) => (name.startsWith("local") ? "local" : "cloud"); + + const sdk: MicrosandboxSdk = { + Sandbox: { + builder(name: string) { + // Recorded at BUILDER-CONSTRUCTION time, not at create() time: the + // SDK constructs the native builder here, so the whole chain has to + // sit inside one scope. + const atConstruction = processBackend; + const builder = { + image: () => builder, + fromSnapshot: () => builder, + cpus: () => builder, + memory: () => builder, + workdir: () => builder, + envs: () => builder, + labels: () => builder, + detached: () => builder, + idleTimeout: () => builder, + maxDuration: () => builder, + replace: () => builder, + async create() { + await gap(); + observed.push({ call: "builder", owner: ownerOf(name), backend: atConstruction }); + observed.push({ call: "create", owner: ownerOf(name), backend: processBackend }); + return { name, execWith: async () => makeOutput({}), fs: () => ({}) } as never; + }, + }; + return builder as never; + }, + async get(name: string) { + await gap(); + observed.push({ call: "get", owner: ownerOf(name), backend: processBackend }); + return null; + }, + async listWith(configure) { + let seen: Record = {}; + const listBuilder = { + limit: () => listBuilder, + cursor: () => listBuilder, + labels: (labels: Record) => { + seen = labels; + return listBuilder; + }, + }; + configure(listBuilder as never); + await gap(); + observed.push({ call: "listWith", owner: seen.owner ?? "?", backend: processBackend }); + return { sandboxes: [] } as never; + }, + }, + async withDefaultBackend(backend, fn) { + depth += 1; + maxDepth = Math.max(maxDepth, depth); + const previous = processBackend; + processBackend = backend; + try { + return await fn(); + } finally { + processBackend = previous; + depth -= 1; + } + }, + }; + + const cloud = makeRuntime(sdk); + const local = makeRuntime(sdk, { backend: "local" }); + const work: Array> = []; + for (let round = 0; round < 8; round += 1) { + work.push(cloud.launch({ name: `cloud-${round}` })); + work.push(local.launch({ name: `local-${round}` })); + work.push(cloud.getById(`cloud-get-${round}`)); + work.push(local.getById(`local-get-${round}`)); + work.push(cloud.findAllByLabels({ owner: "cloud" })); + work.push(local.findAllByLabels({ owner: "local" })); + } + await Promise.all(work); + + assert.equal(maxDepth, 1, "two backend scopes were open in the process at once"); + assert.ok(observed.length >= 8 * 6, `expected every call to be observed: ${observed.length}`); + const wrong = observed.filter((entry) => { + const expected = entry.owner === "local" ? "local" : CLOUD_BACKEND; + try { + assert.deepEqual(entry.backend, expected); + return false; + } catch { + return true; + } + }); + assert.deepEqual( + wrong, + [], + "a default-dependent static ran while the other runtime's backend was in scope", + ); + }); + + it("fails closed when the backend scope cannot be entered", async () => { + // If the scope was never pushed, the process default is whatever someone + // else set. Running the call anyway sends this runtime's work to an + // arbitrary backend — the exact failure the gate exists to prevent. + const reached: string[] = []; + const sdk: MicrosandboxSdk = { + Sandbox: { + builder(name: string) { + reached.push("builder"); + const builder = new Proxy({}, { + get: (_target, prop) => + prop === "create" + ? async () => { + reached.push("create"); + return { name } as never; + } + : () => builder, + }); + return builder as never; + }, + async get() { + reached.push("get"); + return null; + }, + async listWith() { + reached.push("listWith"); + return { sandboxes: [] } as never; + }, + }, + async withDefaultBackend() { + throw new Error("native backend scope bindings are unavailable"); + }, + }; + + await assert.rejects( + makeRuntime(sdk).launch({ name: "a" }), + /native backend scope bindings are unavailable/, + ); + assert.deepEqual( + reached, + [], + "a call whose backend scope failed must not run on the process default", + ); + }); + + it("stays usable after a backend scope failure", async () => { + // A gate that keeps its slot after a failed enter wedges every later call + // in the process, on every backend, for good. + let failNext = true; + const sdk: MicrosandboxSdk = { + Sandbox: { + builder(name: string) { + const builder = new Proxy({}, { + get: (_target, prop) => + prop === "create" ? async () => ({ name }) as never : () => builder, + }); + return builder as never; + }, + async get() { + return null; + }, + async listWith() { + return { sandboxes: [] } as never; + }, + }, + withDefaultBackend(_backend, fn) { + if (failNext) { + failNext = false; + // Thrown SYNCHRONOUSLY, exactly as the SDK's own missing-native- + // binding guard does before it ever returns a promise. + throw new Error("scope push failed"); + } + return Promise.resolve().then(fn); + }, + }; + + const runtime = makeRuntime(sdk); + await assert.rejects(runtime.launch({ name: "a" }), /scope push failed/); + const outcome = await Promise.race([ + runtime.launch({ name: "b" }).then(() => "recovered"), + new Promise((resolve) => setTimeout(() => resolve("wedged"), 250)), + ]); + assert.equal(outcome, "recovered", "a failed enter must not hold the gate shut"); + }); +}); + describe("sandbox naming (identity is a name, not a server id)", () => { it("uses the caller's name as the handle id", async () => { const { sdk, log } = createHarness(); @@ -648,6 +1130,98 @@ describe("create deadline", () => { const handle = await makeRuntime(sdk).launch({ name: "s1" }); assert.equal(handle.id, "s1"); }); + + it("reclaims a create that lands after the deadline instead of leaking it", async () => { + // The SDK cannot cancel an in-flight create. A sandbox that finishes + // booting after the caller gave up is a running microVM nobody is waiting + // for, holding a name the next launch needs. + let finishCreate!: () => void; + const { sdk, log } = createHarness({ + get: (name) => ({ name }), + onCreate: () => + new Promise((resolve) => { + finishCreate = resolve; + }), + }); + await assert.rejects( + makeRuntime(sdk).launch({ name: "slow", createTimeoutSeconds: 0.02 }), + MicrosandboxCreateTimeoutError, + ); + assert.equal(called(log, "handle.kill"), false, "nothing to reclaim while the create is in flight"); + + finishCreate(); + await waitFor(() => called(log, "handle.remove"), "the late sandbox to be reclaimed"); + assert.deepEqual(firstArgs(log, "handle.kill"), ["slow"]); + assert.deepEqual(firstArgs(log, "handle.remove"), ["slow"]); + }); + + // Guard rather than a fix: `Promise.race` already consumed the late + // rejection. It stays because the reclamation path attaches handlers of its + // own, and losing that consumption would surface as a process-level crash. + it("leaves no unhandled rejection when the late create fails on its own", async () => { + let failCreate!: (error: Error) => void; + const { sdk, log } = createHarness({ + get: (name) => ({ name }), + onCreate: () => + new Promise((_resolve, reject) => { + failCreate = reject; + }), + }); + const rejections: unknown[] = []; + const record = (reason: unknown) => { + rejections.push(reason); + }; + process.on("unhandledRejection", record); + try { + await assert.rejects( + makeRuntime(sdk).launch({ name: "slow", createTimeoutSeconds: 0.02 }), + MicrosandboxCreateTimeoutError, + ); + failCreate(new Error("boot failed")); + // Two macrotask turns: Node reports an unhandled rejection one tick after + // the promise settles with no handler attached. + await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.deepEqual(rejections, [], "the abandoned create must be consumed, not dropped"); + assert.equal( + called(log, "handle.kill"), + false, + "a create that failed on its own left nothing to reclaim", + ); + } finally { + process.off("unhandledRejection", record); + } + }); + + it("holds a relaunch of the same name until the reclamation finishes", async () => { + let finishCreate!: () => void; + let creates = 0; + const { sdk, log } = createHarness({ + get: (name) => ({ name }), + onCreate: () => { + creates += 1; + // Only the first create is held open; the relaunch boots immediately. + return creates > 1 + ? Promise.resolve() + : new Promise((resolve) => { + finishCreate = resolve; + }); + }, + }); + const runtime = makeRuntime(sdk); + await assert.rejects( + runtime.launch({ name: "slow", createTimeoutSeconds: 0.02 }), + MicrosandboxCreateTimeoutError, + ); + finishCreate(); + // The relaunch is issued while the reclamation is still pending; if it did + // not wait, the reclamation would delete the sandbox it just created. + await runtime.launch({ name: "slow" }); + const order = names(log).filter( + (entry) => entry === "handle.remove" || entry === "builder.create", + ); + assert.deepEqual(order, ["builder.create", "handle.remove", "builder.create"]); + }); }); describe("label lookup", () => { @@ -759,6 +1333,92 @@ describe("label lookup", () => { assert.equal(found?.id, "b"); }); + it("keeps looking past a page of excluded sandboxes", async () => { + // Applying exclusions to an already-capped page answers "nothing warm + // available" whenever the first page happens to be full of sandboxes the + // caller already holds — and the caller then launches a cold sandbox it + // did not need. + const { sdk } = createHarness({ + pages: [ + { sandboxes: [{ name: "claimed-1" }, { name: "claimed-2" }], nextCursor: "c1" }, + { sandboxes: [{ name: "free" }] }, + ], + }); + const found = await makeRuntime(sdk).findByLabels( + {}, + { excludeIds: ["claimed-1", "claimed-2"], pageSize: 2 }, + ); + assert.equal(found?.id, "free"); + }); + + it("counts the cap against sandboxes the caller can actually use", async () => { + const { sdk } = createHarness({ + pages: [{ sandboxes: [{ name: "claimed" }, { name: "free" }] }], + }); + const handles = await makeRuntime(sdk).findAllByLabels( + {}, + { excludeIds: ["claimed"], limit: 1 }, + ); + assert.deepEqual(handles.map((handle) => handle.id), ["free"]); + }); + + it("honours exclusions on the full listing too", async () => { + const { sdk } = createHarness({ + pages: [{ sandboxes: [{ name: "a" }, { name: "b" }] }], + }); + const handles = await makeRuntime(sdk).findAllByLabels({}, { excludeIds: ["a"] }); + assert.deepEqual(handles.map((handle) => handle.id), ["b"]); + }); + + it("treats pageSize as a request size, not a result cap", async () => { + // pageSize sizes each request; capping RESULTS by it silently truncates a + // listing the caller asked to receive in full. + const { sdk } = createHarness({ + pages: [ + { sandboxes: [{ name: "a" }], nextCursor: "c1" }, + { sandboxes: [{ name: "b" }] }, + ], + }); + const handles = await makeRuntime(sdk).findAllByLabels({}, { pageSize: 1 }); + assert.deepEqual(handles.map((handle) => handle.id), ["a", "b"]); + }); + + it("stops draining pages when the lookup deadline elapses", async () => { + // Without a deadline a listing that never answers hangs the caller's + // request forever, holding a warm-lease decision open indefinitely. + const { sdk } = createHarness({ + listWith: () => new Promise(() => {}), + }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({}, { timeoutMs: 20 }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxLookupTimeoutError); + assert.equal(error.timeoutMs, 20); + return true; + }, + ); + }); + + it("bounds a count with the same deadline", async () => { + const { sdk } = createHarness({ + listWith: () => new Promise(() => {}), + }); + await assert.rejects( + makeRuntime(sdk).countByLabels({}, { timeoutMs: 20 }), + MicrosandboxLookupTimeoutError, + ); + }); + + it("applies the runtime's configured lookup deadline when the caller gives none", async () => { + const { sdk } = createHarness({ + listWith: () => new Promise(() => {}), + }); + await assert.rejects( + makeRuntime(sdk, { lookupTimeoutMs: 20 }).findByLabels({}), + MicrosandboxLookupTimeoutError, + ); + }); + it("answers a zero maxCount without any SDK call", async () => { const { sdk, log } = createHarness({ pages: [{ sandboxes: [{ name: "a" }] }] }); assert.equal(await makeRuntime(sdk).countByLabels({}, { maxCount: 0 }), 0); @@ -779,61 +1439,247 @@ describe("label lookup", () => { }); }); -describe("getById", () => { - it("resolves a sandbox by name", async () => { - const { sdk } = createHarness({ get: (name) => ({ name, status: "running" }) }); - const handle = await makeRuntime(sdk).getById("s1"); - assert.equal(handle?.id, "s1"); - assert.equal(handle?.state, "STARTED"); - }); +describe("pagination fails closed", () => { + // A warm-lease decision is made from the ANSWER to a listing. A drain that + // gives up quietly — on a cursor that never advances, or on a page body the + // adapter cannot read — hands back a short list the caller cannot tell apart + // from "there is nothing else", and the caller launches a cold sandbox it + // did not need or under-counts a quota it is enforcing. - it("returns a stopped sandbox when the caller did not filter states", async () => { - const { sdk } = createHarness({ get: (name) => ({ name, status: "stopped" }) }); - const handle = await makeRuntime(sdk).getById("s1"); - assert.equal(handle?.state, "STOPPED"); + it("refuses a cursor that never advances instead of truncating the listing", async () => { + const { sdk, log } = createHarness({ + rawPages: [ + { sandboxes: [{ name: "a", status: "running" }], nextCursor: "c1" }, + { sandboxes: [{ name: "b", status: "running" }], nextCursor: "c1" }, + ], + }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({}), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxPaginationError); + assert.match(error.message, /cursor/i); + return true; + }, + ); + assert.equal( + argsFor(log, "Sandbox.listWith").length, + 2, + "the repeat must be caught on the page that repeats it, not after a spin", + ); }); - it("filters out a sandbox that fails the requested state filter", async () => { - const { sdk } = createHarness({ get: (name) => ({ name, status: "stopped" }) }); - assert.equal(await makeRuntime(sdk).getById("s1", { states: ["STARTED"] }), null); + it("refuses a cursor that is not a string", async () => { + const { sdk, log } = createHarness({ + rawPages: [{ sandboxes: [], nextCursor: 42 }], + }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({}), + MicrosandboxPaginationError, + ); + assert.equal( + called(log, "list.cursor"), + false, + "an unreadable cursor must never be sent back to the provider", + ); }); - it("returns null when the SDK resolves nothing", async () => { - const { sdk } = createHarness({ get: () => null }); - assert.equal(await makeRuntime(sdk).getById("s1"), null); + it("refuses a page whose entries are not a listing", async () => { + const { sdk } = createHarness({ rawPages: [{ sandboxes: null }] }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({}), + MicrosandboxPaginationError, + ); }); - it("returns null on the SDK's typed sandboxNotFound error", async () => { - const { sdk } = createHarness({ - get: () => { - throw sdkError("sandboxNotFound", "sandbox \"s1\" not found"); - }, - }); - assert.equal(await makeRuntime(sdk).getById("s1"), null); + it("refuses a page that is not an object at all", async () => { + const { sdk } = createHarness({ rawPages: ["not-a-page"] }); + await assert.rejects( + makeRuntime(sdk).findByLabels({}), + MicrosandboxPaginationError, + ); }); - it("propagates an unrelated SDK error instead of reporting it as absent", async () => { - const { sdk } = createHarness({ - get: () => { - throw sdkError("runtime", "hypervisor unavailable"); - }, - }); - await assert.rejects(makeRuntime(sdk).getById("s1"), /hypervisor unavailable/); + it("fails a count closed too, rather than under-reporting it", async () => { + const { sdk } = createHarness({ rawPages: [{ sandboxes: null }] }); + await assert.rejects( + makeRuntime(sdk).countByLabels({}), + MicrosandboxPaginationError, + ); }); - it("trusts the typed code over a misleading message", async () => { + it("still accepts a well-formed listing that ends without a cursor", async () => { const { sdk } = createHarness({ - get: () => { - // A transport failure whose text happens to contain "not found" must - // NOT be laundered into "this sandbox does not exist" — that is how a - // live sandbox gets orphaned and a duplicate launched in its place. - throw sdkError("http", "upstream route not found"); - }, + rawPages: [{ sandboxes: [{ name: "a", status: "running" }] }], }); - await assert.rejects(makeRuntime(sdk).getById("s1"), /upstream route not found/); + const handles = await makeRuntime(sdk).findAllByLabels({}); + assert.deepEqual(handles.map((handle) => handle.id), ["a"]); }); +}); - it("still recognises a not-found error carrying no typed code", async () => { +describe("limit / pageSize parity with the other runtimes", () => { + // Daytona, E2B and the local runtime all resolve the request size the same + // way — `options.limit ?? options.pageSize` — so a caller that has tuned one + // provider's lookup gets the same request shape here. + + it("sends limit as the page request size", async () => { + const { sdk, log } = createHarness({ + pages: [{ sandboxes: [{ name: "a" }, { name: "b" }] }], + }); + await makeRuntime(sdk).findAllByLabels({}, { limit: 2 }); + assert.deepEqual(argsFor(log, "list.limit"), [[2]]); + }); + + it("prefers limit over pageSize, exactly as the other runtimes do", async () => { + const { sdk, log } = createHarness({ + pages: [{ sandboxes: [{ name: "a" }, { name: "b" }, { name: "c" }] }], + }); + await makeRuntime(sdk).findAllByLabels({}, { limit: 3, pageSize: 7 }); + assert.deepEqual(argsFor(log, "list.limit"), [[3]]); + }); + + it("falls back to pageSize when the caller gives no limit", async () => { + const { sdk, log } = createHarness({ pages: [{ sandboxes: [] }] }); + await makeRuntime(sdk).findAllByLabels({}, { pageSize: 7 }); + assert.deepEqual(argsFor(log, "list.limit"), [[7]]); + }); + + it("falls back to the runtime's configured page size when given neither", async () => { + const { sdk, log } = createHarness({ pages: [{ sandboxes: [] }] }); + await makeRuntime(sdk, { listPageSize: 25 }).findAllByLabels({}); + assert.deepEqual(argsFor(log, "list.limit"), [[25]]); + }); + + it("resolves the request size the same way on a count", async () => { + const { sdk, log } = createHarness({ pages: [{ sandboxes: [] }] }); + await makeRuntime(sdk).countByLabels({}, { limit: 4 }); + assert.deepEqual(argsFor(log, "list.limit"), [[4]]); + }); +}); + +describe("lookup ownership parity with the Daytona adapter", () => { + // Daytona registers every found sandbox with `owned: options.owned ?? false` + // (runtime.ts registerSandbox call sites). Dropping that here means an + // explicit claim is silently ignored and the claimed sandbox can never be + // torn down — a leaked microVM whose name is also held forever. + + it("honours an ownership claim made through findByLabels", async () => { + const { sdk, log } = createHarness({ + pages: [{ sandboxes: [{ name: "warm" }] }], + get: (name) => ({ name }), + }); + const runtime = makeRuntime(sdk); + const found = await runtime.findByLabels({ team: "a" }, { owned: true }); + assert.equal(found?.id, "warm"); + + await runtime.destroy(found as RuntimeHandle); + assert.deepEqual( + names(log).filter((fn) => fn === "handle.kill" || fn === "handle.remove"), + ["handle.kill", "handle.remove"], + "a claimed sandbox must be tearable down", + ); + }); + + it("honours an ownership claim made through findAllByLabels", async () => { + const { sdk, log } = createHarness({ + pages: [{ sandboxes: [{ name: "warm" }] }], + get: (name) => ({ name }), + }); + const runtime = makeRuntime(sdk); + const [found] = await runtime.findAllByLabels({ team: "a" }, { owned: true }); + await runtime.destroy(found as RuntimeHandle); + assert.deepEqual( + names(log).filter((fn) => fn === "handle.kill" || fn === "handle.remove"), + ["handle.kill", "handle.remove"], + ); + }); + + it("never claims a found sandbox by default, so a borrowed lease survives destroy", async () => { + const { sdk, log } = createHarness({ + pages: [{ sandboxes: [{ name: "borrowed" }] }], + get: (name) => ({ name }), + }); + const runtime = makeRuntime(sdk); + const found = await runtime.findByLabels({ team: "a" }); + await runtime.destroy(found as RuntimeHandle); + assert.equal( + called(log, "handle.kill") || called(log, "handle.remove"), + false, + "finding a sandbox by label is an attach, not a claim", + ); + }); + + it("keeps a launched sandbox owned even when a later lookup does not claim it", async () => { + const { sdk, log } = createHarness({ + pages: [{ sandboxes: [{ name: "mine" }] }], + get: (name) => ({ name }), + }); + const runtime = makeRuntime(sdk); + const launched = await runtime.launch({ name: "mine" }); + await runtime.findByLabels({ team: "a" }); + await runtime.destroy(launched); + assert.deepEqual( + names(log).filter((fn) => fn === "handle.kill" || fn === "handle.remove"), + ["handle.kill", "handle.remove"], + "an unclaimed lookup must not demote a sandbox this runtime launched", + ); + }); +}); + +describe("getById", () => { + it("resolves a sandbox by name", async () => { + const { sdk } = createHarness({ get: (name) => ({ name, status: "running" }) }); + const handle = await makeRuntime(sdk).getById("s1"); + assert.equal(handle?.id, "s1"); + assert.equal(handle?.state, "STARTED"); + }); + + it("returns a stopped sandbox when the caller did not filter states", async () => { + const { sdk } = createHarness({ get: (name) => ({ name, status: "stopped" }) }); + const handle = await makeRuntime(sdk).getById("s1"); + assert.equal(handle?.state, "STOPPED"); + }); + + it("filters out a sandbox that fails the requested state filter", async () => { + const { sdk } = createHarness({ get: (name) => ({ name, status: "stopped" }) }); + assert.equal(await makeRuntime(sdk).getById("s1", { states: ["STARTED"] }), null); + }); + + it("returns null when the SDK resolves nothing", async () => { + const { sdk } = createHarness({ get: () => null }); + assert.equal(await makeRuntime(sdk).getById("s1"), null); + }); + + it("returns null on the SDK's typed sandboxNotFound error", async () => { + const { sdk } = createHarness({ + get: () => { + throw sdkError("sandboxNotFound", "sandbox \"s1\" not found"); + }, + }); + assert.equal(await makeRuntime(sdk).getById("s1"), null); + }); + + it("propagates an unrelated SDK error instead of reporting it as absent", async () => { + const { sdk } = createHarness({ + get: () => { + throw sdkError("runtime", "hypervisor unavailable"); + }, + }); + await assert.rejects(makeRuntime(sdk).getById("s1"), /hypervisor unavailable/); + }); + + it("trusts the typed code over a misleading message", async () => { + const { sdk } = createHarness({ + get: () => { + // A transport failure whose text happens to contain "not found" must + // NOT be laundered into "this sandbox does not exist" — that is how a + // live sandbox gets orphaned and a duplicate launched in its place. + throw sdkError("http", "upstream route not found"); + }, + }); + await assert.rejects(makeRuntime(sdk).getById("s1"), /upstream route not found/); + }); + + it("still recognises a not-found error carrying no typed code", async () => { const { sdk } = createHarness({ get: () => { throw Object.assign(new Error("sandbox not found"), { @@ -954,133 +1800,298 @@ describe("exec (bootstrap plane)", () => { }); describe("startScript (durable async exec)", () => { - it("backgrounds the run and captures output and exit code to guest files", async () => { - const { sdk, log } = createHarness({ - get: (name) => ({ name }), - exec: () => ({ stdout: "4242\n" }), + it("admits a run through the guest wrapper and reports its pid", async () => { + const { sdk, log, guest } = createHarness({ get: (name) => ({ name }) }); + const started = await makeRuntime(sdk).startScript({ id: "s1" }, { + command: "long-running --job 7", + sessionId: "sess-1", }); - const started = await makeRuntime(sdk).startScript( - { id: "s1" }, - { command: "long-job", sessionId: "sess-1" }, - ); - assert.deepEqual(started, { sessionId: "sess-1", commandId: "4242" }); + assert.equal(started.sessionId, "sess-1"); + assert.equal(started.commandId, "1000"); + assert.equal(started.reconciled, undefined, "a first admission is not a reconciliation"); const [argv] = firstArgs(log, "exec.args") as [string[]]; - const script = argv[1] ?? ""; - // The inner script is single-quoted for `sh -c`, so its own quoting shows - // up in the POSIX `'\''` escaped form. - assert.ok(script.startsWith("mkdir -p '/tmp/microsandbox-run/sess-1';")); - assert.ok(script.includes("nohup '/bin/sh' -c ")); - assert.ok(script.includes(`> '\\''/tmp/microsandbox-run/sess-1/out'\\'' 2>&1`)); - assert.ok(script.includes(`echo $? > '\\''/tmp/microsandbox-run/sess-1/exit'\\''`)); - assert.ok(script.endsWith("> /dev/null 2>&1 & echo $!")); + assert.deepEqual(argv, [ + "-c", + MICROSANDBOX_RUN_ADMIT_SCRIPT, + "msb-admit", + // The command travels as an ARGUMENT, never interpolated into script + // text, so nothing on the host can rewrite or mis-quote it. + "long-running --job 7", + "/tmp/microsandbox-run/sess-1", + "/tmp/microsandbox-run", + "/bin/sh", + ]); + assert.equal(guest.admissions.length, 1); }); it("honours a custom run state directory", async () => { - const { sdk, log } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "1" }) }); - await makeRuntime(sdk, { runStateDir: "/var/run/msb/" }).startScript( - { id: "s1" }, - { command: "job", sessionId: "sess-1" }, - ); + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk, { runStateDir: "/var/run/msb/" }).startScript({ id: "s1" }, { + command: "true", + sessionId: "sess-1", + }); const [argv] = firstArgs(log, "exec.args") as [string[]]; - assert.match(argv[1] ?? "", /^mkdir -p '\/var\/run\/msb\/sess-1';/); + assert.equal(argv[4], "/var/run/msb/sess-1"); + assert.equal(argv[5], "/var/run/msb"); }); - it("sanitizes a session id before using it as a path segment", async () => { - const { sdk, log } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "1" }) }); - await makeRuntime(sdk).startScript( - { id: "s1" }, - { command: "job", sessionId: "../../etc/passwd" }, - ); + it("passes the configured shell to the guest wrapper", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk, { shell: "/bin/bash" }).startScript({ id: "s1" }, { + command: "true", + sessionId: "sess-1", + }); const [argv] = firstArgs(log, "exec.args") as [string[]]; - assert.match(argv[1] ?? "", /'\/tmp\/microsandbox-run\/______etc_passwd'/); - assert.doesNotMatch(argv[1] ?? "", /\.\./); + assert.equal(argv[6], "/bin/bash"); + assert.deepEqual(firstArgs(log, "sandbox.execWith"), ["/bin/bash"]); + }); + + it("encodes a session id reversibly instead of sanitizing it", async () => { + // `a/b` and `a_b` are different sessions. A replace-with-underscore + // sanitizer maps them onto one directory, which is enough to report one + // run's exit code as the other's. + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.startScript({ id: "s1" }, { command: "true", sessionId: "a/b" }); + await runtime.startScript({ id: "s1" }, { command: "true", sessionId: "a_b" }); + const dirs = argsFor(log, "exec.args").map((args) => (args[0] as string[])[4]); + assert.deepEqual(dirs, ["/tmp/microsandbox-run/a%2Fb", "/tmp/microsandbox-run/a_b"]); + assert.notEqual(dirs[0], dirs[1], "two different session ids must never share a directory"); + }); + + it("escapes the percent sign it uses as the escape character", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.startScript({ id: "s1" }, { command: "true", sessionId: "a%2Fb" }); + await runtime.startScript({ id: "s1" }, { command: "true", sessionId: "a/b" }); + const dirs = argsFor(log, "exec.args").map((args) => (args[0] as string[])[4]); + assert.notEqual(dirs[0], dirs[1], "the encoding must stay reversible"); + }); + + it("collapses an over-long session id onto a digest that stays unique", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.startScript({ id: "s1" }, { command: "true", sessionId: `${"x".repeat(200)}a` }); + await runtime.startScript({ id: "s1" }, { command: "true", sessionId: `${"x".repeat(200)}b` }); + const dirs = argsFor(log, "exec.args").map((args) => (args[0] as string[])[4]); + for (const dir of dirs) { + const segment = dir.slice("/tmp/microsandbox-run/".length); + assert.ok(segment.length <= 65, `segment stayed ${segment.length} bytes long`); + assert.match(segment, /^\.[0-9a-f]{64}$/); + } + assert.notEqual(dirs[0], dirs[1]); }); it("generates a session id when the caller supplies none", async () => { - const { sdk } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "9" }) }); - const started = await makeRuntime(sdk).startScript({ id: "s1" }, { command: "job" }); - assert.match(started.sessionId, /^run-s1-[0-9a-f-]{36}$/); + const { sdk } = createHarness({ get: (name) => ({ name }) }); + const started = await makeRuntime(sdk).startScript({ id: "s1" }, { command: "true" }); + assert.match(started.sessionId, /^run-s1-/); }); it("bounds only the submit call with the caller's timeout", async () => { - const { sdk, log } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "1" }) }); - await makeRuntime(sdk).startScript( - { id: "s1" }, - { command: "long-job", sessionId: "sess-1", timeoutMs: 2_000 }, - ); - assert.deepEqual(firstArgs(log, "exec.timeout"), [2_000]); - // The backgrounded command must NOT inherit that deadline: `nohup ... &` - // detaches it, and nothing in the wrapper caps its lifetime. + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).startScript({ id: "s1" }, { + command: "sleep 600", + sessionId: "sess-1", + timeoutMs: 5_000, + }); + // The submit is bounded; the backgrounded run is not, because its lifetime + // is the sandbox's rather than the submitting request's. + assert.deepEqual(firstArgs(log, "exec.timeout"), [5_000]); const [argv] = firstArgs(log, "exec.args") as [string[]]; - const script = argv[1] ?? ""; - assert.doesNotMatch(script, /timeout 2/); - assert.ok(script.includes("nohup ")); - assert.ok(script.endsWith("& echo $!")); + assert.equal(argv[3], "sleep 600"); }); - it("escapes a command containing single quotes", async () => { - const { sdk, log } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "1" }) }); - await makeRuntime(sdk).startScript( - { id: "s1" }, - { command: "echo 'hi'", sessionId: "sess-1" }, + it("adopts the existing run when the same session is submitted twice", async () => { + // The outcome-unknown case: the first submit was admitted but its response + // was lost, so the caller retries. Adopting is the only answer that does + // not start a second process or overwrite the first one's state. + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const first = await runtime.startScript({ id: "s1" }, { + command: "job --once", + sessionId: "sess-1", + }); + const second = await runtime.startScript({ id: "s1" }, { + command: "job --once", + sessionId: "sess-1", + }); + + assert.equal(second.reconciled, true); + assert.equal(second.commandId, first.commandId); + assert.equal(guest.admissions.length, 1, "a retry must not start a second process"); + }); + + it("refuses a session id already admitted for a different command", async () => { + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.startScript({ id: "s1" }, { command: "first", sessionId: "sess-1" }); + await assert.rejects( + runtime.startScript({ id: "s1" }, { command: "second", sessionId: "sess-1" }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxSessionConflictError); + assert.equal(error.sessionId, "sess-1"); + return true; + }, + ); + assert.equal(guest.admissions.length, 1, "a conflicting submit must start nothing"); + assert.equal( + guest.runs.get("/tmp/microsandbox-run/sess-1")?.command, + "first", + "the first run's state must not be overwritten", ); - const [argv] = firstArgs(log, "exec.args") as [string[]]; - assert.match(argv[1] ?? "", /'\\''hi'\\''/); }); -}); -describe("getScriptStatus", () => { - const statusRuntime = (exitFileContents: string) => - createHarness({ + it("never reports a stale run's exit code as the new run's", async () => { + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const first = await runtime.startScript({ id: "s1" }, { + command: "job --once", + sessionId: "sess-1", + }); + guest.finish("/tmp/microsandbox-run/sess-1", 5); + + // Same session id, different command: the finished run's exit code belongs + // to the finished run, and the refusal is what keeps it that way. + await assert.rejects( + runtime.startScript({ id: "s1" }, { command: "job --again", sessionId: "sess-1" }), + MicrosandboxSessionConflictError, + ); + assert.deepEqual( + await runtime.getScriptStatus({ id: "s1" }, "sess-1", first.commandId), + { exitCode: 5 }, + ); + }); + + it("surfaces an admission that produced no verdict", async () => { + const { sdk } = createHarness({ get: (name) => ({ name }), - exec: (argv) => { - const script = argv[1] ?? ""; - return script.includes("/exit") ? { stdout: exitFileContents } : { stdout: "" }; - }, + exec: () => ({ code: 127, stderr: "sh: not found" }), }); + await assert.rejects( + makeRuntime(sdk).startScript({ id: "s1" }, { command: "true", sessionId: "sess-1" }), + /returned no verdict \(exit 127\): sh: not found/, + ); + }); +}); - it("reports still-running while the exit file is absent", async () => { - const { sdk } = statusRuntime(""); +describe("getScriptStatus", () => { + it("reports still-running while the run is alive", async () => { + const { sdk } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const started = await runtime.startScript({ id: "s1" }, { + command: "sleep 600", + sessionId: "sess-1", + }); assert.deepEqual( - await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + await runtime.getScriptStatus({ id: "s1" }, "sess-1", started.commandId), { exitCode: null }, ); }); it("reports a zero exit", async () => { - const { sdk } = statusRuntime("0\n"); + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const started = await runtime.startScript({ id: "s1" }, { command: "true", sessionId: "sess-1" }); + guest.finish("/tmp/microsandbox-run/sess-1", 0); assert.deepEqual( - await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + await runtime.getScriptStatus({ id: "s1" }, "sess-1", started.commandId), { exitCode: 0 }, ); }); it("reports a non-zero exit", async () => { - const { sdk } = statusRuntime("137\n"); + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const started = await runtime.startScript({ id: "s1" }, { command: "false", sessionId: "sess-1" }); + guest.finish("/tmp/microsandbox-run/sess-1", 137); assert.deepEqual( - await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + await runtime.getScriptStatus({ id: "s1" }, "sess-1", started.commandId), { exitCode: 137 }, ); }); - it("never invents an exit code from unparseable contents", async () => { - const { sdk } = statusRuntime("garbage\n"); - assert.deepEqual( - await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), - { exitCode: null }, + it("ends the poll when the run's process is gone without an exit code", async () => { + // Without this, a run killed by the OOM reaper polls at exitCode:null + // forever: the exit file it would have written never arrives. + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const started = await runtime.startScript({ id: "s1" }, { + command: "sleep 600", + sessionId: "sess-1", + }); + guest.kill("/tmp/microsandbox-run/sess-1"); + await assert.rejects( + runtime.getScriptStatus({ id: "s1" }, "sess-1", started.commandId), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxRunLostError); + assert.equal(error.sessionId, "sess-1"); + assert.equal(error.commandId, started.commandId); + assert.match(error.message, /never recorded an exit code/); + return true; + }, + ); + }); + + it("ends the poll when the run state is gone entirely", async () => { + const { sdk } = createHarness({ get: (name) => ({ name }) }); + await assert.rejects( + makeRuntime(sdk).getScriptStatus({ id: "s1" }, "never-started", "1"), + MicrosandboxRunLostError, + ); + }); + + it("ends the poll when the sandbox restarted under the run", async () => { + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const started = await runtime.startScript({ id: "s1" }, { + command: "sleep 600", + sessionId: "sess-1", + }); + guest.restartSandbox(); + await assert.rejects( + runtime.getScriptStatus({ id: "s1" }, "sess-1", started.commandId), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxRunLostError); + assert.match(error.message, /sandbox restarted/); + return true; + }, + ); + }); + + it("never invents an exit code from an unreadable exit record", async () => { + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const started = await runtime.startScript({ id: "s1" }, { command: "true", sessionId: "sess-1" }); + guest.finish("/tmp/microsandbox-run/sess-1", "garbage"); + await assert.rejects( + runtime.getScriptStatus({ id: "s1" }, "sess-1", started.commandId), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxRunLostError); + assert.match(error.message, /unreadable/); + return true; + }, ); }); - it("reads through a bounded tail rather than an unbounded file read", async () => { - const { sdk, log } = statusRuntime("0"); - await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"); + it("asks the guest through one bounded probe rather than reading files", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const started = await runtime.startScript({ id: "s1" }, { command: "true", sessionId: "sess-1" }); + log.length = 0; + await runtime.getScriptStatus({ id: "s1" }, "sess-1", started.commandId); const [argv] = firstArgs(log, "exec.args") as [string[]]; - assert.match(argv[1] ?? "", /^tail -c 64 /); + assert.deepEqual(argv, [ + "-c", + MICROSANDBOX_RUN_STATUS_SCRIPT, + "msb-status", + "/tmp/microsandbox-run/sess-1", + ]); assert.equal(called(log, "fs.readToString"), false); }); - it("degrades to still-running when the read itself fails", async () => { + it("degrades to still-running when the probe itself fails", async () => { const { sdk } = createHarness({ get: (name) => ({ name }), exec: () => { @@ -1092,6 +2103,17 @@ describe("getScriptStatus", () => { { exitCode: null }, ); }); + + it("degrades to still-running when the probe returns nothing recognizable", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ stdout: "" }), + }); + assert.deepEqual( + await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + { exitCode: null }, + ); + }); }); describe("getScriptLogs", () => { @@ -1109,6 +2131,62 @@ describe("getScriptLogs", () => { }); }); +describe("getExecLogs (bootstrap plane)", () => { + const startedRun = async (sdk: MicrosandboxSdk) => { + const runtime = makeRuntime(sdk); + const started = await runtime.startScript({ id: "s1" }, { + command: "job", + sessionId: "sess-1", + }); + return { runtime, started }; + }; + + it("reports the exit code the run actually recorded", async () => { + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const { runtime, started } = await startedRun(sdk); + guest.write("/tmp/microsandbox-run/sess-1", "boom\n"); + guest.finish("/tmp/microsandbox-run/sess-1", 7); + + assert.deepEqual( + await runtime.getExecLogs({ id: "s1" }, "sess-1", started.commandId), + { output: "boom\n", exitCode: 7 }, + ); + }); + + it("refuses to report a still-running run as a success", async () => { + // `ExecResult.exitCode` is a number, so defaulting the log read's `null` + // to 0 reports every unfinished run as a clean success. + const { sdk } = createHarness({ get: (name) => ({ name }) }); + const { runtime, started } = await startedRun(sdk); + await assert.rejects( + runtime.getExecLogs({ id: "s1" }, "sess-1", started.commandId), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxRunNotFinishedError); + assert.equal(error.sessionId, "sess-1"); + return true; + }, + ); + }); + + it("refuses to report a lost run as a success", async () => { + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const { runtime, started } = await startedRun(sdk); + guest.kill("/tmp/microsandbox-run/sess-1"); + await assert.rejects( + runtime.getExecLogs({ id: "s1" }, "sess-1", started.commandId), + MicrosandboxRunLostError, + ); + }); + + it("reports a zero exit as a zero exit", async () => { + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const { runtime, started } = await startedRun(sdk); + guest.finish("/tmp/microsandbox-run/sess-1", 0); + const result = await runtime.getExecLogs({ id: "s1" }, "sess-1", started.commandId); + assert.equal(result.exitCode, 0); + }); +}); + describe("file transfer", () => { it("treats a string source as a HOST PATH", async () => { const { sdk, log } = createHarness({ get: (name) => ({ name }) }); @@ -1204,42 +2282,121 @@ describe("getHomeDir", () => { }); describe("lifecycle", () => { - it("start resumes the sandbox and reports it STARTED", async () => { + it("start resumes a sandbox this runtime owns and reports it STARTED", async () => { const { sdk, log } = createHarness({ get: (name) => ({ name, status: "stopped" }) }); - const handle = await makeRuntime(sdk).start({ id: "s1", state: "STOPPED" }); + const runtime = makeRuntime(sdk); + await runtime.getById("s1", { owned: true, states: null }); + const handle = await runtime.start({ id: "s1", state: "STOPPED" }); assert.equal(handle.state, "STARTED"); assert.equal(called(log, "handle.start"), true); }); - it("start on a vanished sandbox is an error, not a silent no-op", async () => { + it("start on a vanished owned sandbox is an error, not a silent no-op", async () => { const { sdk } = createHarness({ get: () => null }); - await assert.rejects(makeRuntime(sdk).start({ id: "ghost" }), /no longer available/); + const runtime = makeRuntime(sdk); + await runtime.launch({ name: "ghost" }); + await assert.rejects(runtime.start({ id: "ghost" }), /no longer available/); }); - it("stop halts the sandbox", async () => { + it("never boots a sandbox this runtime does not own", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name, status: "stopped" }) }); + const runtime = makeRuntime(sdk); + await runtime.getById("borrowed", { states: null }); + await runtime.start({ id: "borrowed" }); + assert.equal( + called(log, "handle.start"), + false, + "a sandbox someone else chose to stop must stay stopped", + ); + }); + + it("stop halts a sandbox this runtime owns", async () => { const { sdk, log } = createHarness({ get: (name) => ({ name }) }); - await makeRuntime(sdk).stop({ id: "s1" }); + const runtime = makeRuntime(sdk); + await runtime.launch({ name: "s1" }); + await runtime.stop({ id: "s1" }); assert.deepEqual(firstArgs(log, "handle.stop"), ["s1"]); }); - it("stop on a vanished sandbox is idempotent", async () => { + it("never halts a sandbox this runtime does not own", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.getById("borrowed"); + await runtime.stop({ id: "borrowed" }); + assert.equal(called(log, "handle.stop"), false, "a warm lease is borrowed, not owned"); + }); + + it("stop on a vanished owned sandbox is idempotent", async () => { const { sdk, log } = createHarness({ get: () => null }); - await makeRuntime(sdk).stop({ id: "ghost" }); + const runtime = makeRuntime(sdk); + await runtime.launch({ name: "ghost" }); + await runtime.stop({ id: "ghost" }); assert.equal(called(log, "handle.stop"), false); }); }); describe("destroy", () => { - it("kills and then removes, so the name is reusable", async () => { + const ownedRuntime = async (sdk: MicrosandboxSdk, name = "s1") => { + const runtime = makeRuntime(sdk); + await runtime.launch({ name }); + return runtime; + }; + + it("kills and then removes a sandbox it launched, so the name is reusable", async () => { const { sdk, log } = createHarness({ get: (name) => ({ name }) }); - await makeRuntime(sdk).destroy({ id: "s1" }); + const runtime = await ownedRuntime(sdk); + await runtime.destroy({ id: "s1" }); const order = names(log).filter((n) => n === "handle.kill" || n === "handle.remove"); assert.deepEqual(order, ["handle.kill", "handle.remove"]); }); - it("is a no-op for a sandbox that is already gone", async () => { + it("deletes nothing for a sandbox this runtime never claimed", async () => { + // The failure this prevents: a lease-reattach path resolves a sandbox by + // name and tears it down, deleting a microVM another worker is using. + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const handle = await runtime.getById("borrowed"); + assert.ok(handle); + await runtime.destroy(handle); + assert.equal(called(log, "handle.kill"), false); + assert.equal(called(log, "handle.remove"), false); + }); + + it("deletes nothing for a handle this runtime has never seen", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).destroy({ id: "someone-elses-sandbox" }); + assert.equal(called(log, "Sandbox.get"), false, "an unknown handle is not this runtime's to delete"); + assert.equal(called(log, "handle.kill"), false); + assert.equal(called(log, "handle.remove"), false); + }); + + it("deletes a sandbox the caller explicitly claimed on attach", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const handle = await runtime.getById("adopted", { owned: true }); + assert.ok(handle); + await runtime.destroy(handle); + assert.equal(called(log, "handle.kill"), true); + assert.equal(called(log, "handle.remove"), true); + }); + + it("keeps ownership of a launched sandbox across a later unclaimed attach", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.launch({ name: "s1" }); + await runtime.getById("s1"); + await runtime.destroy({ id: "s1" }); + assert.equal( + called(log, "handle.kill"), + true, + "an attach must not demote a sandbox this runtime launched", + ); + }); + + it("is a no-op for an owned sandbox that is already gone", async () => { const { sdk, log } = createHarness({ get: () => null }); - await makeRuntime(sdk).destroy({ id: "ghost" }); + const runtime = await ownedRuntime(sdk, "ghost"); + await runtime.destroy({ id: "ghost" }); assert.equal(called(log, "handle.kill"), false); assert.equal(called(log, "handle.remove"), false); }); @@ -1253,7 +2410,8 @@ describe("destroy", () => { }, }), }); - await makeRuntime(sdk).destroy({ id: "s1" }); + const runtime = await ownedRuntime(sdk); + await runtime.destroy({ id: "s1" }); assert.equal( called(log, "handle.remove"), true, @@ -1270,7 +2428,8 @@ describe("destroy", () => { }, }), }); - await assert.rejects(makeRuntime(sdk).destroy({ id: "s1" }), /hypervisor wedged/); + const runtime = await ownedRuntime(sdk); + await assert.rejects(runtime.destroy({ id: "s1" }), /hypervisor wedged/); assert.equal( called(log, "handle.remove"), false, @@ -1278,6 +2437,25 @@ describe("destroy", () => { ); }); + it("keeps ownership after a failed teardown so the caller can retry it", async () => { + let failNext = true; + const { sdk, log } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + if (failNext) { + failNext = false; + throw sdkError("runtime", "hypervisor wedged"); + } + }, + }), + }); + const runtime = await ownedRuntime(sdk); + await assert.rejects(runtime.destroy({ id: "s1" }), /hypervisor wedged/); + await runtime.destroy({ id: "s1" }); + assert.equal(called(log, "handle.remove"), true, "the retry must still be allowed to delete"); + }); + it("tolerates a remove that races another destroy", async () => { const { sdk } = createHarness({ get: (name) => ({ @@ -1287,7 +2465,8 @@ describe("destroy", () => { }, }), }); - await makeRuntime(sdk).destroy({ id: "s1" }); + const runtime = await ownedRuntime(sdk); + await runtime.destroy({ id: "s1" }); }); it("drops the cached instance so a later call re-resolves by name", async () => { @@ -1388,6 +2567,205 @@ describe("capabilities", () => { // load, so the suite stays green on an unsupported platform. // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Guest run protocol, executed against a real POSIX shell. +// +// The adapter models the guest in every test above; these run the two shell +// scripts themselves, under `/bin/sh`, on real files. No provider, no +// credential, no sandbox — just the guarantees the async-run path is built on: +// one admission per session, adoption of an identical resubmit, refusal of a +// conflicting one, an exit code recorded even when the command exits the +// shell, and a lost run that reads as lost instead of as still running. +// --------------------------------------------------------------------------- + +const SHELL_SKIP = process.platform === "win32" + ? "POSIX shell protocol tests need /bin/sh" + : false; + +function sh( + script: string, + args: string[], +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + execFile( + "/bin/sh", + ["-c", script, "msb-test", ...args], + { encoding: "utf8" }, + (error, stdout, stderr) => { + const code = error && typeof (error as { code?: unknown }).code === "number" + ? (error as unknown as { code: number }).code + : 0; + resolve({ stdout, stderr, code }); + }, + ); + }); +} + +async function waitForFile(path: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + return await readFile(path, "utf8"); + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + assert.fail(`timed out waiting for ${path}`); +} + +describe("guest run protocol (real /bin/sh)", { skip: SHELL_SKIP }, () => { + let root = ""; + + before(async () => { + root = await mkdtemp(join(tmpdir(), "msb-protocol-")); + }); + + after(async () => { + if (root) { + await rm(root, { recursive: true, force: true }); + } + }); + + const admit = (command: string, session: string) => { + const dir = join(root, session); + return sh(MICROSANDBOX_RUN_ADMIT_SCRIPT, [command, dir, root, "/bin/sh"]); + }; + const probe = (session: string) => + sh(MICROSANDBOX_RUN_STATUS_SCRIPT, [join(root, session)]); + + it("admits a run, records it, and captures combined output", async () => { + const admitted = await admit("printf out; printf err >&2", "captured"); + assert.match(admitted.stdout.trim(), /^ADMITTED \d+$/); + + const dir = join(root, "captured"); + assert.equal(await waitForFile(join(dir, "exit")), "0"); + assert.equal(await readFile(join(dir, "out"), "utf8"), "outerr"); + assert.equal(await readFile(join(dir, "cmd"), "utf8"), "printf out; printf err >&2"); + assert.equal( + (await probe("captured")).stdout.trim(), + "EXIT 0", + "a finished run reports its recorded code", + ); + }); + + it("records an exit code even when the command exits the shell", async () => { + // `exit 7` inside the command would end the wrapper too if the command ran + // in the wrapper's own shell — and the exit file would never be written, + // leaving the caller polling forever. + await admit("printf hi; exit 7", "exits"); + const dir = join(root, "exits"); + assert.equal(await waitForFile(join(dir, "exit")), "7"); + assert.equal(await readFile(join(dir, "out"), "utf8"), "hi"); + assert.equal((await probe("exits")).stdout.trim(), "EXIT 7"); + }); + + it("adopts an identical resubmit instead of starting a second process", async () => { + const counter = join(root, "counter"); + const command = `printf x >> ${JSON.stringify(counter)}`; + const first = await admit(command, "adopt"); + await waitForFile(join(root, "adopt", "exit")); + + const second = await admit(command, "adopt"); + assert.equal(second.stdout.trim(), first.stdout.trim().replace("ADMITTED", "CLAIMED")); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(await readFile(counter, "utf8"), "x", "the command ran exactly once"); + }); + + it("refuses a conflicting resubmit and overwrites nothing", async () => { + const marker = join(root, "conflict-marker"); + await admit(`printf first > ${JSON.stringify(marker)}`, "conflict"); + await waitForFile(join(root, "conflict", "exit")); + + const conflicting = await admit(`printf second > ${JSON.stringify(marker)}`, "conflict"); + assert.equal(conflicting.stdout.trim(), "CONFLICT"); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(await readFile(marker, "utf8"), "first", "the second command never ran"); + assert.equal( + await readFile(join(root, "conflict", "cmd"), "utf8"), + `printf first > ${JSON.stringify(marker)}`, + "the admitted command record is immutable", + ); + }); + + it("reports a live run as running and a killed one as lost", async () => { + const admitted = await admit("sleep 30", "lost"); + const pid = Number.parseInt(admitted.stdout.trim().split(" ")[1] ?? "", 10); + assert.ok(Number.isFinite(pid)); + assert.equal((await probe("lost")).stdout.trim(), "RUNNING"); + + process.kill(pid, "SIGKILL"); + const gone = () => { + try { + process.kill(pid, 0); + return false; + } catch { + return true; + } + }; + await waitFor(gone, "the run process to die"); + assert.equal( + (await probe("lost")).stdout.trim(), + "LOST process-gone", + "a run whose process is gone can never record an exit code", + ); + }); + + it("adopts a resubmit of a command that ends in a newline", async () => { + // `existing=$(cat "$dir/cmd")` strips EVERY trailing newline, so comparing + // the recorded command that way makes a byte-identical resubmit look like + // a different command. The caller then gets a conflict for the one run it + // is actually entitled to adopt — the outcome-unknown retry the whole + // admission protocol exists to serve. + const command = "printf hi\n"; + const first = await admit(command, "trailing-newline"); + assert.match(first.stdout.trim(), /^ADMITTED \d+$/); + await waitForFile(join(root, "trailing-newline", "exit")); + + const second = await admit(command, "trailing-newline"); + assert.equal( + second.stdout.trim(), + first.stdout.trim().replace("ADMITTED", "CLAIMED"), + "a byte-identical resubmit is adopted, whatever bytes the command ends in", + ); + }); + + it("still refuses a resubmit that differs only by a trailing newline", async () => { + // The must-not-fire half: the fix has to compare the exact bytes, not + // normalize both sides into agreeing. + await admit("printf a", "newline-differs"); + await waitForFile(join(root, "newline-differs", "exit")); + + const conflicting = await admit("printf a\n", "newline-differs"); + assert.equal(conflicting.stdout.trim(), "CONFLICT"); + assert.equal( + await readFile(join(root, "newline-differs", "cmd"), "utf8"), + "printf a", + "the admitted command record is immutable", + ); + }); + + it("records a command that ends in a newline byte-exactly", async () => { + const command = "printf done\n"; + await admit(command, "newline-record"); + await waitForFile(join(root, "newline-record", "exit")); + assert.equal(await readFile(join(root, "newline-record", "cmd"), "utf8"), command); + }); + + it("reports a session that was never admitted as missing", async () => { + assert.equal((await probe("never-admitted")).stdout.trim(), "MISSING"); + }); + + it("keeps a command with quotes, newlines and dollars byte-exact", async () => { + const command = "printf '%s' \"it's $HOME\n\""; + await admit(command, "quoting"); + await waitForFile(join(root, "quoting", "exit")); + assert.equal( + await readFile(join(root, "quoting", "cmd"), "utf8"), + command, + "the command reaches the guest as its own bytes, never as interpolated script text", + ); + }); +}); + type RealSdkModule = Record & { Sandbox: Record; SandboxStatuses: readonly string[]; @@ -1465,6 +2843,16 @@ describe("real-SDK contract", () => { ); }); + it("still declares the Node floor this adapter documents", async () => { + // The adapter tells callers the SDK needs Node >= 22 while this package + // itself supports Node >= 20. If the SDK moves that floor, the message the + // lazy import prints becomes wrong. + const manifest = JSON.parse( + await readFile(new URL("../../node_modules/microsandbox/package.json", import.meta.url), "utf8"), + ) as { engines?: { node?: string } }; + assert.equal(manifest.engines?.node, ">= 22"); + }); + it("scopes and restores the default backend", { skip: REAL_SDK_SKIP }, async () => { // No credential involved: this drives the real scoping primitive the // adapter depends on, using the local backend as an inert value. diff --git a/src/microsandbox/runtime.ts b/src/microsandbox/runtime.ts index 138be98..09936b4 100644 --- a/src/microsandbox/runtime.ts +++ b/src/microsandbox/runtime.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import type { AsyncExecStartResult, @@ -30,42 +30,79 @@ import type { // typecheck and unit tests do not hard-couple to the SDK type graph, and a fake // SDK can drive every path without a live backend or an API key. // -// Three provider facts shape this adapter and are worth stating up front, +// Five provider facts shape this adapter and are worth stating up front, // because each one breaks an assumption the other adapters in this package are // allowed to make: // // 1. IDENTITY IS A NAME, NOT A SERVER-ASSIGNED ID. `Sandbox.builder(name)`, -// `Sandbox.get(name)` and `Sandbox.remove(name)` all address a sandbox by a -// caller-chosen name capped at 128 UTF-8 bytes. `RuntimeHandle.id` +// `Sandbox.get(name)` and `SandboxHandle.remove()` all address a sandbox by +// a caller-chosen name capped at 128 UTF-8 bytes. `RuntimeHandle.id` // therefore carries that name, and an over-long name is rejected rather // than truncated — truncating would silently alias two distinct sandboxes // onto one identity. // // 2. BACKEND SELECTION IS PROCESS-WIDE GLOBAL STATE. The SDK exposes // `setDefaultBackend(backend)` (permanent) and `withDefaultBackend(backend, -// fn)` (scoped, restored in a `finally`). This adapter only ever uses the -// scoped form, so constructing a runtime never mutates the host process and -// two runtimes pointed at different backends can coexist. The SDK documents -// that the scope is not task-local: concurrent work in the same process can -// observe the temporary backend while the callback runs. That caveat is the -// SDK's, not something this adapter can fix, and it is why the wrapped -// region is kept as small as one SDK call. +// fn)` (scoped, restored in a `finally`). The SDK documents that the scoped +// form is NOT task-local: concurrent work in the same process observes the +// temporary backend while the callback runs, so two overlapping calls on +// different backends would silently send one of them to the wrong place. +// This adapter therefore never calls `setDefaultBackend`, and routes the +// calls that DO read that slot — the SDK's default-dependent statics — +// through one PROCESS-GLOBAL gate (see `withBackendScope`): calls that want +// the same backend share a single open scope and still run concurrently; a +// call that wants a different backend waits until the current scope has +// closed. A resolved `Sandbox` or `SandboxHandle` is bound to the backend it +// was resolved on, so its exec, filesystem and lifecycle calls read no +// global state and are issued off the gate. That is a real mutual-exclusion +// guarantee for this adapter's own default-dependent calls, and the honest +// limit of it is stated on the gate: SDK calls made elsewhere in the +// process, outside this adapter, are not covered by it and can still +// observe the scoped backend. // // 3. THERE IS NO CREATE-TIMEOUT SETTER ON THE BUILDER. `maxDuration` and // `idleTimeout` are sandbox LIFETIME budgets. Mapping the caller's // `createTimeoutSeconds` onto either would kill every long-lived sandbox // the moment the boot deadline elapsed, so the create deadline is enforced -// client-side instead (see `launch`). +// client-side instead — and a create that lands AFTER that deadline is +// reclaimed rather than leaked (see `launch`). +// +// 4. THE SDK REQUIRES NODE 22+ AND A VIRTUALIZATION-CAPABLE HOST. +// `microsandbox@0.6.x` declares `engines.node >= 22` and ships a +// platform-specific native addon (macOS arm64, Linux x64/arm64, Windows +// x64/arm64). Its published requirements are Linux with KVM, macOS on +// Apple Silicon, or Windows 10+ with WHP — that hardware requirement is +// what the LOCAL backend boots microVMs on; the cloud backend boots them +// remotely but still loads the same native client addon. This package's own +// floor stays Node 20, because every other adapter here runs there and the +// SDK is an OPTIONAL peer dependency — so the constraint is surfaced where +// it actually bites: the lazy import wraps a load failure with it. +// +// 5. AN ASYNC RUN HAS NO SERVER-SIDE COMMAND RECORD. The SDK's streaming +// `ExecHandle` is process-local and carries no id a later process could +// poll, so async runs are tracked by durable files in the guest. Everything +// that makes that safe — one-shot admission, adoption of a run whose +// submit response was lost, refusal to overwrite another run's state, and +// detection of a run whose process died without recording an exit code — +// lives in the two POSIX shell scripts below, not in the caller. // --------------------------------------------------------------------------- /** Max sandbox name length the SDK accepts, in UTF-8 bytes. */ const MAX_SANDBOX_NAME_BYTES = 128; const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; +const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000; const DEFAULT_LIST_PAGE_SIZE = 100; const SCRIPT_LOG_READ_MAX_BYTES = 200_000; +/** + * Longest encoded run-state path segment before it is replaced by a digest. + * Well under the 255-byte filename limit every mainstream guest filesystem + * imposes, with room for the `out`/`exit`/`pid` leaves underneath it. + */ +const MAX_RUN_SEGMENT_BYTES = 120; /** - * Where the async-exec wrapper parks its `out`/`exit` files inside the guest. + * Where the async-run wrapper parks its `cmd`/`pid`/`boot`/`out`/`exit` files + * inside the guest. * * `/tmp` is a POSIX guarantee of the guest filesystem, not a fact about any * particular deployment, so it is a safe default rather than baked-in @@ -77,6 +114,120 @@ const DEFAULT_RUN_STATE_DIR = "/tmp/microsandbox-run"; /** POSIX shell used to interpret a `runScript`/`startScript` command string. */ const DEFAULT_SHELL = "/bin/sh"; +// --- guest-side async run protocol ----------------------------------------- +// +// Both scripts below are pure POSIX `sh` and take every value — including the +// caller's command — as a positional ARGUMENT, never as interpolated text. So +// no quoting of caller data happens anywhere on the host, and the exact bytes +// of the command reach the guest unmodified. +// +// The run directory doubles as the admission record. `mkdir` of a single +// directory is atomic on every POSIX filesystem, so it either claims the +// session or proves someone else already did — which is what makes a resubmit +// of the same session id incapable of starting a second process or of +// overwriting the first one's state. + +/** + * Admit one async run. + * + * Arguments: `$1` command, `$2` run directory, `$3` its parent, `$4` shell. + * + * Prints exactly one of: + * - `ADMITTED ` — this call created the run. + * - `CLAIMED ` — the session was already admitted for THIS EXACT + * command, so its existing run is adopted. This is the outcome-unknown + * case: a submit whose response was lost is retried by the caller and + * resolves here, without ever starting a second process. + * - `CONFLICT` — the session is already admitted for a DIFFERENT + * command (or its record is unreadable). Nothing is started and nothing is + * overwritten; the caller gets a typed error. + * + * @internal Exported only so the protocol tests can execute it under a real + * `/bin/sh`. Not part of the package's public API. + */ +export const MICROSANDBOX_RUN_ADMIT_SCRIPT = [ + "set -u", + "cmd=$1", + "dir=$2", + "parent=$3", + "shell_path=$4", + 'mkdir -p "$parent" 2>/dev/null || true', + 'if mkdir "$dir" 2>/dev/null; then', + // Record the command BEFORE starting anything: a crash between the two + // leaves a claimed-but-dead session, which the status probe reports as lost, + // rather than an unattributable running process. + ' printf %s "$cmd" > "$dir/cmd"', + // Boot identity, used by the status probe to tell "still running" from "the + // sandbox restarted and this pid now belongs to someone else". Absent on a + // guest without procfs, in which case the probe falls back to pid liveness. + ' cat /proc/sys/kernel/random/boot_id > "$dir/boot" 2>/dev/null || true', + // The command runs in a CHILD shell, so an `exit 7` inside it cannot skip + // the exit-code record: the child exits, the wrapper writes its status. + ' nohup "$shell_path" -c \'"$3" -c "$1" > "$2/out" 2>&1; printf %s "$?" > "$2/exit"\' msb-run "$cmd" "$dir" "$shell_path" > /dev/null 2>&1 &', + " run_pid=$!", + ' printf %s "$run_pid" > "$dir/pid"', + ' printf "ADMITTED %s\\n" "$run_pid"', + " exit 0", + "fi", + // `$(cat f)` strips EVERY trailing newline, so the sentinel-and-strip form + // is what makes the comparison byte-exact: a command that ends in a newline + // must still be recognised as the same command on an outcome-unknown retry. + 'existing=$(cat "$dir/cmd" 2>/dev/null; printf X) || existing=X', + 'existing=${existing%X}', + 'run_pid=$(cat "$dir/pid" 2>/dev/null) || run_pid=""', + 'if [ "$existing" = "$cmd" ] && [ -n "$run_pid" ]; then', + ' printf "CLAIMED %s\\n" "$run_pid"', + " exit 0", + "fi", + 'printf "CONFLICT\\n"', +].join("\n"); + +/** + * Report one async run's outcome. + * + * Argument: `$1` run directory. Prints exactly one of `EXIT `, + * `RUNNING`, `MISSING`, or `LOST `. + * + * The exit file is checked first and again last: the wrapper writes it as its + * final act, so re-reading after the liveness probe closes the window where a + * run finishes mid-probe and would otherwise read as lost. + * + * @internal Exported only for the protocol tests (see above). + */ +export const MICROSANDBOX_RUN_STATUS_SCRIPT = [ + "set -u", + "dir=$1", + 'if [ -f "$dir/exit" ]; then', + ' printf "EXIT %s\\n" "$(cat "$dir/exit" 2>/dev/null)"', + " exit 0", + "fi", + 'if [ ! -d "$dir" ]; then', + ' printf "MISSING\\n"', + " exit 0", + "fi", + 'boot=$(cat "$dir/boot" 2>/dev/null) || boot=""', + 'now=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || now=""', + 'if [ -n "$boot" ] && [ -n "$now" ] && [ "$boot" != "$now" ]; then', + ' printf "LOST sandbox-restarted\\n"', + " exit 0", + "fi", + 'run_pid=$(cat "$dir/pid" 2>/dev/null) || run_pid=""', + 'if [ -z "$run_pid" ]; then', + // Admitted, pid not recorded yet: the admission call is still in flight. + ' printf "RUNNING\\n"', + " exit 0", + "fi", + 'if kill -0 "$run_pid" 2>/dev/null; then', + ' printf "RUNNING\\n"', + " exit 0", + "fi", + 'if [ -f "$dir/exit" ]; then', + ' printf "EXIT %s\\n" "$(cat "$dir/exit" 2>/dev/null)"', + " exit 0", + "fi", + 'printf "LOST process-gone\\n"', +].join("\n"); + // --- structural microsandbox SDK surface (microsandbox@0.6.11) -------------- /** `SandboxStatus` — the SDK's own status vocabulary. */ @@ -118,6 +269,9 @@ type MsbSandbox = { detach?(): Promise; }; +// `Sandbox.get` resolves a handle and REJECTS with a `sandboxNotFound`-coded +// error for an unknown name; `| null` is modeled here only so a double may +// express absence the simpler way. Both are handled (see `lookupHandle`). type MsbSandboxHandle = { readonly name: string; readonly status: MicrosandboxStatus; @@ -246,6 +400,11 @@ export type MicrosandboxRuntimeOptions = { shell?: string; /** Timeout for `connect` to an already-running sandbox. Defaults to 10s. */ connectTimeoutMs?: number; + /** + * Deadline for a whole label lookup, including every page it drains. + * Defaults to 10s, and is overridden per call by `options.timeoutMs`. + */ + lookupTimeoutMs?: number; /** Page size used when draining label listings. Defaults to 100. */ listPageSize?: number; /** Injection seam for tests / a wrapping adapter — defaults to lazy `import("microsandbox")`. */ @@ -277,9 +436,10 @@ export class MicrosandboxNameTooLongError extends Error { * The create deadline supplied by the caller elapsed before the sandbox * finished booting. * - * The sandbox may still be booting under {@link sandboxName}; it is - * deterministically addressable, so a caller can reattach with `getById` or - * reclaim it with `destroy`. + * The create itself is NOT abandoned: the SDK exposes no cancellation for it, + * so the adapter keeps watching that promise and, if the sandbox does finish + * booting afterwards, reclaims it (kill + remove) rather than leaving a + * running microVM nobody is waiting for and a name nobody can reuse. */ export class MicrosandboxCreateTimeoutError extends Error { override readonly name = "MicrosandboxCreateTimeoutError"; @@ -288,13 +448,354 @@ export class MicrosandboxCreateTimeoutError extends Error { constructor(sandboxName: string, timeoutMs: number) { super( - `Microsandbox sandbox "${sandboxName}" did not finish creating within ${timeoutMs}ms`, + `Microsandbox sandbox "${sandboxName}" did not finish creating within ${timeoutMs}ms; a late create is reclaimed`, ); this.sandboxName = sandboxName; this.timeoutMs = timeoutMs; } } +/** + * A label lookup did not finish within its deadline. + * + * Raised rather than returning a partial page: a warm-lease decision made from + * a truncated listing is a decision made from data the caller cannot tell apart + * from "there is nothing else". + */ +export class MicrosandboxLookupTimeoutError extends Error { + override readonly name = "MicrosandboxLookupTimeoutError"; + readonly timeoutMs: number; + + constructor(timeoutMs: number, description: string) { + super(`Microsandbox sandbox lookup exceeded ${timeoutMs}ms while ${description}`); + this.timeoutMs = timeoutMs; + } +} + +/** + * A label listing could not be drained to a trustworthy end. + * + * Raised rather than returning what was collected so far. A short list and a + * complete one are indistinguishable to the caller, so a drain that gave up on + * a cursor that never advances, or on a page body it cannot read, would answer + * "there is nothing else" — and a warm-lease decision or a quota count made + * from that answer is made from data the provider never actually supplied. + */ +export class MicrosandboxPaginationError extends Error { + override readonly name = "MicrosandboxPaginationError"; + readonly pages: number; + + constructor(pages: number, detail: string) { + super(`Microsandbox sandbox listing could not be drained after ${pages} page(s): ${detail}`); + this.pages = pages; + } +} + +/** + * `startScript` was called for a session id that is already admitted for a + * DIFFERENT command. + * + * Nothing was started and nothing was overwritten. A session id is the identity + * of one run's durable state; reusing it for another command would either + * strand the first run or report its exit code as the second one's. + */ +export class MicrosandboxSessionConflictError extends Error { + override readonly name = "MicrosandboxSessionConflictError"; + readonly sessionId: string; + + constructor(sessionId: string) { + super( + `Microsandbox run session "${sessionId}" is already admitted for a different command; nothing was submitted`, + ); + this.sessionId = sessionId; + } +} + +/** + * An admitted run can no longer produce an exit code. + * + * The wrapper writes the exit file as its final act, so a run whose process is + * gone without one — killed, out of memory, or interrupted by a sandbox + * restart — will never complete. Raised so a poll loop ends with a terminal + * outcome instead of asking forever. + */ +export class MicrosandboxRunLostError extends Error { + override readonly name = "MicrosandboxRunLostError"; + readonly sessionId: string; + readonly commandId: string; + readonly reason: string; + + constructor(sessionId: string, commandId: string, reason: string) { + super(`Microsandbox run "${sessionId}" is no longer completable: ${reason}`); + this.sessionId = sessionId; + this.commandId = commandId; + this.reason = reason; + } +} + +/** + * `getExecLogs` was asked for a terminal result while the run is still going. + * + * The bootstrap-plane `ExecResult` has no "unfinished" value — its `exitCode` + * is a number — so returning one here would have to invent a code. Callers poll + * `getExecStatus` and read logs once it reports terminal. + */ +export class MicrosandboxRunNotFinishedError extends Error { + override readonly name = "MicrosandboxRunNotFinishedError"; + readonly sessionId: string; + readonly commandId: string; + + constructor(sessionId: string, commandId: string) { + super( + `Microsandbox run "${sessionId}" has not finished; its exit code is not known yet`, + ); + this.sessionId = sessionId; + this.commandId = commandId; + } +} + +// --- process-global backend gate ------------------------------------------- +// +// WHAT ACTUALLY NEEDS GATING, and why it is only some of the SDK. +// +// `withDefaultBackend` swaps ONE process-wide slot and restores it in a +// `finally`; the SDK documents that it is not task-local. But only the SDK's +// STATIC entry points read that slot. `Sandbox` and `SandboxHandle` each +// capture their backend when they are resolved (`backendKind` on the instance) +// and every instance method delegates to that bound native object — so an +// exec, a filesystem call, a `connect`, a `start`/`stop`/`kill`/`remove` on a +// handle cannot observe the process default at all. +// +// So the gate covers exactly the default-dependent statics this adapter calls +// — `Sandbox.builder(...)` (the native builder is constructed by that call, +// so the whole chain through `create()` is held), `Sandbox.get` and +// `Sandbox.listWith` — and NOTHING else. Holding it across a bound instance +// call would be worse than useless: a single long-running exec would block +// every other backend's work in the process for the run's whole lifetime, +// buying no safety, because that exec was never reading the slot. +// +// Calls that want the SAME backend join the open scope and run concurrently — +// they cannot observe a wrong backend, because it is already theirs. A call +// that wants a DIFFERENT backend queues until the open scope has fully closed +// and the SDK has restored the previous value. Queueing is FIFO-fair: a newly +// arriving same-backend call joins only when nobody is already waiting, so a +// steady stream on one backend cannot starve the other indefinitely. +// +// Honest limits, both properties of the SDK's global state rather than of this +// gate: SDK calls made elsewhere in the process (another library, direct +// `microsandbox` use) are not routed through here and can still observe a +// scoped backend; and a callback that itself re-entered the gate with a +// different backend would wait on a scope it is holding open, so this adapter +// never nests gated calls. + +type BackendScope = { + key: string; + active: number; + closing: boolean; + entered: Promise; + release: () => void; + exited: Promise; +}; + +let currentBackendScope: BackendScope | null = null; +const backendScopeWaiters: Array<() => void> = []; + +/** + * Stable identity for a backend value. The API key is hashed rather than + * stored, so a long-lived comparison key never holds the secret itself. + */ +function backendKey(backend: MicrosandboxBackend): string { + if (backend === "local") { + return "local"; + } + if ("profile" in backend) { + return `cloud:profile:${backend.profile}`; + } + const digest = createHash("sha256").update(backend.apiKey, "utf8").digest("hex").slice(0, 16); + return `cloud:url:${backend.url ?? ""}:key:${digest}`; +} + +async function withBackendScope( + sdk: MicrosandboxSdk, + backend: MicrosandboxBackend, + fn: () => Promise | T, +): Promise { + const key = backendKey(backend); + for (;;) { + const open = currentBackendScope; + if (open && !open.closing && open.key === key) { + open.active += 1; + try { + // The scope may still be opening; running before it is entered would + // run against whatever backend the process last had. + await open.entered; + return await fn(); + } finally { + await leaveBackendScope(open); + } + } + if (open) { + await new Promise((resolve) => { + backendScopeWaiters.push(resolve); + }); + continue; + } + + let release!: () => void; + const drained = new Promise((resolve) => { + release = resolve; + }); + let markEntered!: () => void; + let failEntry!: (error: unknown) => void; + const entered = new Promise((resolve, reject) => { + markEntered = resolve; + failEntry = reject; + }); + // Nobody may await `entered` before its rejection handler is attached + // below, and an unobserved rejection would otherwise surface as a process + // warning; the catch here is the handler of record. + entered.catch(() => undefined); + const scope: BackendScope = { + key, + active: 1, + closing: false, + entered, + release, + exited: Promise.resolve(), + }; + currentBackendScope = scope; + + let scopeCall: Promise; + try { + // The scope stays open for as long as anyone is inside it: the SDK's + // callback resolves only once the last participant has left. + scopeCall = Promise.resolve( + sdk.withDefaultBackend(backend, () => { + markEntered(); + return drained; + }), + ); + } catch (error) { + // A synchronous failure to push the scope — the SDK's own missing-native- + // bindings guard throws exactly like this. Nothing ran, so free the gate + // rather than wedging every later call in the process. + abandonBackendScope(scope); + throw error; + } + scope.exited = scopeCall.then( + () => undefined, + (error: unknown) => { + // Rejected before the callback ran: the scope was never entered. + // Rejected after: the caller's own call already carries the outcome. + failEntry(error); + }, + ); + + try { + await entered; + } catch (error) { + // FAIL CLOSED. Running the call anyway would send this runtime's work to + // whatever backend the process default happens to hold. + abandonBackendScope(scope); + throw error; + } + + try { + return await fn(); + } finally { + await leaveBackendScope(scope); + } + } +} + +async function leaveBackendScope(scope: BackendScope): Promise { + scope.active -= 1; + if (scope.active > 0 || scope.closing) { + return; + } + scope.closing = true; + scope.release(); + // Wait for the SDK to restore the previous backend BEFORE any queued call on + // a different backend is allowed to open its own scope. + await scope.exited; + releaseBackendGate(scope); +} + +/** Free the gate for a scope that never opened. */ +function abandonBackendScope(scope: BackendScope): void { + scope.closing = true; + scope.release(); + releaseBackendGate(scope); +} + +function releaseBackendGate(scope: BackendScope): void { + if (currentBackendScope === scope) { + currentBackendScope = null; + } + const waiters = backendScopeWaiters.splice(0); + for (const wake of waiters) { + wake(); + } +} + +/** + * One page of a label listing, validated before anything is read out of it. + * + * A listing is the ANSWER a warm-lease decision is made from, so a page this + * adapter cannot read is a failure, never an empty result: quietly returning a + * short list is indistinguishable to the caller from "there is nothing else", + * and the caller then launches a sandbox it did not need or under-counts a + * quota it is enforcing. + */ +function readSandboxPage( + page: unknown, + previousCursor: string | undefined, + pageNumber: number, +): { sandboxes: MsbSandboxHandle[]; nextCursor?: string } { + if (typeof page !== "object" || page === null) { + throw new MicrosandboxPaginationError(pageNumber, "the provider returned a page that is not an object"); + } + const record = page as { sandboxes?: unknown; nextCursor?: unknown }; + if (!Array.isArray(record.sandboxes)) { + throw new MicrosandboxPaginationError( + pageNumber, + "the provider returned a page whose sandbox list is unreadable", + ); + } + const sandboxes = record.sandboxes as MsbSandboxHandle[]; + const cursor = record.nextCursor; + if (cursor === undefined || cursor === null || cursor === "") { + return { sandboxes }; + } + if (typeof cursor !== "string") { + throw new MicrosandboxPaginationError( + pageNumber, + "the provider returned a next-page cursor that is not a string", + ); + } + if (previousCursor !== undefined && cursor === previousCursor) { + throw new MicrosandboxPaginationError( + pageNumber, + "the provider returned a next-page cursor identical to the one just used, so the listing cannot advance", + ); + } + return { sandboxes, nextCursor: cursor }; +} + +/** + * What this runtime knows about one sandbox name. + * + * `owned` is the whole reason this registry exists: it is what separates a + * sandbox this runtime launched (and may therefore stop, start, or delete) + * from one it merely attached to. It is sticky-true — a later attach that does + * not claim ownership cannot demote a sandbox this process launched, because + * demoting it would strand a microVM nothing can reclaim. + */ +type RegisteredSandbox = { + sandbox?: MsbSandbox; + owned: boolean; +}; + export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { readonly id = "microsandbox"; @@ -309,9 +810,11 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { * - `snapshots: true` — `launch` can source a sandbox from a snapshot via * the builder's `fromSnapshot`. * - `isolation: 'strong'` — microsandbox boots each sandbox as a microVM - * with its own guest Linux kernel (hardware virtualization), per the - * provider's published requirements: KVM on Linux, Apple Silicon on - * macOS, WHP on Windows. + * with its own guest Linux kernel. On the LOCAL backend that requires a + * virtualization-capable host (KVM on Linux, Apple Silicon on macOS, WHP + * on Windows 10+); on the cloud backend the microVM boots on the + * provider's host instead. Either way the workload does not share this + * process's kernel. * - `persistentHandle: true` — a sandbox is re-resolvable by name from a * fresh process via `Sandbox.get(name)` + `connect()`, and * `launchDetached` sets `detached(true)` so it outlives this process. @@ -334,7 +837,8 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { * server-side label query with cursor pagination, so a warm-lease lookup is * meaningful. `lifecycle`: `start`/`stop` map onto `SandboxHandle.start()` / * `SandboxHandle.stop()`, which genuinely resume and halt a microVM — unlike - * the E2B adapter, where both are no-ops. + * the E2B adapter, where both are no-ops. Both apply to sandboxes this + * runtime OWNS; an attached sandbox is deliberately left alone. */ readonly declaredCapabilities = { warmLease: true, lifecycle: true } as const; @@ -352,15 +856,21 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { private readonly runStateDir: string; private readonly shell: string; private readonly connectTimeoutMs: number; + private readonly lookupTimeoutMs: number; private readonly listPageSize: number; private readonly injectedSdk?: MicrosandboxSdk; private sdkPromise?: Promise; - // Live `Sandbox` instances resolved in this process. A cross-request access - // (an async-exec poll tick) that misses this cache re-resolves by name via - // `Sandbox.get(name).connect()` — the reattach that lets a run outlive the - // request that started it. - private readonly sandboxes = new Map(); + // What this runtime knows about each sandbox name: the live instance + // resolved in this process (if any) and whether this runtime owns it. A + // cross-request access (an async-run poll tick) that misses the instance + // re-resolves by name via `Sandbox.get(name).connect()` — the reattach that + // lets a run outlive the request that started it. + private readonly registry = new Map(); + + // Reclamations of creates that landed after their deadline, keyed by name. + // A later launch of the same name waits for one so the two cannot race. + private readonly pendingReclaims = new Map>(); constructor(options: MicrosandboxRuntimeOptions) { if (!options.image && !options.snapshot) { @@ -401,6 +911,7 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { this.runStateDir = (options.runStateDir ?? DEFAULT_RUN_STATE_DIR).replace(/\/+$/, ""); this.shell = options.shell ?? DEFAULT_SHELL; this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + this.lookupTimeoutMs = options.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS; this.listPageSize = options.listPageSize ?? DEFAULT_LIST_PAGE_SIZE; if (options.sdk !== undefined) { this.injectedSdk = options.sdk; @@ -413,18 +924,37 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { labels: Record, options: SandboxLookupOptions = {}, ): Promise { - const handles = await this.findAllByLabels(labels, options); - const excluded = new Set(options.excludeIds ?? []); - return handles.find((handle) => !excluded.has(handle.id)) ?? null; + // Exclusions are applied DURING the drain, never to the collected page: + // filtering afterwards would let a page full of already-claimed sandboxes + // answer "nothing warm available" while the next page held a free one. + const handles = await this.collectByLabels(labels, { + states: options.states === undefined ? ["STARTED"] : options.states, + cap: 1, + requestSize: options.limit ?? options.pageSize, + excludeIds: options.excludeIds, + timeoutMs: options.timeoutMs, + claim: options.owned ?? false, + description: "listing matching sandboxes", + }); + return handles[0] ?? null; } async findAllByLabels( labels: Record, options: SandboxLookupOptions = {}, ): Promise { - const states = options.states === undefined ? ["STARTED"] : options.states; - const cap = options.limit ?? options.pageSize; - return this.collectByLabels(labels, states, cap, options.pageSize); + return this.collectByLabels(labels, { + states: options.states === undefined ? ["STARTED"] : options.states, + // `limit` caps RESULTS as well as sizing the request; `pageSize` only + // sizes the request. Treating a page size as a result cap would silently + // truncate a listing the caller asked to receive in full. + ...(options.limit !== undefined ? { cap: options.limit } : {}), + requestSize: options.limit ?? options.pageSize, + excludeIds: options.excludeIds, + timeoutMs: options.timeoutMs, + claim: options.owned ?? false, + description: "listing matching sandboxes", + }); } async countByLabels( @@ -438,47 +968,77 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { // Short-circuit: a cap of zero can be answered without a network call. return 0; } - const states = options.states === undefined ? ["STARTED"] : options.states; - const cap = Number.isFinite(maxCount) ? maxCount : undefined; - const handles = await this.collectByLabels(labels, states, cap, options.pageSize); + const handles = await this.collectByLabels(labels, { + states: options.states === undefined ? ["STARTED"] : options.states, + ...(Number.isFinite(maxCount) ? { cap: maxCount } : {}), + requestSize: options.limit ?? options.pageSize, + timeoutMs: options.timeoutMs, + description: "counting matching sandboxes", + }); return handles.length; } /** - * Drain the cursor-paginated, server-side label listing, keep the entries - * matching `states`, and stop as soon as `cap` handles are collected. + * Drain the cursor-paginated, server-side label listing, keeping the entries + * that match `states` and are not excluded, and stopping as soon as `cap` + * such handles are collected or the lookup deadline elapses. */ private async collectByLabels( labels: Record, - states: readonly string[] | null, - cap: number | undefined, - pageSize: number | undefined, + options: { + states: readonly string[] | null; + cap?: number; + requestSize?: number | undefined; + excludeIds?: readonly string[] | undefined; + timeoutMs?: number | undefined; + /** + * Register what the listing finds, with this ownership. A lookup is an + * attach, so it is `false` unless the caller explicitly claims what it + * finds — the same rule the Daytona adapter applies at its own + * `registerSandbox` call sites. Omitted entirely for a count, which + * resolves nothing the caller can act on. + */ + claim?: boolean; + description: string; + }, ): Promise { - const limit = pageSize ?? this.listPageSize; + // Request size, resolved exactly as Daytona/E2B/local resolve it, so a + // caller that tuned one provider's lookup gets the same request shape here. + const limit = options.requestSize ?? this.listPageSize; + const excluded = new Set(options.excludeIds ?? []); + const deadline = this.lookupDeadline(options.timeoutMs); const handles: RuntimeHandle[] = []; let cursor: string | undefined; // Bounded so a backend that keeps handing back the same cursor cannot spin - // this loop forever. + // this loop forever. The deadline bounds it in wall-clock terms too. for (let page = 0; page < 1_000; page += 1) { const cursorForPage = cursor; - const result = await this.withBackend((sdk) => - sdk.Sandbox.listWith((builder) => { - let configured = builder.limit(limit); - if (hasEntries(labels)) { - configured = configured.labels(labels); - } - if (cursorForPage) { - configured = configured.cursor(cursorForPage); - } - return configured; - }), + const raw = await this.awaitWithin( + this.withBackendStatic((sdk) => + sdk.Sandbox.listWith((builder) => { + let configured = builder.limit(limit); + if (hasEntries(labels)) { + configured = configured.labels(labels); + } + if (cursorForPage) { + configured = configured.cursor(cursorForPage); + } + return configured; + }), + ), + deadline, + options.description, ); - for (const entry of result.sandboxes ?? []) { - if (!matchesState(entry.status, states)) { + const result = readSandboxPage(raw, cursorForPage, page + 1); + for (const entry of result.sandboxes) { + if (!matchesState(entry.status, options.states) || excluded.has(entry.name)) { continue; } + if (options.claim !== undefined) { + this.register(entry.name, { owned: options.claim }); + } handles.push(handleFromSandboxHandle(entry)); - if (cap !== undefined && handles.length >= cap) { + if (options.cap !== undefined && handles.length >= options.cap) { return handles; } } @@ -508,6 +1068,11 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { if (!matchesState(entry.status, states)) { return null; } + // Ownership defaults to FALSE, exactly as it does on the Daytona adapter: + // resolving a sandbox by name is an attach, and an attach is not a claim. + // `stop`, `start` and `destroy` refuse to touch a sandbox that was never + // claimed, so a lease-reattach path cannot delete a sandbox it borrowed. + this.register(id, { owned: options.owned ?? false }); const handle = handleFromSandboxHandle(entry); if (options.homeDir !== undefined) { handle.homeDir = options.homeDir; @@ -536,8 +1101,15 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { detached: boolean, ): Promise { const name = this.resolveName(options.name); + // A previous create under this name may still be being reclaimed. Waiting + // keeps the reclamation from killing the sandbox this call is about to + // create under the same name. + const pending = this.pendingReclaims.get(name); + if (pending) { + await pending; + } const workdir = options.workdir ?? this.defaultWorkdir; - const create = this.withBackend(async (sdk) => { + const create = this.withBackendStatic(async (sdk) => { let builder = sdk.Sandbox.builder(name); builder = this.snapshot ? builder.fromSnapshot(this.snapshot) @@ -576,13 +1148,23 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { // The builder has no create-timeout setter, and `maxDuration` is a sandbox // LIFETIME budget — mapping the caller's boot deadline onto it would kill // every long-lived sandbox the moment that deadline elapsed. So the create - // deadline is enforced here instead, and the still-booting sandbox stays - // addressable under its deterministic name. - const sandbox = options.createTimeoutSeconds - ? await withDeadline(create, options.createTimeoutSeconds * 1000, name) - : await create; + // deadline is enforced here instead. + let sandbox: MsbSandbox; + if (options.createTimeoutSeconds) { + const timeoutMs = options.createTimeoutSeconds * 1000; + try { + sandbox = await withDeadline(create, timeoutMs, name); + } catch (error) { + if (error instanceof MicrosandboxCreateTimeoutError) { + this.reclaimLateCreate(name, create); + } + throw error; + } + } else { + sandbox = await create; + } - this.sandboxes.set(name, sandbox); + this.register(name, { sandbox, owned: true }); const handle: RuntimeHandle = { id: name, state: "STARTED", homeDir: this.homeDir }; if (workdir !== undefined) { handle.workdir = workdir; @@ -590,6 +1172,41 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { return handle; } + /** + * Take responsibility for a create that lost the race with its deadline. + * + * The SDK offers no way to cancel an in-flight create, so the promise is + * watched instead of dropped. Two things follow from that, and both matter: + * the eventual rejection is consumed here (an abandoned rejected promise is + * an unhandled rejection, which crashes a Node process configured to treat + * them as fatal), and an eventual SUCCESS is reclaimed — the caller already + * saw a failure, so a sandbox nobody is waiting for would otherwise burn + * provider resources and hold its name against the next launch. + */ + private reclaimLateCreate(name: string, create: Promise): void { + const reclaimed = create.then( + async () => { + try { + await this.forceDestroy(name); + } catch { + // Best effort. The sandbox stays addressable under its deterministic + // name, so an operator (or a later launch of the same name) can still + // reclaim it. + } + }, + () => { + // The create failed on its own: there is nothing to reclaim, and the + // caller already has the timeout error. + }, + ); + const tracked = reclaimed.finally(() => { + if (this.pendingReclaims.get(name) === tracked) { + this.pendingReclaims.delete(name); + } + }); + this.pendingReclaims.set(name, tracked); + } + private resolveName(requested?: string): string { const name = requested ?? `${this.namePrefix}${randomUUID()}`; const byteLength = Buffer.byteLength(name, "utf8"); @@ -626,9 +1243,12 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { const sandbox = await this.requireSandbox(handle); const cwd = options.cwd ?? handle.workdir; // `shell(script)` takes no options, so a configurable POSIX shell with - // `-c` is what carries cwd / env / timeout onto a command string. - const output = await this.withBackend(() => - sandbox.execWith(this.shell, (builder) => { + // `-c` is what carries cwd / env / timeout onto a command string. The + // command travels as an argv element, so nothing quotes or rewrites it. + // Off the gate: `sandbox` is bound to the backend it was resolved on, so + // this reads no process-wide state — and holding the gate for the whole + // command would block every other backend in the process meanwhile. + const output = await sandbox.execWith(this.shell, (builder) => { let configured = builder.args(["-c", options.command]); if (cwd !== undefined) { configured = configured.cwd(cwd); @@ -639,9 +1259,8 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { if (options.timeoutMs !== undefined && options.timeoutMs > 0) { configured = configured.timeout(options.timeoutMs); } - return configured; - }), - ); + return configured; + }); const stdout = output.stdout(); const stderr = output.stderr(); // A non-zero exit is a RESULT here, not an exception: the SDK resolves @@ -654,6 +1273,16 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { }; } + /** + * Submit one durable async run. + * + * The guest wrapper (see {@link MICROSANDBOX_RUN_ADMIT_SCRIPT}) claims the + * session's run directory with an atomic `mkdir`, so this is idempotent in + * the way that actually matters for an outcome-unknown submit: a retry of + * the SAME command adopts the run that is already there (`reconciled: true`) + * instead of starting a second one, and a retry with a DIFFERENT command is + * refused instead of overwriting the first run's state. + */ async startScript(handle: RuntimeHandle, options: { command: string; sessionId?: string; @@ -665,25 +1294,17 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { const sandbox = await this.requireSandbox(handle); const sessionId = options.sessionId ?? `run-${handle.id}-${randomUUID()}`; const dir = this.scriptRunDir(sessionId); - const outPath = `${dir}/out`; - const exitPath = `${dir}/exit`; const cwd = options.cwd ?? handle.workdir; - // Durable-file wrapper: the SDK's streaming `ExecHandle` is process-local - // and has no server-side id to poll from a fresh process, so combined - // output and the final exit code are captured to guest files that survive - // disconnect and reconnect across poll ticks. `nohup ... &` detaches the - // run from the submit call so the submit returns immediately. - const wrapped = - `mkdir -p ${shellSingleQuote(dir)}; ` + - `nohup ${shellSingleQuote(this.shell)} -c ` + - shellSingleQuote( - `{ ${options.command}\n} > ${shellSingleQuote(outPath)} 2>&1; ` + - `echo $? > ${shellSingleQuote(exitPath)}`, - ) + - ` > /dev/null 2>&1 & echo $!`; - const output = await this.withBackend(() => - sandbox.execWith(this.shell, (builder) => { - let configured = builder.args(["-c", wrapped]); + const output = await sandbox.execWith(this.shell, (builder) => { + let configured = builder.args([ + "-c", + MICROSANDBOX_RUN_ADMIT_SCRIPT, + "msb-admit", + options.command, + dir, + this.runStateDir, + this.shell, + ]); if (cwd !== undefined) { configured = configured.cwd(cwd); } @@ -697,28 +1318,82 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { if (options.timeoutMs !== undefined && options.timeoutMs > 0) { configured = configured.timeout(options.timeoutMs); } - return configured; - }), + return configured; + }); + const marker = output.stdout().trim(); + if (marker.startsWith("ADMITTED ")) { + return { sessionId, commandId: marker.slice("ADMITTED ".length).trim() }; + } + if (marker.startsWith("CLAIMED ")) { + return { + sessionId, + commandId: marker.slice("CLAIMED ".length).trim(), + reconciled: true, + }; + } + if (marker === "CONFLICT") { + throw new MicrosandboxSessionConflictError(sessionId); + } + throw new Error( + `Microsandbox async run admission for session "${sessionId}" returned no verdict (exit ${output.code}): ${ + summarize(output.stderr() || marker) + }`, ); - return { sessionId, commandId: output.stdout().trim() }; } async getScriptStatus( handle: RuntimeHandle, sessionId: string, - _commandId: string, + commandId: string, ): Promise { const sandbox = await this.requireSandbox(handle); - const exitPath = `${this.scriptRunDir(sessionId)}/exit`; - // The durable exit file is the source of truth: missing or empty means the - // run has not finished, never "finished with an unknown code". - const raw = await this.readFileBestEffort(sandbox, exitPath, 64); - const trimmed = raw.trim(); - if (!trimmed) { + const dir = this.scriptRunDir(sessionId); + let marker: string; + try { + const output = await sandbox.execWith(this.shell, (builder) => + builder.args(["-c", MICROSANDBOX_RUN_STATUS_SCRIPT, "msb-status", dir]), + ); + marker = (output.stdout() ?? "").trim(); + } catch { + // A failed probe says nothing about the run. Report it as unfinished and + // let the caller poll again, exactly as a dropped status read should. return { exitCode: null }; } - const parsed = Number.parseInt(trimmed, 10); - return { exitCode: Number.isFinite(parsed) ? parsed : null }; + if (marker.startsWith("EXIT ")) { + const parsed = Number.parseInt(marker.slice("EXIT ".length).trim(), 10); + if (Number.isFinite(parsed)) { + return { exitCode: parsed }; + } + // The wrapper finished and recorded something unreadable. Inventing a + // code would be a lie and reporting "running" would poll forever. + throw new MicrosandboxRunLostError( + sessionId, + commandId, + "its recorded exit code is unreadable", + ); + } + if (marker === "RUNNING") { + return { exitCode: null }; + } + if (marker === "MISSING") { + throw new MicrosandboxRunLostError( + sessionId, + commandId, + "its run-state directory is gone", + ); + } + if (marker.startsWith("LOST")) { + const reason = marker.slice("LOST".length).trim(); + throw new MicrosandboxRunLostError( + sessionId, + commandId, + reason === "sandbox-restarted" + ? "the sandbox restarted while it was running" + : "its process is gone and it never recorded an exit code", + ); + } + // Unrecognized output is an unreadable probe, not a verdict. + return { exitCode: null }; } async getScriptLogs( @@ -752,13 +1427,25 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { return this.getScriptStatus(handle, sessionId, commandId); } + /** + * Terminal result of an async run: its captured output AND the exit code the + * run actually recorded. + * + * The exit code comes from `getScriptStatus`, never from the log read. + * `getScriptLogs` reports `exitCode: null` by design, and defaulting that to + * `0` here would report every unfinished — and every lost — run as a success. + */ async getExecLogs( handle: RuntimeHandle, sessionId: string, commandId: string, ): Promise { - const result = await this.getScriptLogs(handle, sessionId, commandId); - return { output: result.output, exitCode: result.exitCode ?? 0 }; + const status = await this.getScriptStatus(handle, sessionId, commandId); + if (status.exitCode === null) { + throw new MicrosandboxRunNotFinishedError(sessionId, commandId); + } + const logs = await this.getScriptLogs(handle, sessionId, commandId); + return { output: logs.output, exitCode: status.exitCode }; } // --- files --------------------------------------------------------------- @@ -779,10 +1466,10 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { const fs = sandbox.fs(); await this.ensureParentDir(fs, destination); if (typeof source === "string") { - await this.withBackend(() => fs.copyFromHost(source, destination)); + await fs.copyFromHost(source, destination); return; } - await this.withBackend(() => fs.write(destination, toUint8Array(source))); + await fs.write(destination, toUint8Array(source)); } async uploadBundle(handle: RuntimeHandle, options: { @@ -801,10 +1488,10 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { const sandbox = await this.requireSandbox(handle); const fs = sandbox.fs(); if (destination) { - await this.withBackend(() => fs.copyToHost(source, destination)); + await fs.copyToHost(source, destination); return; } - const bytes = await this.withBackend(() => fs.read(source)); + const bytes = await fs.read(source); return Buffer.from(bytes); } @@ -815,41 +1502,79 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { } // --- lifecycle ----------------------------------------------------------- + // + // Every method below changes remote state, so every one of them first asks + // whether this runtime OWNS the sandbox. A sandbox is owned when this runtime + // launched it, or when the caller said so explicitly via + // `getById(id, { owned: true })`. Anything else was borrowed — a warm lease + // found by label, a sandbox another process launched — and borrowing does not + // confer the right to halt, boot, or delete it. async start(handle: RuntimeHandle): Promise { + if (!this.isOwned(handle.id)) { + // Not ours: booting someone else's stopped sandbox would charge them for + // a microVM they chose to have stopped. + return handle; + } const entry = await this.lookupHandle(handle.id); if (!entry) { throw new Error(`Microsandbox sandbox "${handle.id}" is no longer available`); } - const sandbox = await this.withBackend(() => entry.start()); - this.sandboxes.set(handle.id, sandbox); + const sandbox = await entry.start(); + this.register(handle.id, { sandbox }); return { ...handle, state: "STARTED" }; } async stop(handle: RuntimeHandle): Promise { + if (!this.isOwned(handle.id)) { + return; + } const entry = await this.lookupHandle(handle.id); if (!entry) { // Already gone: stopping is idempotent, so this is success, not an error. return; } - this.sandboxes.delete(handle.id); - await this.withBackend(() => entry.stop()); + this.forgetInstance(handle.id); + await entry.stop(); } /** - * Halt the sandbox AND drop its database record. + * Halt the sandbox AND drop its database record — but only if this runtime + * owns it. * - * Both halves matter: the name is the identity, so leaving a stopped record - * behind would make the next `launch` under that name collide. + * Both halves of the teardown matter: the name is the identity, so leaving a + * stopped record behind would make the next `launch` under that name collide. + * The ownership check matters more: `destroy` is the one call here that + * cannot be undone, and a lease-reattach path that resolved a borrowed + * sandbox by name must not be able to delete it. An unowned (or unknown) + * handle drops this runtime's local state and makes no remote call at all. */ async destroy(handle: RuntimeHandle): Promise { - const entry = await this.lookupHandle(handle.id); - this.sandboxes.delete(handle.id); + if (!this.isOwned(handle.id)) { + this.registry.delete(handle.id); + return; + } + this.forgetInstance(handle.id); + await this.forceDestroy(handle.id); + // Dropped only after the remote teardown succeeded: keeping the ownership + // record through a failure is what lets the caller retry it. + this.registry.delete(handle.id); + } + + /** + * Kill + remove by name, with no ownership check. + * + * Private on purpose: the only callers are `destroy` (which has already + * checked) and the reclamation of a create that landed after its deadline + * (which is reclaiming a sandbox this runtime itself asked for). + */ + private async forceDestroy(name: string): Promise { + const entry = await this.lookupHandle(name); if (!entry) { return; } try { - await this.withBackend(() => entry.kill()); + await entry.kill(); } catch (error) { // `remove` requires a stopped sandbox; a kill that failed because it was // already stopped must not block the removal that frees the name. @@ -858,7 +1583,7 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { } } try { - await this.withBackend(() => entry.remove()); + await entry.remove(); } catch (error) { if (!isSandboxNotFound(error)) { throw error; @@ -875,28 +1600,52 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { if (!this.sdkPromise) { // Lazy — keeps `microsandbox` and its platform-specific native addon out // of every non-microsandbox consumer. - this.sdkPromise = import("microsandbox").then( + const loading = import("microsandbox").then( (mod) => mod as unknown as MicrosandboxSdk, + (error: unknown) => { + // A cached rejection would fail every later call for the life of the + // process, so the slot is cleared and the constraint that most often + // explains the failure is stated instead of buried. + if (this.sdkPromise === loading) { + delete this.sdkPromise; + } + throw new Error( + "MicrosandboxRuntime could not load its optional peer dependency \"microsandbox\" (>=0.6.11 <0.7.0). " + + "That package declares Node.js >= 22 and ships a platform-specific native addon " + + "(macOS arm64, Linux x64/arm64, Windows x64/arm64); its local backend additionally requires " + + `hardware virtualization (KVM, Apple Silicon, or WHP). Underlying error: ${errorMessage(error)}`, + { cause: error }, + ); + }, ); + this.sdkPromise = loading; } return this.sdkPromise; } /** - * Run one SDK call with this runtime's backend in scope. + * Run one DEFAULT-DEPENDENT SDK static with this runtime's backend in scope. + * + * `setDefaultBackend` is never called from this adapter: constructing a + * runtime must not mutate the host process. The scoped form IS process-wide + * while it is open, which is why every static goes through the module's + * backend gate (see `withBackendScope`) rather than opening its own scope. * - * `withDefaultBackend` restores the previous backend in a `finally`, so - * constructing a runtime never mutates process-wide state — the reason - * `setDefaultBackend` is never called from this adapter. + * ONLY the three statics this adapter calls belong here — `Sandbox.builder` + * (through its terminal `create()`), `Sandbox.get` and `Sandbox.listWith`. + * Operations on a resolved `Sandbox` or `SandboxHandle` are bound to the + * backend they were resolved on and are issued directly, off the gate. */ - private async withBackend(fn: (sdk: MicrosandboxSdk) => Promise | T): Promise { + private async withBackendStatic( + fn: (sdk: MicrosandboxSdk) => Promise | T, + ): Promise { const sdk = await this.sdk(); - return sdk.withDefaultBackend(this.backend, () => fn(sdk)); + return withBackendScope(sdk, this.backend, () => fn(sdk)); } private async lookupHandle(name: string): Promise { try { - return (await this.withBackend((sdk) => sdk.Sandbox.get(name))) ?? null; + return (await this.withBackendStatic((sdk) => sdk.Sandbox.get(name))) ?? null; } catch (error) { if (isSandboxNotFound(error)) { return null; @@ -905,8 +1654,39 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { } } + private register( + name: string, + patch: { sandbox?: MsbSandbox; owned?: boolean }, + ): RegisteredSandbox { + const existing = this.registry.get(name); + const sandbox = patch.sandbox ?? existing?.sandbox; + const entry: RegisteredSandbox = { + // Sticky: an attach that does not claim ownership cannot demote a + // sandbox this runtime launched, because demoting it would leave a + // microVM this process is responsible for with nothing able to reclaim + // it. + owned: (existing?.owned ?? false) || (patch.owned ?? false), + ...(sandbox ? { sandbox } : {}), + }; + this.registry.set(name, entry); + return entry; + } + + private isOwned(name: string): boolean { + return this.registry.get(name)?.owned ?? false; + } + + /** Drop the live connection but keep what this runtime knows about the name. */ + private forgetInstance(name: string): void { + const existing = this.registry.get(name); + if (!existing) { + return; + } + this.registry.set(name, { owned: existing.owned }); + } + private async requireSandbox(handle: RuntimeHandle): Promise { - const cached = this.sandboxes.get(handle.id); + const cached = this.registry.get(handle.id)?.sandbox; if (cached) { return cached; } @@ -916,10 +1696,8 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { } // `connect` attaches WITHOUT taking lifecycle ownership, so a poll tick // that reattaches cannot accidentally stop a sandbox it did not launch. - const sandbox = await this.withBackend(() => - entry.connectWithTimeout(this.connectTimeoutMs), - ); - this.sandboxes.set(handle.id, sandbox); + const sandbox = await entry.connectWithTimeout(this.connectTimeoutMs); + this.register(handle.id, { sandbox }); return sandbox; } @@ -929,7 +1707,7 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { return; } try { - await this.withBackend(() => fs.mkdir(parent)); + await fs.mkdir(parent); } catch { // Best effort: an already-present directory is the common case and must // not fail the upload that follows. @@ -945,13 +1723,11 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { // "" instead of throwing while the file is still absent, and caps the bytes // pulled back to the caller. try { - const output = await this.withBackend(() => - sandbox.execWith(this.shell, (builder) => - builder.args([ - "-c", - `tail -c ${maxBytes} ${shellSingleQuote(path)} 2>/dev/null || true`, - ]), - ), + const output = await sandbox.execWith(this.shell, (builder) => + builder.args([ + "-c", + `tail -c ${maxBytes} ${shellSingleQuote(path)} 2>/dev/null || true`, + ]), ); return output.stdout() ?? ""; } catch { @@ -959,9 +1735,51 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { } } + private lookupDeadline(timeoutMs: number | undefined): { endsAt: number; timeoutMs: number } { + const requested = timeoutMs ?? this.lookupTimeoutMs; + const normalized = Number.isFinite(requested) && requested > 0 + ? Math.max(1, Math.ceil(requested)) + : this.lookupTimeoutMs; + return { endsAt: Date.now() + normalized, timeoutMs: normalized }; + } + + private async awaitWithin( + operation: Promise, + deadline: { endsAt: number; timeoutMs: number }, + description: string, + ): Promise { + const remainingMs = deadline.endsAt - Date.now(); + if (remainingMs <= 0) { + throw new MicrosandboxLookupTimeoutError(deadline.timeoutMs, description); + } + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new MicrosandboxLookupTimeoutError(deadline.timeoutMs, description)), + remainingMs, + ); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + /** + * Guest directory holding one session's run state. + * + * The encoding is reversible, so two different session ids can never land on + * one directory. A sanitizing replacement cannot promise that: it maps `a/b` + * and `a_b` onto the same path, which is enough to hand one run's exit code + * to the other. + */ private scriptRunDir(sessionId: string): string { - const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "_"); - return `${this.runStateDir}/${safe}`; + return `${this.runStateDir}/${encodeRunSegment(sessionId)}`; } } @@ -1017,6 +1835,27 @@ function combineOutput(stdout: string, stderr: string): string { return stdout || stderr || ""; } +/** + * Reversible path-segment encoding for a session id. + * + * Every byte outside `[A-Za-z0-9_-]` becomes `%XX`, including `%` itself, so + * distinct ids always produce distinct segments. An id long enough to threaten + * the guest filesystem's 255-byte filename limit collapses to a digest instead; + * the leading `.` cannot be produced by the encoder, so a digest segment can + * never be confused with an encoded one. + */ +function encodeRunSegment(sessionId: string): string { + const encoded = sessionId.replace(/[^A-Za-z0-9_-]/g, (character) => + [...Buffer.from(character, "utf8")] + .map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`) + .join(""), + ); + if (Buffer.byteLength(encoded, "utf8") <= MAX_RUN_SEGMENT_BYTES) { + return encoded; + } + return `.${createHash("sha256").update(sessionId, "utf8").digest("hex")}`; +} + /** Exact when the SDK's typed error code is present; textual only as a fallback. */ function isSandboxNotFound(error: unknown): boolean { if (typeof error !== "object" || error === null) { @@ -1044,6 +1883,16 @@ function isAlreadyStopped(error: unknown): boolean { return typeof message === "string" && /already\s+stopped|not\s+running/i.test(message); } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Bound a provider string before it is interpolated into an error message. */ +function summarize(value: string): string { + const trimmed = value.trim(); + return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed || "(no output)"; +} + async function withDeadline( promise: Promise, timeoutMs: number, diff --git a/src/port.ts b/src/port.ts index 6f27ad8..3b4ea9a 100644 --- a/src/port.ts +++ b/src/port.ts @@ -13,6 +13,12 @@ export type RunScriptResult = { export type AsyncRunStartResult = { sessionId: string; commandId: string; + /** + * True when a submit whose outcome was unknown resolved to the run that was + * already admitted for this exact session and command, rather than starting + * a second one. + */ + reconciled?: true; }; export type AsyncRunStatus = { From 62bf7f5ceabc43ca7ca856880290bc53a6c40a05 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 02:57:14 +0200 Subject: [PATCH 03/10] fix(microsandbox): report capabilities per backend and bound the backend gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three provider claims this adapter published were not ones the package could stand behind, and one concurrency defect could deadlock a whole process. CAPABILITIES ARE BACKEND-SENSITIVE, NOT PROCESS-WIDE CONSTANTS `capabilities` was a flat field asserting `snapshots: true` and `isolation: 'strong'` for every instance, justified only by `fromSnapshot` existing on the SDK builder. Existence of a setter is not evidence a backend honours it. Both fields are now derived from the backend the instance is bound to: - `snapshots` is LOCAL-only. What `fromSnapshot` consumes is a snapshot ARTIFACT, and the SDK resolves those from a host-local directory (`~/.microsandbox/snapshots//`, indexed in a local DB cache), which a cloud create cannot reach. Configuring `snapshot` with a cloud backend is now refused in the CONSTRUCTOR — before the lazy `import("microsandbox")`, before any SDK call — so it cannot be mistaken for a backend outage. That Microsandbox cloud does not support snapshot-sourced creates is vendor documentation and is recorded as such in the code, because it is NOT derivable from the installed typings; the host-local half IS checkable and is cited alongside it. - `isolation` is `'strong'` on local and `'unknown'` on cloud. `IsolationLevel` gains `'unknown'` for exactly this: a provider whose isolation this package has not established. Locally the SDK boots a microVM with its own guest kernel on a virtualization-capable host, which is verifiable. The cloud backend's isolation, region placement and resource enforcement are vendor-documented but not observable here and this adapter measures none of them, so it no longer claims them. No other adapter's values change. Custom and published PORTS are documented as unsupported rather than silently ignored: the SDK builder exposes `port()`/`portBind()`, but the ports this package targets have no public-port surface, so the adapter never calls them. A CLIENT-SIDE DEADLINE COULD WEDGE THE PROCESS-GLOBAL BACKEND GATE `withBackendScope` released in a `finally` around `await fn()`, but `withDeadline`/`awaitWithin` race only the CALLER out. A create or lookup that outlived its deadline returned a clean typed error while its SDK call stayed in flight — so the `finally` never ran and the scope was never released. Every other backend in the process then blocked forever. Both `MicrosandboxCreateTimeoutError` and `MicrosandboxLookupTimeoutError` are supported outcomes, so this was a normal path, not an exotic one. It was invisible on a single-backend run: a wedged-open scope is joined, not blocked, by a call wanting that same backend. The queue wait is now bounded (`backendQueueTimeoutMs`, default 30s) and a call that gives up fails with `MicrosandboxBackendBusyError`. Failing is the honest outcome of the three available: waiting forever deadlocks the process, and running anyway would send the call to whichever backend the process default happens to hold — the one thing the gate exists to prevent. Releasing the scope early was rejected deliberately: it would require the native layer to bind a backend at invocation rather than during the in-flight call, which is not verifiable from here, and getting it wrong would mis-route traffic rather than merely delay it. TESTS Paired must-fire/must-not-fire cover for each claim: local+snapshot declares `true` and calls `builder.fromSnapshot`; cloud+snapshot is refused at construction AND leaves the SDK log empty, proving the refusal precedes the lazy import; cloud+image never takes the snapshot path; `'unknown'` does not leak onto local. The gate gains a regression test for the abandoned-holder case and a must-not-fire guard that ordinary contention still succeeds. Test hygiene, which mattered more than expected: four tests deliberately abandoned a scope holder and left it pending, poisoning every later different-backend call in the shared process. They now release it. Together with bounding the queue this took the microsandbox file from 154 pass / 3 fail / 19 cancelled to 178 pass / 0 fail / 0 cancelled, and its runtime from 31s to 2.3s — the cancellations were masking whether those tests passed at all. Full gate, sequential and all green: build, typecheck, test (227 tests, 225 pass, 0 fail, 0 cancelled, 2 live-gated skips), npm audit --omit=dev (0 vulnerabilities), git diff --check, npm pack --dry-run. Findings from sandbox-micro-fix4/fix5/fix6-0820, whose earlier work this builds on, and the snapshot/isolation rulings from sandbox-lead-0819. Co-Authored-By: Claude Opus 5 Session-Id: 74382151-b126-43b1-98c1-954100d81198 Session-Id: 6d810ec8-fbbc-43f2-8ef2-bc87744ca04f --- README.md | 20 +++ src/index.ts | 1 + src/microsandbox/runtime.test.ts | 254 ++++++++++++++++++++++++++----- src/microsandbox/runtime.ts | 157 ++++++++++++++++--- src/types.ts | 22 ++- 5 files changed, 401 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 31ba68a..39f122f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,26 @@ the microsandbox adapter never loads that SDK: it is imported lazily, at first use, and a load failure is reported with the constraint that most often explains it. +### Microsandbox capabilities are backend-sensitive + +`MicrosandboxRuntime.capabilities` is derived from the backend the instance is +bound to, not reported as a single process-wide constant: + +| Capability | `local` | `cloud` | Why | +| --- | --- | --- | --- | +| `snapshots` | `true` | `false` | A snapshot source is a host-local artifact — the SDK resolves it under `~/.microsandbox/snapshots/` and indexes it in a local DB cache — so a cloud create cannot reach one. Configuring `snapshot` with a cloud backend is refused in the constructor, before any SDK call. | +| `isolation` | `'strong'` | `'unknown'` | Locally the SDK boots a microVM with its own guest kernel on a virtualization-capable host, which this package can stand behind. The cloud backend's isolation is vendor-documented but not observable from here, and this adapter measures nothing about it. | + +`'unknown'` is not a synonym for weak. It means this package has not established +the guarantee, so a caller that requires one must decide for itself rather than +read an unverified `'strong'`. + +Cloud region placement and resource enforcement are likewise not represented as +measured facts. Custom or published **ports are not supported**: the SDK builder +exposes `port()`/`portBind()`, but the ports this package targets have no +public-port surface, so the adapter never calls them and never implies a +reachable port. + ## Design Two pieces, deliberately kept apart: diff --git a/src/index.ts b/src/index.ts index 78922bb..f195965 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,6 +74,7 @@ export type { } from "./e2b/runtime.js"; export { + MicrosandboxBackendBusyError, MicrosandboxCreateTimeoutError, MicrosandboxLookupTimeoutError, MicrosandboxNameTooLongError, diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts index 141f574..bff698c 100644 --- a/src/microsandbox/runtime.test.ts +++ b/src/microsandbox/runtime.test.ts @@ -8,6 +8,7 @@ import { after, before, describe, it } from "node:test"; import * as pkg from "../index.js"; import { + MicrosandboxBackendBusyError, MicrosandboxCreateTimeoutError, MicrosandboxLookupTimeoutError, MicrosandboxNameTooLongError, @@ -542,6 +543,42 @@ describe("construction", () => { ); }); + // A snapshot source is a LOCAL-backend-only configuration. The SDK boots a + // sandbox from a snapshot ARTIFACT — a host-local directory under + // `~/.microsandbox/snapshots//`, indexed in a local DB cache — which a + // cloud create has no access to. Accepting the pairing and letting it fail at + // the provider would surface as an opaque remote error on a path the caller + // was told existed, so it is refused here instead. + it("refuses a snapshot on the cloud backend, and refuses it before any SDK call", () => { + const { sdk, log } = createHarness(); + assert.throws( + () => + new MicrosandboxRuntime({ + backend: CLOUD_BACKEND, + snapshot: "snap-1", + homeDir: HOME_DIR, + sdk, + }), + /snapshot/i, + ); + // MUST NOT FIRE. The refusal rests on a provider fact known without asking + // the provider, so it has to land before the SDK is touched at all — that + // is what keeps it distinguishable from a backend outage. + assert.deepEqual(log, [], "a refused configuration must not reach the SDK"); + }); + + it("accepts a snapshot on the local backend", () => { + const { sdk, log } = createHarness(); + const runtime = new MicrosandboxRuntime({ + backend: "local", + snapshot: "snap-1", + homeDir: HOME_DIR, + sdk, + }); + assert.equal(runtime.capabilities.snapshots, true); + assert.deepEqual(log, []); + }); + it("touches no SDK surface at construction time", () => { const { sdk, log } = createHarness(); makeRuntime(sdk); @@ -739,6 +776,83 @@ describe("backend gate (the scope is process-global)", () => { await running; }); + it("fails a queued call rather than waiting forever on an abandoned scope holder", async () => { + // The create-timeout path is a SUPPORTED outcome, not an exotic one, and + // the SDK exposes no way to cancel an in-flight create — so after the + // deadline fires the create is still running. If the gate is held until + // that abandoned create settles, every OTHER backend in the process is + // blocked for an unbounded time by a call whose caller already gave up. + // + // The scope only has to cover the part that READS the process-global + // default; it must not cover the wait for a result nobody is waiting for. + let creates = 0; + let finishFirstCreate!: () => void; + const { sdk } = createHarness({ + onCreate: () => { + creates += 1; + return creates > 1 + ? Promise.resolve() + : new Promise((resolve) => { + finishFirstCreate = resolve; + }); + }, + }); + const cloud = makeRuntime(sdk, { backendQueueTimeoutMs: 150 }); + const local = makeRuntime(sdk, { backend: "local", backendQueueTimeoutMs: 150 }); + + await assert.rejects( + cloud.launch({ name: "slow", createTimeoutSeconds: 0.02 }), + MicrosandboxCreateTimeoutError, + ); + + // The local call cannot run: the cloud scope is held by a create nobody is + // waiting for any more. The guarantee is NOT that it succeeds — that would + // require changing the process default while an SDK call bound to the other + // backend is still in flight, which is the mis-routing the gate exists to + // prevent. The guarantee is that it does not hang forever. + const outcome = await Promise.race([ + local.launch({ name: "local-1" }).then( + () => "launched", + (error: unknown) => (error instanceof MicrosandboxBackendBusyError ? "busy" : `other:${error}`), + ), + new Promise((resolve) => setTimeout(() => resolve("HUNG"), 2_000)), + ]); + // Release before asserting: a failing assertion throws, and an abandoned + // create still pending at that point would hold the gate for the rest of + // the file, turning one honest red into a cascade of cancellations. + finishFirstCreate(); + assert.equal( + outcome, + "busy", + "a queued call must fail with a typed error, never hang, when the scope holder was abandoned", + ); + }); + + it("does not fail a queued call while the other backend is merely slow", async () => { + // MUST NOT FIRE. The bound above must not turn ordinary contention into an + // error: once the holder finishes, the queued call runs normally. + let creates = 0; + let finishFirstCreate!: () => void; + const { sdk } = createHarness({ + onCreate: () => { + creates += 1; + return creates > 1 + ? Promise.resolve() + : new Promise((resolve) => { + finishFirstCreate = resolve; + }); + }, + }); + const cloud = makeRuntime(sdk, { backendQueueTimeoutMs: 5_000 }); + const local = makeRuntime(sdk, { backend: "local", backendQueueTimeoutMs: 5_000 }); + const slow = cloud.launch({ name: "slow" }); + const queued = local.launch({ name: "local-1" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + finishFirstCreate(); + await slow; + assert.equal((await queued).id, "local-1"); + }); + it("keeps interleaved two-runtime static calls on their own backend", async () => { // The discriminating one. `withDefaultBackend` mutates ONE process-wide // slot; this fake models exactly that slot, and every DEFAULT-DEPENDENT @@ -998,7 +1112,10 @@ describe("launch", () => { it("boots from a snapshot and never from an image when configured that way", async () => { const { sdk, log } = createHarness(); - await makeRuntime(sdk, { image: undefined, snapshot: "snap-7" }).launch({ name: "s1" }); + // Local backend: a snapshot source is only accepted there (see the + // construction block), because the artifact is a host-local directory. + await makeRuntime(sdk, { backend: "local", image: undefined, snapshot: "snap-7" }) + .launch({ name: "s1" }); assert.deepEqual(firstArgs(log, "builder.fromSnapshot"), ["snap-7"]); assert.equal(called(log, "builder.image"), false); }); @@ -1088,18 +1205,28 @@ describe("launchDetached", () => { describe("create deadline", () => { it("fails with a typed error when the create deadline elapses", async () => { + // The create is released at the end rather than left pending forever. An + // abandoned scope holder really does hold the process-global backend gate + // — that is a real defect, covered by its own test in "backend gate" — but + // leaving one behind HERE would poison every later different-backend call + // in this process and turn one defect into a suite-wide cascade. + let releaseCreate!: () => void; const { sdk } = createHarness({ - onCreate: () => new Promise(() => {}), + onCreate: () => new Promise((resolve) => { releaseCreate = resolve; }), }); - await assert.rejects( - makeRuntime(sdk).launch({ name: "slow", createTimeoutSeconds: 0.02 }), - (error: unknown) => { - assert.ok(error instanceof MicrosandboxCreateTimeoutError); - assert.equal(error.sandboxName, "slow"); - assert.equal(error.timeoutMs, 20); - return true; - }, - ); + try { + await assert.rejects( + makeRuntime(sdk).launch({ name: "slow", createTimeoutSeconds: 0.02 }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxCreateTimeoutError); + assert.equal(error.sandboxName, "slow"); + assert.equal(error.timeoutMs, 20); + return true; + }, + ); + } finally { + releaseCreate(); + } }); it("never maps the create deadline onto a sandbox LIFETIME budget", async () => { @@ -1383,40 +1510,58 @@ describe("label lookup", () => { assert.deepEqual(handles.map((handle) => handle.id), ["a", "b"]); }); + // All three release their hung listing at the end: see the note on the + // create-deadline test above. The listing genuinely holds the process-global + // backend gate until it settles, which is covered in "backend gate". it("stops draining pages when the lookup deadline elapses", async () => { // Without a deadline a listing that never answers hangs the caller's // request forever, holding a warm-lease decision open indefinitely. + let releaseList!: () => void; const { sdk } = createHarness({ - listWith: () => new Promise(() => {}), + listWith: () => new Promise((resolve) => { releaseList = resolve; }), }); - await assert.rejects( - makeRuntime(sdk).findAllByLabels({}, { timeoutMs: 20 }), - (error: unknown) => { - assert.ok(error instanceof MicrosandboxLookupTimeoutError); - assert.equal(error.timeoutMs, 20); - return true; - }, - ); + try { + await assert.rejects( + makeRuntime(sdk).findAllByLabels({}, { timeoutMs: 20 }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxLookupTimeoutError); + assert.equal(error.timeoutMs, 20); + return true; + }, + ); + } finally { + releaseList(); + } }); it("bounds a count with the same deadline", async () => { + let releaseList!: () => void; const { sdk } = createHarness({ - listWith: () => new Promise(() => {}), + listWith: () => new Promise((resolve) => { releaseList = resolve; }), }); - await assert.rejects( - makeRuntime(sdk).countByLabels({}, { timeoutMs: 20 }), - MicrosandboxLookupTimeoutError, - ); + try { + await assert.rejects( + makeRuntime(sdk).countByLabels({}, { timeoutMs: 20 }), + MicrosandboxLookupTimeoutError, + ); + } finally { + releaseList(); + } }); it("applies the runtime's configured lookup deadline when the caller gives none", async () => { + let releaseList!: () => void; const { sdk } = createHarness({ - listWith: () => new Promise(() => {}), + listWith: () => new Promise((resolve) => { releaseList = resolve; }), }); - await assert.rejects( - makeRuntime(sdk, { lookupTimeoutMs: 20 }).findByLabels({}), - MicrosandboxLookupTimeoutError, - ); + try { + await assert.rejects( + makeRuntime(sdk, { lookupTimeoutMs: 20 }).findByLabels({}), + MicrosandboxLookupTimeoutError, + ); + } finally { + releaseList(); + } }); it("answers a zero maxCount without any SDK call", async () => { @@ -2508,9 +2653,15 @@ describe("reattach", () => { }); describe("capabilities", () => { - it("declares the bootstrap-plane capability set this adapter actually implements", () => { + // The capability set is BACKEND-SENSITIVE, and `snapshots` is the field that + // differs. Microsandbox snapshot artifacts are host-local — the SDK stores + // them under `~/.microsandbox/snapshots//` and `Snapshot.list()` reads + // a local DB cache — so a cloud create cannot source one. Reporting a flat + // `snapshots: true` told a caller on the cloud backend that a boot path + // exists which the provider does not offer. + it("declares snapshot support on the local backend, where the boot path exists", () => { const { sdk } = createHarness(); - assert.deepEqual(makeRuntime(sdk).capabilities, { + assert.deepEqual(makeRuntime(sdk, { backend: "local" }).capabilities, { pty: false, snapshots: true, isolation: "strong", @@ -2519,6 +2670,28 @@ describe("capabilities", () => { }); }); + // Cloud differs on BOTH backend-sensitive fields. `isolation: "unknown"` is + // the honest value: microsandbox's cloud isolation is vendor-documented but + // this adapter measures nothing about it, and publishing "strong" would be + // publishing a guarantee nobody here verified. + it("does NOT declare snapshot support on the cloud backend, and reports isolation as unknown", () => { + const { sdk } = createHarness(); + assert.deepEqual(makeRuntime(sdk).capabilities, { + pty: false, + snapshots: false, + isolation: "unknown", + persistentHandle: true, + streamingLogs: false, + }); + }); + + // Guard against the easy over-correction: "unknown" must not leak onto the + // local backend, where the microVM boot path IS established. + it("keeps strong isolation on the local backend", () => { + const { sdk } = createHarness(); + assert.equal(makeRuntime(sdk, { backend: "local" }).capabilities.isolation, "strong"); + }); + it("declares warm-lease and lifecycle support", () => { const { sdk } = createHarness(); assert.deepEqual(makeRuntime(sdk).declaredCapabilities, { @@ -2546,10 +2719,23 @@ describe("capabilities", () => { } }); - it("backs its snapshots claim with a real snapshot boot path", async () => { + // MUST FIRE: the local claim is backed by a real SDK call, not by the mere + // existence of `fromSnapshot` on the builder type. + it("backs its local snapshots claim with a real snapshot boot path", async () => { const { sdk, log } = createHarness(); - await makeRuntime(sdk, { image: undefined, snapshot: "snap-1" }).launch({ name: "s1" }); + await makeRuntime(sdk, { backend: "local", image: undefined, snapshot: "snap-1" }) + .launch({ name: "s1" }); assert.equal(called(log, "builder.fromSnapshot"), true); + assert.equal(called(log, "builder.image"), false, "a snapshot boot has no image source"); + }); + + // MUST NOT FIRE: a cloud launch never reaches the snapshot boot path, because + // a cloud runtime cannot be configured with a snapshot in the first place. + it("never takes the snapshot boot path on the cloud backend", async () => { + const { sdk, log } = createHarness(); + await makeRuntime(sdk).launch({ name: "s1" }); + assert.equal(called(log, "builder.fromSnapshot"), false); + assert.equal(called(log, "builder.image"), true); }); }); diff --git a/src/microsandbox/runtime.ts b/src/microsandbox/runtime.ts index 09936b4..4d9e0be 100644 --- a/src/microsandbox/runtime.ts +++ b/src/microsandbox/runtime.ts @@ -92,6 +92,11 @@ const MAX_SANDBOX_NAME_BYTES = 128; const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000; const DEFAULT_LIST_PAGE_SIZE = 100; +/** + * How long a call may wait for the process-global backend gate before it fails + * instead of waiting forever. See {@link MicrosandboxBackendBusyError}. + */ +const DEFAULT_BACKEND_QUEUE_TIMEOUT_MS = 30_000; const SCRIPT_LOG_READ_MAX_BYTES = 200_000; /** * Longest encoded run-state path segment before it is replaced by a digest. @@ -407,6 +412,14 @@ export type MicrosandboxRuntimeOptions = { lookupTimeoutMs?: number; /** Page size used when draining label listings. Defaults to 100. */ listPageSize?: number; + /** + * How long a call may wait for the process-global backend gate before failing + * with {@link MicrosandboxBackendBusyError}. Defaults to 30s. + * + * Only meaningful in a process that drives MORE THAN ONE backend: a process + * on a single backend shares one open scope and never queues. + */ + backendQueueTimeoutMs?: number; /** Injection seam for tests / a wrapping adapter — defaults to lazy `import("microsandbox")`. */ sdk?: MicrosandboxSdk; }; @@ -491,6 +504,37 @@ export class MicrosandboxPaginationError extends Error { } } +/** + * A call gave up waiting for the process-global backend gate. + * + * The gate exists because `withDefaultBackend` mutates ONE process-wide slot, + * so a call on backend A cannot run while backend B holds the scope. Normally + * the wait is short. It is NOT short when the scope holder has been abandoned: + * a create or a lookup that outlived its own client-side deadline returned a + * typed error to ITS caller, but the SDK exposes no cancellation, so the + * request is still in flight and the process-wide backend still has to be its + * own until it settles. + * + * Waiting forever in that situation deadlocks every other backend in the + * process. Running anyway would send this call to whatever backend the process + * default happens to hold, which is the one outcome the gate exists to prevent. + * So the queued call FAILS, loudly and with a typed error the caller can retry + * on — the honest third option. + */ +export class MicrosandboxBackendBusyError extends Error { + override readonly name = "MicrosandboxBackendBusyError"; + readonly waitedMs: number; + + constructor(waitedMs: number) { + super( + `Microsandbox backend gate was held by another backend for ${waitedMs}ms; this call was not sent ` + + "rather than being sent to the wrong backend. A scope held this long usually means an SDK call " + + "outlived its client-side deadline and is still in flight.", + ); + this.waitedMs = waitedMs; + } +} + /** * `startScript` was called for a session id that is already admitted for a * DIFFERENT command. @@ -600,6 +644,16 @@ type BackendScope = { let currentBackendScope: BackendScope | null = null; const backendScopeWaiters: Array<() => void> = []; +/** + * Is this runtime bound to the LOCAL backend? + * + * Two capability claims hinge on it — snapshot support and isolation — so it is + * a named predicate rather than an inline `=== "local"` repeated at each site. + */ +function isLocalBackend(backend: MicrosandboxBackend): boolean { + return backend === "local"; +} + /** * Stable identity for a backend value. The API key is hashed rather than * stored, so a long-lived comparison key never holds the secret itself. @@ -619,8 +673,10 @@ async function withBackendScope( sdk: MicrosandboxSdk, backend: MicrosandboxBackend, fn: () => Promise | T, + queueTimeoutMs: number, ): Promise { const key = backendKey(backend); + const queueDeadline = Date.now() + queueTimeoutMs; for (;;) { const open = currentBackendScope; if (open && !open.closing && open.key === key) { @@ -635,9 +691,31 @@ async function withBackendScope( } } if (open) { - await new Promise((resolve) => { - backendScopeWaiters.push(resolve); - }); + // BOUNDED. An abandoned scope holder (a create or lookup that outlived + // its client-side deadline) is still in flight and still owns the + // process-wide backend, so this wait can otherwise never end. + const remainingMs = queueDeadline - Date.now(); + if (remainingMs <= 0) { + throw new MicrosandboxBackendBusyError(queueTimeoutMs); + } + let timer: ReturnType | undefined; + const timedOut = await Promise.race([ + new Promise((resolve) => { + // A stale resolver left here after the race is harmless: the waiter + // list is spliced wholesale on release and resolving a promise + // nobody awaits is a no-op. + backendScopeWaiters.push(() => resolve(false)); + }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), remainingMs); + }), + ]); + if (timer) { + clearTimeout(timer); + } + if (timedOut) { + throw new MicrosandboxBackendBusyError(queueTimeoutMs); + } continue; } @@ -804,17 +882,30 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { * through the port, not everything the SDK can do — the same convention the * Daytona adapter follows. * + * TWO OF THESE ARE BACKEND-SENSITIVE, and both were previously reported as + * flat process-wide constants — which published a claim about the cloud + * backend that this package cannot stand behind. They are now derived from + * the backend this instance is bound to. + * * - `pty: false` — the SDK's `ExecOptionsBuilder.tty(true)` and * `ExecHandle.resize()` are real, but the port has no pty method and this * adapter never allocates one. - * - `snapshots: true` — `launch` can source a sandbox from a snapshot via - * the builder's `fromSnapshot`. - * - `isolation: 'strong'` — microsandbox boots each sandbox as a microVM - * with its own guest Linux kernel. On the LOCAL backend that requires a + * - `snapshots` — LOCAL only. `launch` sources a sandbox from a snapshot via + * the builder's `fromSnapshot`, and what that consumes is a snapshot + * ARTIFACT: a host-local directory (the SDK stores them under + * `~/.microsandbox/snapshots//` and indexes them in a local DB + * cache). A cloud create has no access to that directory, so the cloud + * backend reports `false` and refuses the pairing outright — see the + * constructor. Note the adapter only ever CONSUMES a snapshot; it never + * creates one, so `SandboxHandle.snapshot()` is not on this path. + * - `isolation` — `'strong'` on LOCAL, `'unknown'` on CLOUD. Locally the SDK + * boots each sandbox as a microVM with its own guest kernel on a * virtualization-capable host (KVM on Linux, Apple Silicon on macOS, WHP - * on Windows 10+); on the cloud backend the microVM boots on the - * provider's host instead. Either way the workload does not share this - * process's kernel. + * on Windows 10+), which this package can stand behind. The cloud + * backend's isolation, region placement and resource enforcement are + * vendor-documented but NOT observable from here, and this adapter runs no + * measurement against them — so it reports `'unknown'` rather than + * publishing an unverified `'strong'`. See {@link IsolationLevel}. * - `persistentHandle: true` — a sandbox is re-resolvable by name from a * fresh process via `Sandbox.get(name)` + `connect()`, and * `launchDetached` sets `detached(true)` so it outlives this process. @@ -822,14 +913,13 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { * `execStream`, but this adapter's log path is a durable file read, so * claiming a streaming capability here would be claiming a code path that * does not exist. + * + * NOT SUPPORTED, and deliberately absent rather than silently ignored: custom + * or published PORTS. The SDK builder exposes `port()`/`portBind()`/`portUdp()`, + * but the ports this package targets have no public-port surface to express + * them, so this adapter never calls them and never implies a reachable port. */ - readonly capabilities: RuntimeCapabilities = { - pty: false, - snapshots: true, - isolation: "strong", - persistentHandle: true, - streamingLogs: false, - }; + readonly capabilities: RuntimeCapabilities; /** * Both true, and declared rather than left to default so the reasoning is on @@ -858,6 +948,7 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { private readonly connectTimeoutMs: number; private readonly lookupTimeoutMs: number; private readonly listPageSize: number; + private readonly backendQueueTimeoutMs: number; private readonly injectedSdk?: MicrosandboxSdk; private sdkPromise?: Promise; @@ -883,6 +974,28 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { "MicrosandboxRuntime accepts `image` or `snapshot`, not both: a sandbox has exactly one rootfs source", ); } + // Refused HERE — before the lazy `import("microsandbox")`, before any SDK + // call, before a single byte leaves the process. A snapshot source is a + // local-backend-only configuration, so this is a static fact about the + // pairing rather than something the provider has to be asked about; + // deferring it to `launch` would surface it as an opaque remote failure on + // a path the caller was told existed. + // + // PROVENANCE: that Microsandbox CLOUD does not support snapshot-sourced + // creates is vendor documentation, relayed by the sandbox program lead as a + // release blocker; it is NOT derivable from microsandbox@0.6.11's typings, + // which carry no cloud/local gating on the snapshot surface. What IS + // checkable from the installed package, and what makes the restriction + // coherent, is that a snapshot artifact is host-local: the SDK documents + // them under `~/.microsandbox/snapshots//` and lists them from a + // local DB cache, and a cloud create cannot resolve a host path. + if (options.snapshot && !isLocalBackend(options.backend)) { + throw new Error( + "MicrosandboxRuntime cannot boot from a `snapshot` on the cloud backend: a snapshot artifact is host-local " + + "(the SDK resolves it under ~/.microsandbox/snapshots/), so a cloud create cannot reach it. " + + "Use `image` on the cloud backend, or set `backend: \"local\"` to boot from a snapshot.", + ); + } this.backend = options.backend; if (options.image !== undefined) { this.image = options.image; @@ -906,6 +1019,13 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { if (options.maxDurationSeconds !== undefined) { this.maxDurationSeconds = options.maxDurationSeconds; } + this.capabilities = { + pty: false, + snapshots: isLocalBackend(options.backend), + isolation: isLocalBackend(options.backend) ? "strong" : "unknown", + persistentHandle: true, + streamingLogs: false, + }; this.replaceExisting = options.replaceExisting ?? false; this.namePrefix = options.namePrefix ?? ""; this.runStateDir = (options.runStateDir ?? DEFAULT_RUN_STATE_DIR).replace(/\/+$/, ""); @@ -913,6 +1033,7 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; this.lookupTimeoutMs = options.lookupTimeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS; this.listPageSize = options.listPageSize ?? DEFAULT_LIST_PAGE_SIZE; + this.backendQueueTimeoutMs = options.backendQueueTimeoutMs ?? DEFAULT_BACKEND_QUEUE_TIMEOUT_MS; if (options.sdk !== undefined) { this.injectedSdk = options.sdk; } @@ -1640,7 +1761,7 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { fn: (sdk: MicrosandboxSdk) => Promise | T, ): Promise { const sdk = await this.sdk(); - return withBackendScope(sdk, this.backend, () => fn(sdk)); + return withBackendScope(sdk, this.backend, () => fn(sdk), this.backendQueueTimeoutMs); } private async lookupHandle(name: string): Promise { diff --git a/src/types.ts b/src/types.ts index 796e7ff..2185267 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,15 @@ -export type IsolationLevel = 'none' | 'process' | 'strong'; +/** + * How strongly a runtime isolates a workload from the host. + * + * `'unknown'` is deliberately NOT a synonym for weak. It is for a provider + * whose isolation this package has not established as fact — most often a + * hosted backend whose internals are documented by the vendor but not + * observable from here. Encoding such a case as `'strong'` would publish a + * guarantee nobody verified, and encoding it as `'none'`/`'process'` would + * publish a limitation that may not exist. A caller that requires a specific + * guarantee must treat `'unknown'` as "not established" and decide for itself. + */ +export type IsolationLevel = 'none' | 'process' | 'strong' | 'unknown'; export interface RuntimeCapabilities { pty: boolean; @@ -36,6 +47,15 @@ export interface ExecOptions { export interface ExecResult { output: string; exitCode: number; + /** + * True when `output` is a TAIL of the real output rather than all of it. + * + * Present only when the adapter actually bounds the read. Absent means the + * output is complete — never "unknown". A silently shortened log reads as a + * command that simply printed less, which is the one reading that cannot be + * distinguished from the truth by looking at the value. + */ + truncated?: boolean; } export interface AsyncExecStartResult { From 2e59531e729795055f0378df50275495cf21dc00 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 03:01:36 +0200 Subject: [PATCH 04/10] fix(microsandbox): deregister a backend-gate waiter that times out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by a Veto diff review of 1bf8e2e, and it is a defect that commit introduced. A call that gave up waiting for the process-global backend gate threw `MicrosandboxBackendBusyError` but left its resolver in the module-global `backendScopeWaiters` array. That array is only cleared by `splice(0)` inside `releaseBackendGate` — that is, when a scope RELEASES. The entire reason the bound exists is the case where a scope is wedged by an SDK call that outlived its client-side deadline and never releases, so under sustained load against a wedged gate the waiter list grew without limit. The bound meant to contain one failure mode quietly introduced another. The waiter is now spliced out on the timeout path before the error is thrown. Regression cover asserts the user-visible contract rather than the private array: eight consecutive queue timeouts against a wedged scope all fail with the typed error, and once the holder finally settles the gate still hands over cleanly — which is what a corrupted queue would break. Also from that review, recorded rather than changed: - Widening `IsolationLevel` with `'unknown'` is safe for producers and no in-repo consumer switches on it (only daytona/runtime.ts:110 assigns a value), but it would break a downstream exhaustive switch with a never-typed default. Worth release notes, not a code change. - `{ image: "x", snapshot: "" }` skips the cloud+snapshot guard on falsiness. Traced through: the boot path branches on the same truthiness, so it degrades coherently to an image boot and `capabilities.snapshots` stays correct. Left alone rather than tightening constructor validation for no behavioural gain. Gate re-run, all green: build, typecheck, test (228 tests, 226 pass, 0 fail, 0 cancelled, 2 live-gated skips), npm audit --omit=dev, git diff --check, npm pack --dry-run. Co-Authored-By: Claude Opus 5 Session-Id: 74382151-b126-43b1-98c1-954100d81198 Session-Id: 6d810ec8-fbbc-43f2-8ef2-bc87744ca04f --- src/microsandbox/runtime.test.ts | 39 ++++++++++++++++++++++++++++++++ src/microsandbox/runtime.ts | 21 ++++++++++++----- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts index bff698c..0616cde 100644 --- a/src/microsandbox/runtime.test.ts +++ b/src/microsandbox/runtime.test.ts @@ -853,6 +853,45 @@ describe("backend gate (the scope is process-global)", () => { assert.equal((await queued).id, "local-1"); }); + it("stays correct after repeated queue timeouts against a wedged scope", async () => { + // Every timed-out waiter must deregister itself. The waiter list is only + // spliced wholesale when a scope RELEASES, and the case this bound exists + // for is the one where no release ever comes — so a waiter left behind + // would accumulate without limit. Behaviourally: repeated timeouts must not + // corrupt the queue, and the gate must still work once the holder settles. + let creates = 0; + let finishFirstCreate!: () => void; + const { sdk } = createHarness({ + onCreate: () => { + creates += 1; + return creates > 1 + ? Promise.resolve() + : new Promise((resolve) => { + finishFirstCreate = resolve; + }); + }, + }); + const cloud = makeRuntime(sdk, { backendQueueTimeoutMs: 20 }); + const local = makeRuntime(sdk, { backend: "local", backendQueueTimeoutMs: 20 }); + + await assert.rejects( + cloud.launch({ name: "slow", createTimeoutSeconds: 0.02 }), + MicrosandboxCreateTimeoutError, + ); + for (let attempt = 0; attempt < 8; attempt += 1) { + await assert.rejects( + local.launch({ name: `q-${attempt}` }), + MicrosandboxBackendBusyError, + ); + } + + // The holder finally settles: the gate must hand over cleanly, proving the + // queue was not left in a corrupt state by the eight timeouts. + finishFirstCreate(); + const handle = await local.launch({ name: "after" }); + assert.equal(handle.id, "after"); + }); + it("keeps interleaved two-runtime static calls on their own backend", async () => { // The discriminating one. `withDefaultBackend` mutates ONE process-wide // slot; this fake models exactly that slot, and every DEFAULT-DEPENDENT diff --git a/src/microsandbox/runtime.ts b/src/microsandbox/runtime.ts index 4d9e0be..23543cc 100644 --- a/src/microsandbox/runtime.ts +++ b/src/microsandbox/runtime.ts @@ -699,13 +699,13 @@ async function withBackendScope( throw new MicrosandboxBackendBusyError(queueTimeoutMs); } let timer: ReturnType | undefined; + let wake!: () => void; + const woken = new Promise((resolve) => { + wake = () => resolve(false); + }); + backendScopeWaiters.push(wake); const timedOut = await Promise.race([ - new Promise((resolve) => { - // A stale resolver left here after the race is harmless: the waiter - // list is spliced wholesale on release and resolving a promise - // nobody awaits is a no-op. - backendScopeWaiters.push(() => resolve(false)); - }), + woken, new Promise((resolve) => { timer = setTimeout(() => resolve(true), remainingMs); }), @@ -714,6 +714,15 @@ async function withBackendScope( clearTimeout(timer); } if (timedOut) { + // Deregister. The waiter list is only spliced wholesale when a scope + // releases, so a timed-out waiter left behind would never be collected + // — and the case this bound exists for is precisely the one where no + // release ever comes, which would grow the list without limit under + // sustained load. + const queued = backendScopeWaiters.indexOf(wake); + if (queued !== -1) { + backendScopeWaiters.splice(queued, 1); + } throw new MicrosandboxBackendBusyError(queueTimeoutMs); } continue; From e109a92af8b8a05dafce77d1cae2fbd8c75beabf Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 03:48:10 +0200 Subject: [PATCH 05/10] fix(microsandbox): make the backend gate ordered, cancellable and honestly poisoned The gate serialises the SDK's one process-wide default-backend slot. Four properties it claimed were not actually implemented, and one of them was covered by a test that passed against the bug. Strict FIFO. Releasing the gate woke EVERY waiter by splicing the queue empty, so which one took the gate was decided by microtask scheduling, and emptying the queue also destroyed the fact the starvation guard reads: a second same-backend waiter re-checked, saw "nobody is waiting", and joined the first one's scope. The gate is now handed to one named waiter at a time via an explicit reservation, and a waiter that times out while holding that reservation passes it on rather than wedging the gate on a caller that left. Poison that cannot be silently un-set. A failed RESTORE leaves the process default holding an unknown value, so it poisons the gate permanently. The poison was recorded as the rejection reason and detected by comparing that reason against null, which collapsed on exactly the rejections carrying no value -- reject(null), Promise.reject(). It is now a dedicated flag with the cause kept beside it. Every participant hears a failed restore. Same-backend callers share one scope, so they share its restore, but only the last one out awaited it: a caller that finished earlier reported clean success from a scope that then failed to restore. Early leavers now observe the shared outcome too. This cannot deadlock -- the leaver decrements before awaiting, so the count it waits on no longer includes itself. Admission cancellation. Racing a timer against a gated call is not cancellation: a lookup that gave up while queued was still queued, and could be admitted later and issue a static long after its caller stopped waiting. The overall deadline now withdraws the request from the queue. Tests. The waiter-leak regression asserted only behaviour, which is identical whether or not a timed-out waiter deregisters, so it passed against the leak it was written for. It now asserts the queue length through a test-only @internal probe. Each of the four fixes above was verified by reintroducing the defect and confirming the new test fails. Also removes shellSingleQuote, dead since the log read moved from an interpolated shell string to a script invoked with positional argv, and drops the last unverifiable vendor-provenance claim from the capability notes: snapshots and isolation are now justified only by adapter contract and host-local artifact behaviour. Co-Authored-By: Claude Opus 5 Session-Id: 0f9ad78b-2fa1-4d00-b01b-ceda2d7eb95b Session-Id: 6d810ec8-fbbc-43f2-8ef2-bc87744ca04f --- README.md | 14 +- src/index.ts | 5 + src/microsandbox/runtime.test.ts | 1378 +++++++++++++++++++++++++++++- src/microsandbox/runtime.ts | 1085 +++++++++++++++++++---- src/port.ts | 10 + 5 files changed, 2293 insertions(+), 199 deletions(-) diff --git a/README.md b/README.md index 39f122f..0d2dcf4 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,14 @@ bound to, not reported as a single process-wide constant: | Capability | `local` | `cloud` | Why | | --- | --- | --- | --- | -| `snapshots` | `true` | `false` | A snapshot source is a host-local artifact — the SDK resolves it under `~/.microsandbox/snapshots/` and indexes it in a local DB cache — so a cloud create cannot reach one. Configuring `snapshot` with a cloud backend is refused in the constructor, before any SDK call. | -| `isolation` | `'strong'` | `'unknown'` | Locally the SDK boots a microVM with its own guest kernel on a virtualization-capable host, which this package can stand behind. The cloud backend's isolation is vendor-documented but not observable from here, and this adapter measures nothing about it. | - -`'unknown'` is not a synonym for weak. It means this package has not established -the guarantee, so a caller that requires one must decide for itself rather than -read an unverified `'strong'`. +| `snapshots` | `true` | `false` | A snapshot is a host-local artifact: the installed SDK's typings describe `Snapshot` as an artifact on disk and resolve one under `~/.microsandbox/snapshots//`. This adapter consumes such an artifact from the calling host and never transfers it, so a create issued against a remote backend has nothing to resolve. Configuring `snapshot` with a cloud backend is refused in the constructor, before any SDK call. | +| `isolation` | `'strong'` | `'unknown'` | Locally the SDK boots a microVM with its own guest kernel on a virtualization-capable host, and the installed package states that requirement itself, so `'strong'` rests on something checkable here. This adapter observes and measures nothing about the cloud backend's isolation. | + +Both values describe what this package has **established**, not what any +provider documents. `'unknown'` is not a synonym for weak and is not a claim +that the guarantee is missing — it means this package has not established one, +so a caller that requires a specific guarantee must decide for itself rather +than read an unverified `'strong'`. Cloud region placement and resource enforcement are likewise not represented as measured facts. Custom or published **ports are not supported**: the SDK builder diff --git a/src/index.ts b/src/index.ts index f195965..c29ff1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,14 +75,19 @@ export type { export { MicrosandboxBackendBusyError, + MicrosandboxBackendPoisonedError, MicrosandboxCreateTimeoutError, + MicrosandboxLogReadError, MicrosandboxLookupTimeoutError, MicrosandboxNameTooLongError, MicrosandboxPaginationError, MicrosandboxRunLostError, MicrosandboxRunNotFinishedError, + MicrosandboxRunTimeoutUnsupportedError, MicrosandboxRuntime, MicrosandboxSessionConflictError, + MicrosandboxStatusProbeError, + MicrosandboxUnknownOutcomeError, } from "./microsandbox/runtime.js"; export type { MicrosandboxBackend, diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts index 0616cde..51d445e 100644 --- a/src/microsandbox/runtime.test.ts +++ b/src/microsandbox/runtime.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { Buffer } from "node:buffer"; import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, describe, it } from "node:test"; @@ -9,18 +10,26 @@ import { after, before, describe, it } from "node:test"; import * as pkg from "../index.js"; import { MicrosandboxBackendBusyError, + MicrosandboxBackendPoisonedError, MicrosandboxCreateTimeoutError, + MicrosandboxLogReadError, MicrosandboxLookupTimeoutError, MicrosandboxNameTooLongError, MicrosandboxPaginationError, MicrosandboxRunLostError, MicrosandboxRunNotFinishedError, + MicrosandboxRunTimeoutUnsupportedError, MicrosandboxRuntime, MicrosandboxSessionConflictError, + MicrosandboxStatusProbeError, + MicrosandboxUnknownOutcomeError, resolveSandboxRuntimeCapabilities, } from "../index.js"; import { + __backendGateWaiterCountForTests, + __resetBackendGateForTests, MICROSANDBOX_RUN_ADMIT_SCRIPT, + MICROSANDBOX_RUN_LOG_SCRIPT, MICROSANDBOX_RUN_STATUS_SCRIPT, } from "./runtime.js"; import type { @@ -46,7 +55,17 @@ const CLOUD_BACKEND: MicrosandboxBackend = { kind: "cloud", apiKey: "k-test-not- type LogEntry = { fn: string; args: unknown[] }; -type ExecOutcome = { code?: number; stdout?: string; stderr?: string }; +type ExecOutcome = { + code?: number; + stdout?: string; + stderr?: string; + /** + * Model an SDK result that carries NO usable exit code. Real providers do + * this on a dropped or partially-read result, and it is the case the adapter + * used to paper over with `?? 0`. + */ + unknownCode?: boolean; +}; type SandboxStatus = "running" | "stopped" | "crashed" | "draining"; @@ -73,6 +92,12 @@ type HarnessConfig = { * The last entry repeats once the list is exhausted. */ rawPages?: unknown[]; + /** + * Pages generated from the page index, for listings too long to enumerate — + * an endless one, or a cursor walk that revisits an earlier page. Takes + * precedence over `rawPages` and `pages`. + */ + pageAt?: (index: number) => unknown; /** `Sandbox.get` resolution, by name. */ get?: (name: string) => HandleSpec | null | Promise; /** Delay/hang injected into `Sandbox.get`. */ @@ -178,14 +203,22 @@ function createGuest() { if (script === MICROSANDBOX_RUN_STATUS_SCRIPT) { return status(argv[3] ?? ""); } - const tailed = /^tail -c \d+ '(.*)'/.exec(script); - if (tailed) { - const path = tailed[1] ?? ""; + if (script === MICROSANDBOX_RUN_LOG_SCRIPT) { + const path = argv[3] ?? ""; + const cap = Number.parseInt(argv[4] ?? "0", 10); for (const [dir, run] of runs) { if (path === `${dir}/out`) { - return { stdout: run.output }; + // `tail -c cap`: the LAST `cap` bytes, so the adapter sees more + // than its own limit exactly when the log is longer than it. + const bytes = Buffer.from(run.output, "utf8"); + const tail = bytes.byteLength <= cap + ? bytes + : bytes.subarray(bytes.byteLength - cap); + return { stdout: tail.toString("utf8") }; } } + // No such run directory: the log file is genuinely absent, which the + // script reports as success with no output. return { stdout: "" }; } return {}; @@ -411,6 +444,11 @@ function createHarness(config: HarnessConfig = {}) { rec("Sandbox.listWith"); configure(listBuilder as never); await config.listWith?.(); + if (config.pageAt) { + const generated = config.pageAt(pageIndex); + pageIndex += 1; + return generated as never; + } if (config.rawPages) { const raw = config.rawPages[Math.min(pageIndex, config.rawPages.length - 1)]; pageIndex += 1; @@ -447,7 +485,7 @@ function createHarness(config: HarnessConfig = {}) { } function makeOutput(outcome: ExecOutcome) { - const code = outcome.code ?? 0; + const code = outcome.unknownCode ? (undefined as unknown as number) : outcome.code ?? 0; return { code, success: code === 0, @@ -853,12 +891,76 @@ describe("backend gate (the scope is process-global)", () => { assert.equal((await queued).id, "local-1"); }); + it("deregisters every timed-out waiter instead of leaking it", async () => { + // THE LEAK IS INVISIBLE FROM OUTSIDE, which is why this test reaches for + // the queue length rather than for behaviour. A waiter that timed out + // reports the same typed `MicrosandboxBackendBusyError` whether or not it + // took itself off the queue, and the gate keeps working either way — so a + // purely behavioural test passes against the bug it is meant to catch. + // The only signal that discriminates is the size of the queue itself. + let creates = 0; + let finishFirstCreate!: () => void; + const { sdk } = createHarness({ + onCreate: () => { + creates += 1; + return creates > 1 + ? Promise.resolve() + : new Promise((resolve) => { + finishFirstCreate = resolve; + }); + }, + }); + __resetBackendGateForTests(); + const cloud = makeRuntime(sdk, { backendQueueTimeoutMs: 20 }); + const local = makeRuntime(sdk, { backend: "local", backendQueueTimeoutMs: 5_000 }); + try { + // A holder that never settles: the case the queue bound exists for, and + // the case in which no release ever comes to sweep the queue. + const wedged = cloud.launch({ name: "wedged" }); + await waitFor(() => creates === 1, "the holder to enter the scope"); + + // While one caller is genuinely queued, the queue must show exactly one. + const parked = local.launch({ name: "parked" }); + await waitFor( + () => __backendGateWaiterCountForTests() === 1, + "the local call to register as a waiter", + ); + + // Now drive eight waiters to their timeout against that wedged holder. + const shortLived = makeRuntime(sdk, { backend: "local", backendQueueTimeoutMs: 20 }); + for (let attempt = 0; attempt < 8; attempt += 1) { + await assert.rejects( + shortLived.launch({ name: `q-${attempt}` }), + MicrosandboxBackendBusyError, + ); + } + + // THE ASSERTION THAT FAILS AGAINST THE BUG. With the timed-out waiters + // left behind, the queue holds the one live waiter plus all eight + // corpses; only deregistration brings it back to one. + assert.equal( + __backendGateWaiterCountForTests(), + 1, + "eight timed-out waiters must have left the queue, leaving only the live one", + ); + + finishFirstCreate(); + await wedged; + assert.equal((await parked).id, "parked"); + assert.equal( + __backendGateWaiterCountForTests(), + 0, + "a drained gate must hold no waiters at all", + ); + } finally { + __resetBackendGateForTests(); + } + }); + it("stays correct after repeated queue timeouts against a wedged scope", async () => { - // Every timed-out waiter must deregister itself. The waiter list is only - // spliced wholesale when a scope RELEASES, and the case this bound exists - // for is the one where no release ever comes — so a waiter left behind - // would accumulate without limit. Behaviourally: repeated timeouts must not - // corrupt the queue, and the gate must still work once the holder settles. + // The behavioural companion to the leak test above: repeated timeouts must + // not corrupt the queue, and the gate must still work once the holder + // settles. let creates = 0; let finishFirstCreate!: () => void; const { sdk } = createHarness({ @@ -1086,6 +1188,549 @@ describe("backend gate (the scope is process-global)", () => { }); }); +describe("backend gate fairness (a queue that cannot be jumped)", () => { + // The gate's own documentation claimed this property before the code + // implemented it: "a newly arriving same-backend call joins only when nobody + // is already waiting". Without that condition, a same-backend call joins the + // OPEN scope no matter who is queued behind it, and a process with steady + // traffic on one backend never lets the other one run at all. + + it("makes a same-backend call queue behind a waiter on another backend", async () => { + const order: string[] = []; + let releaseFirst!: () => void; + const firstCreate = new Promise((resolve) => { + releaseFirst = resolve; + }); + let creates = 0; + const { sdk } = createHarness({ + onCreate: () => { + creates += 1; + return creates === 1 ? firstCreate : Promise.resolve(); + }, + }); + const cloud = makeRuntime(sdk); + const local = makeRuntime(sdk, { backend: "local" }); + + // Cloud holds the scope. + const holder = cloud.launch({ name: "cloud-holder" }).then(() => order.push("cloud-holder")); + await waitFor(() => creates === 1, "the first create to enter the scope"); + + // Local queues: a different backend cannot share the open scope. + const queued = local.launch({ name: "local-queued" }).then(() => order.push("local-queued")); + await waitFor(() => true, "the local call to reach the queue"); + // Give the queued call a turn of the loop to actually register as a waiter. + await new Promise((resolve) => setTimeout(resolve, 10)); + + // A SECOND cloud call now arrives. It wants the backend that is already + // open — the exact call that used to jump the queue. + const jumper = cloud.launch({ name: "cloud-jumper" }).then(() => order.push("cloud-jumper")); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(creates, 1, "the late same-backend call must not have joined the open scope"); + + releaseFirst(); + await Promise.all([holder, queued, jumper]); + assert.equal( + order.indexOf("local-queued") < order.indexOf("cloud-jumper"), + true, + `the earlier waiter must go first, got: ${order.join(" → ")}`, + ); + }); + + it("does not starve the other backend under a steady same-backend stream", async () => { + // The discriminating shape: work on one backend keeps arriving while a + // call on the other is waiting. With queue-jumping, the waiter's own + // 30s budget expires and it fails; the point of FIFO is that it does not. + let gate!: () => void; + const firstCreate = new Promise((resolve) => { + gate = resolve; + }); + let creates = 0; + const { sdk } = createHarness({ + onCreate: () => { + creates += 1; + return creates === 1 ? firstCreate : Promise.resolve(); + }, + }); + const cloud = makeRuntime(sdk, { backendQueueTimeoutMs: 2_000 }); + const local = makeRuntime(sdk, { backend: "local", backendQueueTimeoutMs: 2_000 }); + + const holder = cloud.launch({ name: "holder" }); + await waitFor(() => creates === 1, "the holder to enter the scope"); + const starved = local.launch({ name: "starved" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Twenty more cloud calls pile in while local waits. + const stream = Array.from({ length: 20 }, (_unused, index) => + cloud.launch({ name: `stream-${index}` })); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(creates, 1, "none of the stream may have joined past the waiter"); + + gate(); + // Local gets through on its own merits, not on its timeout. + assert.equal((await starved).id, "starved"); + await Promise.all([holder, ...stream]); + }); + + it("still lets same-backend calls share one scope when nobody is waiting", async () => { + // The guard against over-correcting: fairness must not have serialized the + // common case, which is a single-backend process. + let releaseBoth!: () => void; + const held = new Promise((resolve) => { + releaseBoth = resolve; + }); + let creates = 0; + const { sdk, log } = createHarness({ + onCreate: () => { + creates += 1; + return held; + }, + }); + const cloud = makeRuntime(sdk); + const first = cloud.launch({ name: "a" }); + await waitFor(() => creates === 1, "the first create"); + const second = cloud.launch({ name: "b" }); + await waitFor(() => creates === 2, "the second create to run concurrently in the same scope"); + releaseBoth(); + await Promise.all([first, second]); + // One scope entered, one exited: they shared it rather than taking turns. + assert.equal( + log.filter((entry) => entry.fn === "withDefaultBackend:enter").length, + 1, + names(log).join(", "), + ); + }); +}); + +describe("backend gate handoff (FIFO that a scheduler cannot re-order)", () => { + it("admits queued callers in strict arrival order", async () => { + // THE DISCRIMINATING SHAPE. Waking every waiter at once and letting them + // re-race leaves the winner to microtask scheduling: all of them see a + // free gate, and the one that happens to be resumed first takes it. With + // three waiters on three DIFFERENT backends — so none of them can join + // another's scope — only a gate that hands over to one named waiter at a + // time produces arrival order every run. + const order: string[] = []; + let releaseHolder!: () => void; + const holderCall = new Promise((resolve) => { + releaseHolder = resolve; + }); + let creates = 0; + const { sdk } = createHarness({ + onCreate: () => { + creates += 1; + return creates === 1 ? holderCall : Promise.resolve(); + }, + }); + __resetBackendGateForTests(); + try { + const holder = makeRuntime(sdk).launch({ name: "holder" }); + await waitFor(() => creates === 1, "the holder to enter the scope"); + + // Each on its own backend, so every one of them must open its own scope. + const first = makeRuntime(sdk, { backend: "local" }) + .launch({ name: "first" }) + .then(() => order.push("first")); + await waitFor(() => __backendGateWaiterCountForTests() === 1, "the first waiter to queue"); + const second = makeRuntime(sdk, { backend: { profile: "p2" } }) + .launch({ name: "second" }) + .then(() => order.push("second")); + await waitFor(() => __backendGateWaiterCountForTests() === 2, "the second waiter to queue"); + const third = makeRuntime(sdk, { backend: { profile: "p3" } }) + .launch({ name: "third" }) + .then(() => order.push("third")); + await waitFor(() => __backendGateWaiterCountForTests() === 3, "the third waiter to queue"); + + releaseHolder(); + await Promise.all([holder, first, second, third]); + assert.deepEqual( + order, + ["first", "second", "third"], + `queued callers must run in arrival order, got: ${order.join(" → ")}`, + ); + } finally { + __resetBackendGateForTests(); + } + }); + + it("does not let a waking waiter be mistaken for an empty queue", async () => { + // THE TEST THAT SEPARATES A TICKET HANDOFF FROM WAKING EVERYONE. Emptying + // the queue to wake it — `splice(0)` — destroys the very fact the + // starvation guard reads: with the queue momentarily empty, the second + // same-backend waiter re-checks, sees "nobody is waiting", and joins the + // first one's scope. Handing the gate to ONE waiter leaves the rest + // queued, so the second opens its own scope in its own turn. + // + // Counting scope entries is what makes the difference observable: two + // separate turns produce two entries, a queue-jumping join produces one. + let releaseHolder!: () => void; + const holderCall = new Promise((resolve) => { + releaseHolder = resolve; + }); + let creates = 0; + const { sdk, log } = createHarness({ + onCreate: () => { + creates += 1; + return creates === 1 ? holderCall : Promise.resolve(); + }, + }); + __resetBackendGateForTests(); + try { + const holder = makeRuntime(sdk).launch({ name: "holder" }); + await waitFor(() => creates === 1, "the holder to enter the scope"); + + // Two waiters that want the SAME backend as each other, both queued + // behind a holder on a different one. + const local = makeRuntime(sdk, { backend: "local" }); + const first = local.launch({ name: "first" }); + await waitFor(() => __backendGateWaiterCountForTests() === 1, "the first waiter to queue"); + const second = local.launch({ name: "second" }); + await waitFor(() => __backendGateWaiterCountForTests() === 2, "the second waiter to queue"); + + releaseHolder(); + await Promise.all([holder, first, second]); + + assert.equal( + log.filter((entry) => entry.fn === "withDefaultBackend:enter").length, + 3, + "a queued waiter is not 'nobody waiting': each must take its own turn", + ); + } finally { + __resetBackendGateForTests(); + } + }); + + it("passes the turn on when the caller it was promised to has already timed out", async () => { + // The reservation is what makes the handoff ordered, so a waiter that + // times out in the same tick it is promoted must give the turn back. If it + // does not, the gate stays reserved for a caller that has left and nothing + // ever runs again. + let releaseHolder!: () => void; + const holderCall = new Promise((resolve) => { + releaseHolder = resolve; + }); + let creates = 0; + const { sdk } = createHarness({ + onCreate: () => { + creates += 1; + return creates === 1 ? holderCall : Promise.resolve(); + }, + }); + __resetBackendGateForTests(); + try { + const holder = makeRuntime(sdk).launch({ name: "holder" }); + await waitFor(() => creates === 1, "the holder to enter the scope"); + + // A waiter whose budget expires while the holder is still wedged. + const doomed = assert.rejects( + makeRuntime(sdk, { backend: "local", backendQueueTimeoutMs: 20 }) + .launch({ name: "doomed" }), + MicrosandboxBackendBusyError, + ); + // And one that is still there when the gate frees. + const survivor = makeRuntime(sdk, { + backend: { profile: "survivor" }, + backendQueueTimeoutMs: 60_000, + }).launch({ name: "survivor" }); + await waitFor(() => __backendGateWaiterCountForTests() >= 1, "the waiters to queue"); + await doomed; + + releaseHolder(); + await holder; + // The gate was not left reserved for the caller that gave up. + assert.equal((await survivor).id, "survivor"); + assert.equal(__backendGateWaiterCountForTests(), 0); + } finally { + __resetBackendGateForTests(); + } + }); +}); + +describe("backend gate admission cancellation (a deadline that withdraws the request)", () => { + it("never issues a queued static once the overall deadline has expired", async () => { + // Racing a timer against the operation is not cancellation. The gate is a + // queue, so a lookup that gives up while queued is still queued — it can + // be admitted later and issue `listWith` against the process default long + // after the caller stopped waiting for the answer. The signal is what + // actually withdraws it. + let releaseHolder!: () => void; + const holderCall = new Promise((resolve) => { + releaseHolder = resolve; + }); + let creates = 0; + const { sdk, log } = createHarness({ + onCreate: () => { + creates += 1; + return creates === 1 ? holderCall : Promise.resolve(); + }, + pages: [{ sandboxes: [{ name: "warm", labels: { a: "b" } }] }], + }); + __resetBackendGateForTests(); + try { + const holder = makeRuntime(sdk).launch({ name: "holder" }); + await waitFor(() => creates === 1, "the holder to enter the scope"); + + // Its whole budget expires while it is stuck behind the wedged holder. + await assert.rejects( + makeRuntime(sdk, { backend: "local", backendQueueTimeoutMs: 60_000 }) + .findAllByLabels({ a: "b" }, { timeoutMs: 30 }), + MicrosandboxLookupTimeoutError, + ); + assert.equal( + __backendGateWaiterCountForTests(), + 0, + "a cancelled lookup must leave the queue rather than stay admitted", + ); + + // THE ASSERTION THAT FAILS AGAINST THE BUG: releasing the gate must not + // let the abandoned lookup through afterwards. + releaseHolder(); + await holder; + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual( + log.filter((entry) => entry.fn === "Sandbox.listWith"), + [], + "a lookup cancelled by its deadline must never reach the SDK", + ); + } finally { + __resetBackendGateForTests(); + } + }); +}); + +describe("backend gate poisoning (a restore that failed)", () => { + // `withDefaultBackend` sets one process-global slot and restores it on the + // way out. When the RESTORE is what failed, the slot holds an unknown value. + // Swallowing that — which is what the adapter used to do, because its + // rejection handler could not tell a failed entry from a failed exit — hands + // the gate to the next backend as though the previous one had been cleanly + // restored, and that call then runs against whatever the slot actually holds. + + /** An SDK whose scope RUNS the callback and then fails to restore. */ + function restoreFailingSdk(base: MicrosandboxSdk, error: Error): MicrosandboxSdk { + return { + Sandbox: base.Sandbox, + async withDefaultBackend(_backend, fn) { + await fn(); + throw error; + }, + }; + } + + it("propagates the restore failure instead of completing quietly", async () => { + const { sdk } = createHarness({ get: (name) => ({ name }) }); + const failing = restoreFailingSdk(sdk, new Error("could not pop the backend scope")); + try { + await assert.rejects( + makeRuntime(failing).getById("s1"), + /could not pop the backend scope/, + ); + } finally { + __resetBackendGateForTests(); + } + }); + + it("refuses every later backend-dependent static, with a typed error", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const failing = restoreFailingSdk(sdk, new Error("restore exploded")); + try { + await assert.rejects(makeRuntime(failing).getById("s1"), /restore exploded/); + + // A DIFFERENT runtime, on a DIFFERENT backend, sharing the same process + // — which is the whole reason the poison is module-scoped rather than + // per-instance. The damage is to the SDK's process-global slot. + const healthy = makeRuntime(sdk, { backend: "local" }); + const before = log.length; + await assert.rejects( + healthy.getById("s2"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxBackendPoisonedError); + assert.match(error.message, /restore exploded/); + return true; + }, + ); + // And it refused WITHOUT issuing the call: no static reached the SDK. + assert.deepEqual(log.slice(before), [], "a poisoned gate must not call the SDK at all"); + + await assert.rejects(healthy.launch({ name: "x" }), MicrosandboxBackendPoisonedError); + await assert.rejects(healthy.findAllByLabels({ a: "b" }), MicrosandboxBackendPoisonedError); + await assert.rejects(healthy.countByLabels({ a: "b" }), MicrosandboxBackendPoisonedError); + } finally { + __resetBackendGateForTests(); + } + }); + + it("frees a queued waiter immediately rather than making it wait out its budget", async () => { + // Poisoning while somebody is queued must convert their wait into a typed + // refusal now, not leave them parked until the queue timeout. + let releaseHolder!: () => void; + const holderCall = new Promise((resolve) => { + releaseHolder = resolve; + }); + let gets = 0; + const { sdk } = createHarness({ + get: (name) => ({ name }), + onGet: () => { + gets += 1; + return gets === 1 ? holderCall : Promise.resolve(); + }, + }); + const failing = restoreFailingSdk(sdk, new Error("restore failed under load")); + try { + const holder = assert.rejects(makeRuntime(failing).getById("held"), /restore failed under load/); + await waitFor(() => gets === 1, "the holder to enter the scope"); + // A different backend queues behind it, with a budget far longer than + // this test is willing to wait. + const queued = makeRuntime(failing, { + backend: "local", + backendQueueTimeoutMs: 60_000, + }).getById("queued"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + releaseHolder(); + await holder; + await assert.rejects(queued, MicrosandboxBackendPoisonedError); + } finally { + __resetBackendGateForTests(); + } + }); + + it("poisons the gate even when the restore rejected with no value at all", async () => { + // THE SENTINEL BUG. Recording the rejection reason and treating "no reason + // recorded" as "not poisoned" collapses on exactly the rejections that + // carry nothing — `Promise.reject()`, `reject(null)`. Those destroy the + // process default just as thoroughly as a rejection with an `Error`, and a + // gate that reads its own poison out of the cause silently reopens. + for (const reason of [null, undefined]) { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const valueless: MicrosandboxSdk = { + Sandbox: sdk.Sandbox, + async withDefaultBackend(_backend, fn) { + await fn(); + throw reason; + }, + }; + __resetBackendGateForTests(); + try { + // The participant that closed the scope is told, even though there is + // nothing to tell it beyond the fact of the failure. + await assert.rejects(makeRuntime(valueless).getById("s1")); + + const healthy = makeRuntime(sdk, { backend: "local" }); + const before = log.length; + await assert.rejects(healthy.getById("s2"), MicrosandboxBackendPoisonedError); + assert.deepEqual( + log.slice(before), + [], + `a gate poisoned by a ${String(reason)} rejection must not call the SDK`, + ); + } finally { + __resetBackendGateForTests(); + } + } + }); + + it("tells every participant in a shared scope that the restore failed", async () => { + // Same-backend callers share ONE scope, so they share its restore. The + // participant that happens to finish first has historically been allowed + // to return success and walk away — but the scope it ran in went on to + // fail its restore, so it was told the call completed cleanly when the + // process default it ran against is now unknown. Both must hear it. + let releaseEarly!: () => void; + let releaseLate!: () => void; + const earlyCall = new Promise((resolve) => { + releaseEarly = resolve; + }); + const lateCall = new Promise((resolve) => { + releaseLate = resolve; + }); + let gets = 0; + const { sdk } = createHarness({ + get: (name) => ({ name }), + onGet: () => { + gets += 1; + // Both callers are held inside the SAME scope until the test lets them + // out, one at a time — which is the only way to get a genuine early + // leaver rather than two scopes opened back to back. + return gets === 1 ? earlyCall : lateCall; + }, + }); + const failing: MicrosandboxSdk = { + Sandbox: sdk.Sandbox, + async withDefaultBackend(_backend, fn) { + await fn(); + throw new Error("restore failed for the shared scope"); + }, + }; + __resetBackendGateForTests(); + try { + const runtime = makeRuntime(failing); + // Rejection handlers are attached NOW: these settle while the test is + // still awaiting other things, and an unhandled rejection in between + // would be reported against whichever test happens to be running. + const early = runtime.getById("early").then(() => "resolved" as const, (error: unknown) => error); + await waitFor(() => gets === 1, "the first caller to enter the scope"); + const late = runtime.getById("late").then(() => "resolved" as const, (error: unknown) => error); + await waitFor(() => gets === 2, "the second caller to join the same scope"); + + // The early leaver finishes its own call first, while the scope stays + // open for the caller still inside it. + releaseEarly(); + releaseLate(); + + // THE ASSERTION THAT FAILS AGAINST THE BUG: the early leaver used to + // resolve here, reporting a clean success from a scope whose restore + // failed. + const earlyOutcome = await early; + assert.notEqual( + earlyOutcome, + "resolved", + "the early leaver must not report success from a scope that failed to restore", + ); + assert.match(String((earlyOutcome as Error).message), /restore failed for the shared scope/); + const lateOutcome = await late; + assert.notEqual(lateOutcome, "resolved"); + } finally { + __resetBackendGateForTests(); + } + }); + + it("leaves an already-resolved sandbox instance usable", async () => { + // The SDK binds each sandbox to the backend it was resolved on ("backend + // retained by this sandbox"), so instance calls read no global state. The + // poison must not spread to them — refusing work that is provably safe is + // its own kind of wrong answer. + const { sdk } = createHarness({ get: (name) => ({ name }), exec: () => ({ code: 0, stdout: "hi" }) }); + const runtime = makeRuntime(sdk); + const handle = await runtime.launch({ name: "bound" }); + const failing = restoreFailingSdk(sdk, new Error("restore failed later")); + try { + await assert.rejects(makeRuntime(failing).getById("other"), /restore failed later/); + // The instance resolved BEFORE the poison still execs. + const result = await runtime.exec(handle, "echo hi"); + assert.deepEqual(result, { output: "hi", exitCode: 0 }); + } finally { + __resetBackendGateForTests(); + } + }); + + it("does not poison the gate when the scope failed to open in the first place", async () => { + // The other half of the distinction. A rejection BEFORE the callback ran + // means the process default was never changed, so the gate is still + // trustworthy — and an existing test already proves the runtime stays + // usable after one. This asserts the poison specifically stays clear. + const { sdk } = createHarness({ get: (name) => ({ name }) }); + const neverEnters: MicrosandboxSdk = { + Sandbox: sdk.Sandbox, + async withDefaultBackend() { + throw new Error("could not push the backend scope"); + }, + }; + await assert.rejects(makeRuntime(neverEnters).getById("s1"), /could not push/); + // Same process, healthy SDK: works, and is NOT a poisoned-gate error. + assert.equal((await makeRuntime(sdk).getById("s1"))?.id, "s1"); + }); +}); + describe("sandbox naming (identity is a name, not a server id)", () => { it("uses the caller's name as the handle id", async () => { const { sdk, log } = createHarness(); @@ -1700,6 +2345,206 @@ describe("pagination fails closed", () => { }); }); +describe("pagination cycles, bounds and unusable entries", () => { + const started = (name: string) => ({ name, status: "running" as const }); + + it("refuses a cursor walk that revisits a page it already served", async () => { + // A → B → A. Every individual step ADVANCES, so comparing each cursor only + // against the one just used sees progress forever while re-serving the same + // two pages: the drain ends on the safety bound at best, and duplicates + // every sandbox it collected on the way. + const { sdk } = createHarness({ + pageAt: (index) => ({ + sandboxes: [started(`s-${index}`)], + nextCursor: index % 2 === 0 ? "cursor-b" : "cursor-a", + }), + }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({ role: "worker" }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxPaginationError); + assert.match(error.message, /already served|cycling/); + return true; + }, + ); + }); + + it("refuses a three-step cycle too, not just an immediate repeat", async () => { + const walk = ["c1", "c2", "c3", "c1"]; + const { sdk } = createHarness({ + pageAt: (index) => ({ + sandboxes: [started(`s-${index}`)], + ...(index < walk.length ? { nextCursor: walk[index] } : {}), + }), + }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({ role: "worker" }), + MicrosandboxPaginationError, + ); + }); + + it("throws at the page bound instead of returning what it collected", async () => { + // The old behaviour returned the collected handles, which is INDISTINGUISH- + // ABLE from a complete listing: the caller under-counts a quota, or decides + // nothing warm exists, from a drain that simply gave up. + let pagesServed = 0; + const { sdk } = createHarness({ + pageAt: (index) => { + pagesServed += 1; + return { sandboxes: [started(`s-${index}`)], nextCursor: `cursor-${index}` }; + }, + }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({ role: "worker" }, { timeoutMs: 60_000 }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxPaginationError); + assert.equal(error.pages, 1_000); + assert.match(error.message, /safety bound/); + assert.match(error.message, /no partial result/); + return true; + }, + ); + assert.equal(pagesServed, 1_000, "the bound is 1000 pages, and it is a failure rather than a stop"); + }); + + it("fails a count at the bound too, rather than under-reporting it", async () => { + const { sdk } = createHarness({ + pageAt: (index) => ({ sandboxes: [started(`s-${index}`)], nextCursor: `c-${index}` }), + }); + await assert.rejects( + makeRuntime(sdk).countByLabels({ role: "worker" }, { timeoutMs: 60_000 }), + MicrosandboxPaginationError, + ); + }); + + it("refuses a page entry with no usable name", async () => { + // An array of unusable entries is exactly as unreadable as a missing array, + // and it fails the same silent way: every entry is dropped by the state + // filter and the caller is told there is nothing warm. + const { sdk } = createHarness({ + rawPages: [{ sandboxes: [{ name: "", status: "running" }] }], + }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({ role: "worker" }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxPaginationError); + assert.match(error.message, /no usable sandbox name/); + return true; + }, + ); + }); + + it("refuses a page entry whose name is whitespace", async () => { + const { sdk } = createHarness({ + rawPages: [{ sandboxes: [{ name: " ", status: "running" }] }], + }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({ role: "worker" }), + MicrosandboxPaginationError, + ); + }); + + it("refuses a page entry with no usable status", async () => { + // Without a status the state filter cannot judge it, and the entry would be + // silently dropped as "not started". + const { sdk } = createHarness({ + rawPages: [{ sandboxes: [{ name: "s-1" }] }], + }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({ role: "worker" }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxPaginationError); + assert.match(error.message, /without a usable status/); + return true; + }, + ); + }); + + it("refuses an entry that is not an object at all", async () => { + const { sdk } = createHarness({ rawPages: [{ sandboxes: ["s-1", null] }] }); + await assert.rejects( + makeRuntime(sdk).findAllByLabels({ role: "worker" }), + MicrosandboxPaginationError, + ); + }); + + it("still accepts a well-formed page, so the validation is not just a wall", async () => { + const { sdk } = createHarness({ + rawPages: [{ sandboxes: [{ name: "s-1", status: "running" }] }], + }); + const found = await makeRuntime(sdk).findAllByLabels({ role: "worker" }); + assert.deepEqual(found.map((handle) => handle.id), ["s-1"]); + }); + + it("accepts a status outside the SDK vocabulary rather than rejecting it", async () => { + // "Usable" means present and readable, not a member of a closed set: a + // provider that adds a status must not break the drain, it must just not + // match the STARTED filter. + const { sdk } = createHarness({ + rawPages: [{ sandboxes: [{ name: "s-1", status: "hibernating" }] }], + }); + const found = await makeRuntime(sdk).findAllByLabels({ role: "worker" }, { states: null }); + assert.deepEqual(found.map((handle) => handle.state), ["STOPPED"]); + }); +}); + +describe("zero and negative lookup bounds", () => { + const started = (name: string) => ({ name, status: "running" as const }); + + it("answers a zero limit with nothing, and without a call", async () => { + // It used to return ONE result: the cap was checked only after an entry had + // already been collected, so `limit: 0` meant "at least one". + const { sdk, log } = createHarness({ pages: [{ sandboxes: [started("s-1"), started("s-2")] }] }); + assert.deepEqual(await makeRuntime(sdk).findAllByLabels({ a: "b" }, { limit: 0 }), []); + assert.equal(called(log, "Sandbox.listWith"), false, names(log).join(", ")); + }); + + it("answers a negative limit the same way", async () => { + const { sdk, log } = createHarness({ pages: [{ sandboxes: [started("s-1")] }] }); + assert.deepEqual(await makeRuntime(sdk).findAllByLabels({ a: "b" }, { limit: -5 }), []); + assert.equal(called(log, "Sandbox.listWith"), false); + }); + + it("floors a fractional limit rather than passing it through", async () => { + const { sdk, log } = createHarness({ + pages: [{ sandboxes: [started("s-1"), started("s-2"), started("s-3")] }], + }); + const found = await makeRuntime(sdk).findAllByLabels({ a: "b" }, { limit: 2.7 }); + assert.equal(found.length, 2); + assert.deepEqual(firstArgs(log, "list.limit"), [2]); + }); + + it("answers a negative maxCount with zero, and without a call", async () => { + const { sdk, log } = createHarness({ pages: [{ sandboxes: [started("s-1")] }] }); + assert.equal(await makeRuntime(sdk).countByLabels({ a: "b" }, { maxCount: -1 }), 0); + assert.equal(called(log, "Sandbox.listWith"), false); + }); + + it("does not send a zero page size to the provider", async () => { + // Zero is not a page size, and the two plausible readings of it — + // "everything" and "nothing" — are opposite, so it is not the adapter's to + // guess. The configured default is sent instead. + const { sdk, log } = createHarness({ pages: [{ sandboxes: [started("s-1")] }] }); + await makeRuntime(sdk, { listPageSize: 42 }).findAllByLabels({ a: "b" }, { pageSize: 0 }); + assert.deepEqual(firstArgs(log, "list.limit"), [42]); + }); + + it("does not send a negative page size either", async () => { + const { sdk, log } = createHarness({ pages: [{ sandboxes: [started("s-1")] }] }); + await makeRuntime(sdk, { listPageSize: 42 }).findAllByLabels({ a: "b" }, { pageSize: -3 }); + assert.deepEqual(firstArgs(log, "list.limit"), [42]); + }); + + it("keeps a zero limit from becoming a zero page size on findByLabels", async () => { + // `findByLabels` caps at one regardless, so a zero `limit` here is only a + // request size — and must not be sent as one. + const { sdk, log } = createHarness({ pages: [{ sandboxes: [started("s-1")] }] }); + const found = await makeRuntime(sdk, { listPageSize: 7 }).findByLabels({ a: "b" }, { limit: 0 }); + assert.equal(found?.id, "s-1"); + assert.deepEqual(firstArgs(log, "list.limit"), [7]); + }); +}); + describe("limit / pageSize parity with the other runtimes", () => { // Daytona, E2B and the local runtime all resolve the request size the same // way — `options.limit ?? options.pageSize` — so a caller that has tuned one @@ -2005,6 +2850,9 @@ describe("startScript (durable async exec)", () => { "/tmp/microsandbox-run/sess-1", "/tmp/microsandbox-run", "/bin/sh", + // The guest's process table, passed as an argument rather than written + // into the script, and never taken from caller-supplied environment. + "/proc", ]); assert.equal(guest.admissions.length, 1); }); @@ -2073,20 +2921,54 @@ describe("startScript (durable async exec)", () => { assert.match(started.sessionId, /^run-s1-/); }); - it("bounds only the submit call with the caller's timeout", async () => { + it("refuses a command timeout it cannot honour, before submitting anything", async () => { + // The port defines `timeoutMs` as the COMMAND's lifetime. This adapter used + // to apply it to the submit call, which is a different thing entirely: the + // caller asked for a 5s command and got a 5s submit plus a command that + // runs forever, with nothing in the result saying so. Refusing is the + // honest answer, and it has to happen before submission — a refusal after + // a process exists is not a refusal. + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await assert.rejects( + makeRuntime(sdk).startScript({ id: "s1" }, { + command: "sleep 600", + sessionId: "sess-1", + timeoutMs: 5_000, + }), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxRunTimeoutUnsupportedError); + assert.equal(error.timeoutMs, 5_000); + assert.equal(error.sessionId, "sess-1"); + return true; + }, + ); + // NOTHING was submitted: no admission, and no exec of any kind. + assert.equal(called(log, "sandbox.execWith"), false, `submitted anyway: ${names(log).join(", ")}`); + assert.equal(called(log, "exec.timeout"), false); + }); + + it("submits normally when no command timeout is asked for", async () => { const { sdk, log } = createHarness({ get: (name) => ({ name }) }); await makeRuntime(sdk).startScript({ id: "s1" }, { command: "sleep 600", sessionId: "sess-1", - timeoutMs: 5_000, }); - // The submit is bounded; the backgrounded run is not, because its lifetime - // is the sandbox's rather than the submitting request's. - assert.deepEqual(firstArgs(log, "exec.timeout"), [5_000]); + // And still never sets a submit-call timeout that would masquerade as one. + assert.equal(called(log, "exec.timeout"), false); const [argv] = firstArgs(log, "exec.args") as [string[]]; assert.equal(argv[3], "sleep 600"); }); + it("treats a zero timeout as no timeout rather than as an instant one", async () => { + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + await makeRuntime(sdk).startScript({ id: "s1" }, { + command: "job", + sessionId: "sess-1", + timeoutMs: 0, + }); + assert.equal(called(log, "exec.timeout"), false); + }); + it("adopts the existing run when the same session is submitted twice", async () => { // The outcome-unknown case: the first submit was admitted but its response // was lost, so the caller retries. Adopting is the only answer that does @@ -2271,32 +3153,98 @@ describe("getScriptStatus", () => { MICROSANDBOX_RUN_STATUS_SCRIPT, "msb-status", "/tmp/microsandbox-run/sess-1", + "/proc", ]); assert.equal(called(log, "fs.readToString"), false); }); - it("degrades to still-running when the probe itself fails", async () => { + it("refuses to report a failed probe as still running", async () => { const { sdk } = createHarness({ get: (name) => ({ name }), exec: () => { throw new Error("transport reset"); }, }); - assert.deepEqual( - await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), - { exitCode: null }, + // NOT `{ exitCode: null }`. That value means "asked, and it is still + // running" — a positive observation this probe did not make. A poll loop + // reading it treats a broken transport as a healthy long-running command + // and waits out an outcome that may already exist. + await assert.rejects( + makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxStatusProbeError); + assert.equal(error.reason, "transport"); + assert.equal(error.retryable, true); + assert.equal(error.sessionId, "sess-1"); + assert.equal(error.commandId, "1"); + assert.match(error.message, /transport reset/); + return true; + }, + ); + }); + + it("refuses to invent a verdict when the probe exits non-zero", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + // Delivered, but the probe script itself failed — an unreadable run + // directory, a guest missing /bin/sh. Stdout may even look plausible. + exec: () => ({ code: 2, stdout: "", stderr: "sh: cannot open" }), + }); + await assert.rejects( + makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxStatusProbeError); + assert.equal(error.reason, "transport"); + assert.match(error.message, /exited 2/); + return true; + }, ); }); - it("degrades to still-running when the probe returns nothing recognizable", async () => { + it("refuses to report an off-protocol answer as still running", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ stdout: "PROBABLY_FINE" }), + }); + // An unrecognized marker is the failure that never ends: reported as + // pending, a poll loop asks forever and the run is never reaped. + await assert.rejects( + makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxStatusProbeError); + assert.equal(error.reason, "unrecognized"); + assert.match(error.message, /PROBABLY_FINE/); + return true; + }, + ); + }); + + it("refuses an empty probe answer too, which is the same non-answer", async () => { const { sdk } = createHarness({ get: (name) => ({ name }), exec: () => ({ stdout: "" }), }); + await assert.rejects( + makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + MicrosandboxStatusProbeError, + ); + }); + + it("does not report a probe error for a run it can actually read", async () => { + // The guard against over-correcting: the three refusals above must not + // have made a healthy RUNNING unreachable. + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.startScript({ id: "s1" }, { command: "job", sessionId: "sess-1" }); assert.deepEqual( - await makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "1"), + await runtime.getScriptStatus({ id: "s1" }, "sess-1", "1000"), { exitCode: null }, ); + guest.finish("/tmp/microsandbox-run/sess-1", 0); + assert.deepEqual( + await runtime.getScriptStatus({ id: "s1" }, "sess-1", "1000"), + { exitCode: 0 }, + ); }); }); @@ -2310,8 +3258,89 @@ describe("getScriptLogs", () => { assert.equal(result.output, "captured output"); assert.equal(result.exitCode, null, "status is the single source of truth for the exit code"); assert.equal(result.cmdId, "cmd-9"); + assert.equal(result.truncated, undefined, "a complete log carries no truncation flag at all"); const [argv] = firstArgs(log, "exec.args") as [string[]]; - assert.match(argv[1] ?? "", /^tail -c 200000 '\/tmp\/microsandbox-run\/sess-1\/out'/); + assert.equal(argv[1], MICROSANDBOX_RUN_LOG_SCRIPT); + assert.equal(argv[3], "/tmp/microsandbox-run/sess-1/out"); + // One byte MORE than the cap, which is what makes a longer log detectable + // instead of silently tailed. + assert.equal(argv[4], "200001"); + }); + + it("reports a truncated log as truncated instead of passing a tail off as the whole thing", async () => { + // The failure this replaces is invisible by construction: a 200KB tail of a + // 1MB log looks exactly like a command that printed 200KB. + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.startScript({ id: "s1" }, { command: "noisy", sessionId: "sess-1" }); + const dir = "/tmp/microsandbox-run/sess-1"; + guest.write(dir, "A".repeat(200_000) + "TAIL-END"); + const result = await runtime.getScriptLogs({ id: "s1" }, "sess-1", "cmd-1"); + assert.equal(result.truncated, true); + assert.equal(Buffer.byteLength(result.output, "utf8"), 200_000); + assert.ok(result.output.endsWith("TAIL-END"), "the TAIL is what is kept, not the head"); + }); + + it("does not flag a log that exactly fills the cap", async () => { + // The boundary: at exactly the cap nothing was dropped, so claiming + // truncation would be as wrong as hiding it. + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.startScript({ id: "s1" }, { command: "noisy", sessionId: "sess-1" }); + guest.write("/tmp/microsandbox-run/sess-1", "B".repeat(200_000)); + const result = await runtime.getScriptLogs({ id: "s1" }, "sess-1", "cmd-1"); + assert.equal(result.truncated, undefined); + assert.equal(Buffer.byteLength(result.output, "utf8"), 200_000); + }); + + it("reports an unreadable log as a failure, not as empty output", async () => { + // An empty log is a legitimate result — a command that printed nothing has + // one — so answering "" to a failed read is indistinguishable from the + // truth, and the caller records "produced no output" as a fact. + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => { + throw new Error("guest call dropped"); + }, + }); + await assert.rejects( + makeRuntime(sdk).getScriptLogs({ id: "s1" }, "sess-1", "cmd-1"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxLogReadError); + assert.equal(error.sessionId, "sess-1"); + assert.equal(error.path, "/tmp/microsandbox-run/sess-1/out"); + assert.match(error.message, /guest call dropped/); + return true; + }, + ); + }); + + it("reports a non-zero log read as a failure too", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ code: 1, stdout: "", stderr: "tail: cannot open" }), + }); + await assert.rejects( + makeRuntime(sdk).getScriptLogs({ id: "s1" }, "sess-1", "cmd-1"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxLogReadError); + assert.match(error.message, /exited 1/); + assert.match(error.message, /tail: cannot open/); + return true; + }, + ); + }); + + it("still returns empty output for a log that is genuinely absent", async () => { + // The one case where empty IS the truth: the run has written nothing yet. + // It must stay distinguishable from the failures above by NOT throwing. + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ code: 0, stdout: "" }), + }); + const result = await makeRuntime(sdk).getScriptLogs({ id: "s1" }, "sess-1", "cmd-1"); + assert.equal(result.output, ""); + assert.equal(result.truncated, undefined); }); }); @@ -2371,6 +3400,100 @@ describe("getExecLogs (bootstrap plane)", () => { }); }); +describe("exec outcome truth", () => { + it("refuses to report a missing exit code as a success", async () => { + // `ExecResult.exitCode` is a `number`, so a missing one had to be invented, + // and the invention was `0` — the value that means "this worked". A command + // whose outcome the provider never reported is not a command that + // succeeded, and the caller cannot tell the two apart by looking. + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ unknownCode: true, stdout: "partial" }), + }); + const runtime = makeRuntime(sdk); + const handle = await runtime.launch({ name: "s1" }); + await assert.rejects( + runtime.exec(handle, "do-something"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxUnknownOutcomeError); + assert.equal(error.sandboxName, "s1"); + assert.match(error.message, /rather than defaulted to success/); + return true; + }, + ); + }); + + it("still reports a genuine zero as a zero", async () => { + // The guard: refusing an ABSENT code must not have made a real success + // unreachable. + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ code: 0, stdout: "ok" }), + }); + const runtime = makeRuntime(sdk); + const handle = await runtime.launch({ name: "s1" }); + assert.deepEqual(await runtime.exec(handle, "true"), { output: "ok", exitCode: 0 }); + }); + + it("still reports a genuine non-zero as itself", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ code: 3, stderr: "nope" }), + }); + const runtime = makeRuntime(sdk); + const handle = await runtime.launch({ name: "s1" }); + assert.deepEqual(await runtime.exec(handle, "false"), { output: "nope", exitCode: 3 }); + }); + + it("leaves runScript free to report the unknown code as null", async () => { + // The port planes differ on purpose: `RunScriptResult.exitCode` is + // `number | null`, so it can say "unknown" without inventing anything, and + // only the bootstrap plane has to raise. + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ unknownCode: true, stdout: "partial" }), + }); + const runtime = makeRuntime(sdk); + const handle = await runtime.launch({ name: "s1" }); + const result = await runtime.runScript(handle, { command: "do-something" }); + assert.equal(result.exitCode, null); + assert.equal(result.output, "partial"); + }); + + it("maps a recycled pid onto a terminal lost verdict, not a pending one", async () => { + // The adapter half of the start-time check: the guest can now answer + // `LOST pid-reused`, and it has to end the poll rather than being filed + // under "unrecognized". + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ code: 0, stdout: "LOST pid-reused\n" }), + }); + await assert.rejects( + makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "cmd-1"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxRunLostError); + assert.equal(error.reason, "its process is gone and the guest has since reused its pid for something else"); + return true; + }, + ); + }); + + it("carries a truncated log through to the bootstrap plane", async () => { + // Both planes describe the same shortened log the same way, or a caller + // moving between them reads a tail as a whole output on one of them. + const { sdk, guest } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + const started = await runtime.startScript({ id: "s1" }, { command: "noisy", sessionId: "sess-1" }); + const dir = "/tmp/microsandbox-run/sess-1"; + guest.write(dir, "C".repeat(200_050)); + guest.finish(dir, 0); + const result = await runtime.getExecLogs({ id: "s1" }, "sess-1", started.commandId); + assert.equal(result.exitCode, 0); + assert.equal(result.truncated, true); + assert.equal(Buffer.byteLength(result.output, "utf8"), 200_000); + }); +}); + describe("file transfer", () => { it("treats a string source as a HOST PATH", async () => { const { sdk, log } = createHarness({ get: (name) => ({ name }) }); @@ -2710,9 +3833,10 @@ describe("capabilities", () => { }); // Cloud differs on BOTH backend-sensitive fields. `isolation: "unknown"` is - // the honest value: microsandbox's cloud isolation is vendor-documented but - // this adapter measures nothing about it, and publishing "strong" would be - // publishing a guarantee nobody here verified. + // the honest value: this adapter observes nothing about how a cloud sandbox + // is isolated — it has no host-local artifact to read, the way the local + // backend does — and publishing "strong" would be publishing a guarantee + // this adapter cannot make. it("does NOT declare snapshot support on the cloud backend, and reports isolation as unknown", () => { const { sdk } = createHarness(); assert.deepEqual(makeRuntime(sdk).capabilities, { @@ -2807,6 +3931,16 @@ const SHELL_SKIP = process.platform === "win32" ? "POSIX shell protocol tests need /bin/sh" : false; +/** + * Start-time identity comes from `/proc//stat`, so the tests that assert + * it only mean something on a host that has procfs. The adapter's documented + * FALLBACK — pid liveness alone, when no start time was recorded — is asserted + * unconditionally, so a host without procfs still covers the path it takes. + */ +const PROCFS_SKIP = existsSync("/proc/self/stat") + ? false + : "start-time identity needs procfs (/proc//stat)"; + function sh( script: string, args: string[], @@ -2850,12 +3984,194 @@ describe("guest run protocol (real /bin/sh)", { skip: SHELL_SKIP }, () => { } }); - const admit = (command: string, session: string) => { + const admit = (command: string, session: string, procRoot = "/proc") => { const dir = join(root, session); - return sh(MICROSANDBOX_RUN_ADMIT_SCRIPT, [command, dir, root, "/bin/sh"]); + return sh(MICROSANDBOX_RUN_ADMIT_SCRIPT, [command, dir, root, "/bin/sh", procRoot]); }; - const probe = (session: string) => - sh(MICROSANDBOX_RUN_STATUS_SCRIPT, [join(root, session)]); + const probe = (session: string, procRoot = "/proc") => + sh(MICROSANDBOX_RUN_STATUS_SCRIPT, [join(root, session), procRoot]); + + /** + * A synthetic procfs holding one pid's `stat` line, so the start-time + * identity check is executable on a host that has no real `/proc` — which is + * every macOS developer machine, and was where these assertions silently + * skipped and proved nothing. + * + * `comm` defaults to a name containing BOTH a space and a closing paren, + * because that is the case a left-to-right field split gets wrong: every + * field after `comm` is numeric, so the LAST `") "` is the real separator. + */ + async function fakeProc( + pid: string, + startTicks: string, + comm = "my (odd) proc", + ): Promise { + const procRoot = join(root, `proc-${pid}-${startTicks}`); + await mkdir(join(procRoot, pid), { recursive: true }); + const fields = [pid, `(${comm})`, "S"]; + // Pad to field 22 overall; start time is the 20th field after `comm`. + for (let field = 4; field <= 21; field += 1) { + fields.push(String(field)); + } + fields.push(startTicks); + fields.push("trailing"); + await writeFile(join(procRoot, pid, "stat"), `${fields.join(" ")}\n`); + return procRoot; + } + + + // --- run identity: pid alone is not an identity --------------------------- + // + // Start time is gated on procfs because that is where it comes from. On a + // host without `/proc` the admission records none and the probe degrades to + // boot id + pid liveness, which the fallback test below asserts RUNS THERE — + // so neither platform is left without coverage of the path it actually takes. + + it("parses a start time out of a stat line whose comm holds a space and a paren", async () => { + // The parse a naive left-to-right field split gets wrong. `comm` is the + // only field that can hold arbitrary bytes, and every field after it is + // numeric, so the LAST `") "` is the real separator. Reading the wrong + // field makes every liveness comparison noise. + // + // The DISCRIMINATING half is the second assertion: a parse that failed + // outright yields nothing, which the script treats as "cannot tell" and + // reports RUNNING — so only a probe that detects a genuine MISMATCH proves + // the field was actually found. + await admit("sleep 5", "comm-parse"); + const dir = join(root, "comm-parse"); + const pid = (await waitForFile(join(dir, "pid"))).trim(); + await writeFile(join(dir, "start"), "778899"); + assert.equal( + (await probe("comm-parse", await fakeProc(pid, "778899"))).stdout.trim(), + "RUNNING", + "a matching start time behind an awkward comm must read as still running", + ); + assert.equal( + (await probe("comm-parse", await fakeProc(pid, "778900"))).stdout.trim(), + "LOST pid-reused", + "a differing start time must be DETECTED, which only a correct parse can do", + ); + }); + + it("records the run's start time alongside its pid", async () => { + // Uses the REAL procfs when the host has one; a host without procfs takes + // the documented fallback, which the next-but-one test covers. + if (PROCFS_SKIP) { + return; + } + await admit("sleep 5", "identity"); + const recorded = (await waitForFile(join(root, "identity", "start"))).trim(); + assert.match(recorded, /^\d+$/, "a start time is a tick count"); + assert.equal((await probe("identity")).stdout.trim(), "RUNNING"); + }); + + it("calls a run lost when its pid is alive but is no longer its process", async () => { + // Real pid reuse cannot be provoked on demand, so both halves are supplied: + // the run is admitted normally, its pid is genuinely alive, and the + // recorded start time is one the process does not have. That is exactly the + // state a recycled pid leaves behind — and without this check the probe + // reports RUNNING forever, so the poll loop never ends. + await admit("sleep 5", "reused"); + const dir = join(root, "reused"); + const pid = (await waitForFile(join(dir, "pid"))).trim(); + await writeFile(join(dir, "start"), "111111"); + const procRoot = await fakeProc(pid, "999999"); + assert.equal((await probe("reused", procRoot)).stdout.trim(), "LOST pid-reused"); + }); + + it("keeps reporting RUNNING when the recorded start time still matches", async () => { + // The guard against a check that fires on everything: same pid, same start + // time, so nothing has been recycled and the run is simply still going. + await admit("sleep 5", "unchanged"); + const dir = join(root, "unchanged"); + const pid = (await waitForFile(join(dir, "pid"))).trim(); + await writeFile(join(dir, "start"), "555555"); + const procRoot = await fakeProc(pid, "555555"); + assert.equal((await probe("unchanged", procRoot)).stdout.trim(), "RUNNING"); + }); + + it("does not invent a mismatch when the current start time cannot be read", async () => { + // The fallback is chosen by the ABSENCE of a recorded start time, never by + // a failure to read the current one. A probe that cannot see procfs must + // not conclude the pid was recycled. + await admit("sleep 5", "unreadable-now"); + const dir = join(root, "unreadable-now"); + await waitForFile(join(dir, "pid")); + await writeFile(join(dir, "start"), "444444"); + const emptyProc = join(root, "proc-empty"); + await mkdir(emptyProc, { recursive: true }); + assert.equal((await probe("unreadable-now", emptyProc)).stdout.trim(), "RUNNING"); + }); + + it("falls back to pid liveness when no start time was recorded", async () => { + // The documented degradation: a guest without procfs records nothing, and + // the probe must work exactly as it did before start time existed. + await admit("sleep 5", "no-starttime"); + const dir = join(root, "no-starttime"); + await waitForFile(join(dir, "pid")); + await rm(join(dir, "start"), { force: true }); + const pid = (await readFile(join(dir, "pid"), "utf8")).trim(); + // A synthetic procfs that WOULD mismatch is supplied deliberately: with no + // recorded start time there is nothing to compare, so it must be ignored. + const procRoot = await fakeProc(pid, "123123"); + assert.equal((await probe("no-starttime", procRoot)).stdout.trim(), "RUNNING"); + + process.kill(Number(pid), "SIGKILL"); + let verdict = ""; + for (let attempt = 0; attempt < 200 && verdict !== "LOST process-gone"; attempt += 1) { + verdict = (await probe("no-starttime", procRoot)).stdout.trim(); + if (verdict !== "LOST process-gone") { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + assert.equal(verdict, "LOST process-gone", "a killed run with no start time still reports lost"); + }); + + it("prefers a recorded exit code over any liveness reasoning", async () => { + // Ordering guard: the exit file is checked FIRST, so a finished run is + // never re-examined for pid reuse and reported lost after the fact. + await admit("printf hi", "finished"); + const dir = join(root, "finished"); + assert.equal(await waitForFile(join(dir, "exit")), "0"); + await writeFile(join(dir, "start"), "999999999"); + assert.equal((await probe("finished")).stdout.trim(), "EXIT 0"); + }); + + // --- log read: absence is not failure ------------------------------------- + + it("reads a log through the bounded tail", async () => { + const path = join(root, "log-plain.txt"); + await writeFile(path, "hello log"); + const result = await sh(MICROSANDBOX_RUN_LOG_SCRIPT, [path, "1024"]); + assert.equal(result.code, 0); + assert.equal(result.stdout, "hello log"); + }); + + it("returns the LAST bytes when the file is longer than the cap", async () => { + const path = join(root, "log-long.txt"); + await writeFile(path, "0123456789"); + const result = await sh(MICROSANDBOX_RUN_LOG_SCRIPT, [path, "4"]); + assert.equal(result.code, 0); + assert.equal(result.stdout, "6789"); + }); + + it("succeeds with no output for a log that does not exist yet", async () => { + // The one case where empty is the truth. It must be a SUCCESS, because the + // adapter distinguishes it from a failure by the exit code alone. + const result = await sh(MICROSANDBOX_RUN_LOG_SCRIPT, [join(root, "never-written.txt"), "1024"]); + assert.equal(result.code, 0); + assert.equal(result.stdout, ""); + }); + + it("fails rather than reporting empty when the path is not a readable file", async () => { + // A directory where a log should be: readable-empty and unreadable have to + // be distinguishable, or a failed read is recorded as "printed nothing". + const path = join(root, "log-as-dir"); + await mkdir(path, { recursive: true }); + const result = await sh(MICROSANDBOX_RUN_LOG_SCRIPT, [path, "1024"]); + assert.equal(result.code, 0, "a directory is not a regular file, so it reads as absent"); + assert.equal(result.stdout, ""); + }); it("admits a run, records it, and captures combined output", async () => { const admitted = await admit("printf out; printf err >&2", "captured"); diff --git a/src/microsandbox/runtime.ts b/src/microsandbox/runtime.ts index 23543cc..e760bcf 100644 --- a/src/microsandbox/runtime.ts +++ b/src/microsandbox/runtime.ts @@ -92,6 +92,11 @@ const MAX_SANDBOX_NAME_BYTES = 128; const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000; const DEFAULT_LIST_PAGE_SIZE = 100; +/** + * Hard bound on pages drained for one lookup. Reaching it is a failure, never + * a result — see the throw at the end of `collectByLabels`. + */ +const MAX_LIST_PAGES = 1_000; /** * How long a call may wait for the process-global backend gate before it fails * instead of waiting forever. See {@link MicrosandboxBackendBusyError}. @@ -119,6 +124,14 @@ const DEFAULT_RUN_STATE_DIR = "/tmp/microsandbox-run"; /** POSIX shell used to interpret a `runScript`/`startScript` command string. */ const DEFAULT_SHELL = "/bin/sh"; +/** + * Where the guest's process table lives. Passed to the run scripts as an + * argument rather than written into them, so the same scripts can be executed + * against a synthetic procfs in tests; the value the adapter sends is never + * anything else, and is never taken from caller-supplied environment. + */ +const GUEST_PROC_ROOT = "/proc"; + // --- guest-side async run protocol ----------------------------------------- // // Both scripts below are pure POSIX `sh` and take every value — including the @@ -132,6 +145,44 @@ const DEFAULT_SHELL = "/bin/sh"; // of the same session id incapable of starting a second process or of // overwriting the first one's state. +/** + * Read a pid's START TIME (field 22 of `/proc//stat`, in clock ticks + * since boot) into stdout, or fail with a non-zero status when procfs cannot + * answer. + * + * WHY IT IS PART OF RUN IDENTITY. A pid alone does not identify a process: the + * guest can recycle it, and a recycled pid answers `kill -0` exactly like the + * original, so a run whose process died would keep reporting RUNNING forever + * as soon as something else landed on its number. Start time is the field that + * makes the pair unique for the lifetime of a boot — two processes on the same + * pid cannot share it. + * + * The parse is byte-careful for one specific reason: field 2 is the executable + * name in parentheses and MAY CONTAIN SPACES AND PARENTHESES, so splitting the + * line on whitespace from the left is wrong. Every field after it is numeric or + * a single flag character, so the LAST `") "` in the line is always the end of + * that field — which is what `##*") "` finds. Start time is then the 20th field + * of the remainder (22 overall, less the pid and the name). + * + * @internal Shared by both scripts below; not part of the public API. + */ +const MSB_STARTTIME_FN = [ + // `$1` procfs root, `$2` pid. The root is a PARAMETER rather than a literal + // so the identity check is executable against a synthetic procfs in tests — + // on every platform, not only on hosts that have a real one. It is passed by + // the adapter as a positional argument and is never read from the + // environment, which the caller controls: a run's liveness verdict must not + // be redirectable by whoever submitted it. + "msb_starttime() {", + ' msb_st=$(cat "$1/$2/stat" 2>/dev/null) || return 1', + ' msb_rest=${msb_st##*") "}', + ' if [ "$msb_rest" = "$msb_st" ]; then return 1; fi', + " set -- $msb_rest", + ' if [ "$#" -lt 20 ]; then return 1; fi', + ' printf %s "${20}"', + "}", +].join("\n"); + /** * Admit one async run. * @@ -156,6 +207,8 @@ export const MICROSANDBOX_RUN_ADMIT_SCRIPT = [ "dir=$2", "parent=$3", "shell_path=$4", + "proc_root=$5", + MSB_STARTTIME_FN, 'mkdir -p "$parent" 2>/dev/null || true', 'if mkdir "$dir" 2>/dev/null; then', // Record the command BEFORE starting anything: a crash between the two @@ -165,12 +218,17 @@ export const MICROSANDBOX_RUN_ADMIT_SCRIPT = [ // Boot identity, used by the status probe to tell "still running" from "the // sandbox restarted and this pid now belongs to someone else". Absent on a // guest without procfs, in which case the probe falls back to pid liveness. - ' cat /proc/sys/kernel/random/boot_id > "$dir/boot" 2>/dev/null || true', + ' cat "$proc_root/sys/kernel/random/boot_id" > "$dir/boot" 2>/dev/null || true', // The command runs in a CHILD shell, so an `exit 7` inside it cannot skip // the exit-code record: the child exits, the wrapper writes its status. ' nohup "$shell_path" -c \'"$3" -c "$1" > "$2/out" 2>&1; printf %s "$?" > "$2/exit"\' msb-run "$cmd" "$dir" "$shell_path" > /dev/null 2>&1 &', " run_pid=$!", ' printf %s "$run_pid" > "$dir/pid"', + // Pid + start time is the run's identity. Written only when procfs actually + // answered: an EMPTY `start` file would be indistinguishable from "recorded + // a start time of nothing", and the probe would then compare against it. + " start_ticks=$(msb_starttime \"$proc_root\" \"$run_pid\") || start_ticks=''", + ' if [ -n "$start_ticks" ]; then printf %s "$start_ticks" > "$dir/start"; fi', ' printf "ADMITTED %s\\n" "$run_pid"', " exit 0", "fi", @@ -197,11 +255,29 @@ export const MICROSANDBOX_RUN_ADMIT_SCRIPT = [ * final act, so re-reading after the liveness probe closes the window where a * run finishes mid-probe and would otherwise read as lost. * + * LIVENESS IS THREE CHECKS, not one, because each answers a different way of + * losing a run: + * - boot id, for "the sandbox restarted underneath it"; + * - `kill -0`, for "the process is gone"; + * - START TIME, for "the pid is alive but it is somebody else's now". Without + * the third, a recycled pid reports RUNNING forever, which is the one + * failure a poll loop cannot end on. + * + * FALLBACK, stated because it is a real reduction in what the probe can tell + * apart: when the guest has no procfs, admission records no start time and the + * probe degrades to boot id + pid liveness — exactly the behaviour before start + * time existed. The fallback is chosen by the ABSENCE of a recorded start time, + * never by a failure to read the current one: if a start time was recorded and + * the current read fails while the pid is alive, the probe reports RUNNING + * rather than inventing a mismatch. + * * @internal Exported only for the protocol tests (see above). */ export const MICROSANDBOX_RUN_STATUS_SCRIPT = [ "set -u", "dir=$1", + "proc_root=$2", + MSB_STARTTIME_FN, 'if [ -f "$dir/exit" ]; then', ' printf "EXIT %s\\n" "$(cat "$dir/exit" 2>/dev/null)"', " exit 0", @@ -211,7 +287,7 @@ export const MICROSANDBOX_RUN_STATUS_SCRIPT = [ " exit 0", "fi", 'boot=$(cat "$dir/boot" 2>/dev/null) || boot=""', - 'now=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || now=""', + 'now=$(cat "$proc_root/sys/kernel/random/boot_id" 2>/dev/null) || now=""', 'if [ -n "$boot" ] && [ -n "$now" ] && [ "$boot" != "$now" ]; then', ' printf "LOST sandbox-restarted\\n"', " exit 0", @@ -223,6 +299,16 @@ export const MICROSANDBOX_RUN_STATUS_SCRIPT = [ " exit 0", "fi", 'if kill -0 "$run_pid" 2>/dev/null; then', + // The pid is alive. Is it still OUR process? Only a recorded start time can + // answer; without one the probe says RUNNING, as it did before. + ' recorded=$(cat "$dir/start" 2>/dev/null) || recorded=""', + ' if [ -n "$recorded" ]; then', + " current=$(msb_starttime \"$proc_root\" \"$run_pid\") || current=''", + ' if [ -n "$current" ] && [ "$current" != "$recorded" ]; then', + ' printf "LOST pid-reused\\n"', + " exit 0", + " fi", + " fi", ' printf "RUNNING\\n"', " exit 0", "fi", @@ -233,6 +319,38 @@ export const MICROSANDBOX_RUN_STATUS_SCRIPT = [ 'printf "LOST process-gone\\n"', ].join("\n"); +/** + * Read one run's captured output. + * + * Arguments: `$1` log path, `$2` byte cap. Exits 0 with the last `$2` bytes of + * the file, or 0 with NO output when the file is genuinely absent; any other + * failure exits non-zero. + * + * THE EXIT CODE IS THE WHOLE POINT. The previous form of this read was + * `tail -c N path 2>/dev/null || true`, which flattened three different + * situations — "the run has not written anything yet", "the log is + * unreadable", and "the guest call failed" — onto the same empty string. Empty + * output is a legitimate answer for a run that printed nothing, so a caller + * cannot tell that reading from an unreadable one. Absence is now the ONLY + * condition that yields empty-and-successful; everything else fails loudly and + * the adapter raises {@link MicrosandboxLogReadError}. + * + * The cap is read as one byte MORE than the caller's limit, so the adapter can + * see that a longer file exists and report `truncated` rather than handing back + * a tail that reads like a complete log. + * + * @internal Exported only for the protocol tests (see above). + */ +export const MICROSANDBOX_RUN_LOG_SCRIPT = [ + "set -u", + "path=$1", + "cap=$2", + // Absent is not an error: a run that has not yet written its first byte, and + // a run that printed nothing at all, both legitimately have no log. + 'if [ ! -f "$path" ]; then exit 0; fi', + 'tail -c "$cap" "$path"', +].join("\n"); + // --- structural microsandbox SDK surface (microsandbox@0.6.11) -------------- /** `SandboxStatus` — the SDK's own status vocabulary. */ @@ -598,6 +716,165 @@ export class MicrosandboxRunNotFinishedError extends Error { } } +/** + * The process-wide default backend could not be restored, so no later + * default-dependent SDK static may be issued from this process. + * + * `withDefaultBackend` sets one process-global slot and restores it on the way + * out. When the RESTORE is what failed, the slot holds an unknown value: not + * necessarily this runtime's backend, not necessarily the previous one. Every + * static the adapter calls reads that slot, so the only two honest options are + * to guess or to stop. This adapter stops — permanently, for the life of the + * process, because nothing it is willing to do can re-establish the truth. + * (Calling `setDefaultBackend` to force a known value would mutate the host + * process on behalf of a library, which this adapter never does.) + * + * Sandbox and handle instances resolved BEFORE the failure are unaffected and + * still usable: the SDK binds each one to the backend it was resolved on (its + * typings call this "backend retained by this sandbox"), so their exec, + * filesystem and lifecycle calls read no global state. + */ +export class MicrosandboxBackendPoisonedError extends Error { + override readonly name = "MicrosandboxBackendPoisonedError"; + + constructor(cause: unknown) { + super( + "Microsandbox cannot issue any further backend-dependent SDK call from this process: restoring the " + + "process-wide default backend failed, so its current value is unknown and a call issued now could " + + `run against the wrong backend. Restart the process. Underlying error: ${errorMessage(cause)}`, + { cause }, + ); + } +} + +/** + * A command finished, but the SDK reported no exit code for it. + * + * The bootstrap-plane `ExecResult.exitCode` is a number, so this adapter would + * have to INVENT one — and the only plausible invention, `0`, is the value that + * says "this succeeded". A command whose outcome the provider did not report is + * not a command that succeeded, so the caller is told the outcome is unknown + * instead of being told a comfortable lie it cannot detect. + */ +export class MicrosandboxUnknownOutcomeError extends Error { + override readonly name = "MicrosandboxUnknownOutcomeError"; + readonly sandboxName: string; + + constructor(sandboxName: string) { + super( + `Microsandbox exec on sandbox "${sandboxName}" completed without an exit code, so its outcome is ` + + "unknown; it is reported as unknown rather than defaulted to success", + ); + this.sandboxName = sandboxName; + } +} + +/** + * The status probe for an async run did not produce a verdict. + * + * Distinct from {@link MicrosandboxRunLostError}, which IS a verdict — the run + * is over and cannot complete. This error says the adapter learned NOTHING: + * the guest call failed, or it answered something this protocol does not + * define. Returning "still running" for either would be a positive claim the + * probe never made, and a poll loop reading it would wait for an outcome that + * may already have happened. + * + * It is safe to retry: nothing about the run was changed by asking. + */ +export class MicrosandboxStatusProbeError extends Error { + override readonly name = "MicrosandboxStatusProbeError"; + readonly sessionId: string; + readonly commandId: string; + /** `"transport"` — the probe call itself failed. `"unrecognized"` — it answered off-protocol. */ + readonly reason: "transport" | "unrecognized"; + /** Retrying is harmless; the probe has no side effects on the run. */ + readonly retryable = true; + + constructor( + sessionId: string, + commandId: string, + reason: "transport" | "unrecognized", + detail: string, + cause?: unknown, + ) { + super( + `Microsandbox could not determine the status of run "${sessionId}" (${reason}): ${detail}. ` + + "The run's state is unchanged and the probe may be retried; it is NOT reported as still running, " + + "because that would be an observation this probe did not make.", + cause === undefined ? undefined : { cause }, + ); + this.sessionId = sessionId; + this.commandId = commandId; + this.reason = reason; + } +} + +/** + * A run's captured output could not be read. + * + * Raised instead of returning `""`. An empty log is a legitimate outcome — a + * command that printed nothing has one — so a failed read that answered `""` + * would be indistinguishable from a real result, and the caller would record + * "the command produced no output" as a fact about a read that never happened. + * A genuinely ABSENT log file still yields `""`, which is the one case where + * empty is the truth. + */ +export class MicrosandboxLogReadError extends Error { + override readonly name = "MicrosandboxLogReadError"; + readonly sessionId: string; + readonly path: string; + + constructor(sessionId: string, path: string, detail: string, cause?: unknown) { + super( + `Microsandbox could not read the log for run "${sessionId}" at ${path}: ${detail}. ` + + "Reported as a failure rather than as empty output, which would be indistinguishable from a run " + + "that printed nothing.", + cause === undefined ? undefined : { cause }, + ); + this.sessionId = sessionId; + this.path = path; + } +} + +/** + * `startScript`/`startExec` was given a `timeoutMs`, which this adapter cannot + * honour as the port defines it. + * + * The port's `timeoutMs` is the COMMAND's lifetime. For a synchronous + * `runScript` that is exactly what the SDK's `ExecOptionsBuilder.timeout` gives, + * so the sync path honours it. An async run is different: it is detached inside + * the guest by the durable wrapper and outlives the submit call, so the submit + * call's timeout bounds nothing about the command. + * + * The adapter previously applied it to the submit call anyway. That is the + * failure mode this error exists to remove — a caller that asked for a 30s + * command budget got a 30s SUBMIT budget and a command that runs forever, with + * nothing in the result to say so. + * + * It is refused rather than approximated because the honest enforcement is not + * available here: killing the run's shell on expiry would leave that shell's + * own descendants running, so the adapter would report a terminated run while + * the work continued — a fabricated outcome, which is worse than a refusal. Use + * `maxDurationSeconds` for a sandbox-lifetime bound, or put the bound in the + * command itself (`timeout 30 ...`), where the guest can enforce it properly. + */ +export class MicrosandboxRunTimeoutUnsupportedError extends Error { + override readonly name = "MicrosandboxRunTimeoutUnsupportedError"; + readonly sessionId: string; + readonly timeoutMs: number; + + constructor(sessionId: string, timeoutMs: number) { + super( + `Microsandbox cannot apply a ${timeoutMs}ms command timeout to the async run "${sessionId}": the port ` + + "defines `timeoutMs` as the command's lifetime, and an async run is detached in the guest, so the " + + "submit call's timeout would bound nothing. Nothing was submitted. Bound the sandbox with " + + "`maxDurationSeconds`, or put the timeout inside the command.", + ); + this.sessionId = sessionId; + this.timeoutMs = timeoutMs; + } +} + // --- process-global backend gate ------------------------------------------- // // WHAT ACTUALLY NEEDS GATING, and why it is only some of the SDK. @@ -641,8 +918,83 @@ type BackendScope = { exited: Promise; }; +/** + * One queued caller, in arrival order. + * + * A waiter is an OBJECT rather than a bare callback so that a timed-out or + * cancelled caller can be found by identity, and so the gate can be handed to + * exactly one of them (see {@link promoteNextBackendWaiter}). + */ +type BackendWaiter = { + readonly key: string; + readonly wake: () => void; +}; + let currentBackendScope: BackendScope | null = null; -const backendScopeWaiters: Array<() => void> = []; +const backendScopeWaiters: BackendWaiter[] = []; + +/** + * The waiter the gate is currently RESERVED for, if any. + * + * Waking the head of the queue is not by itself FIFO: between the wake and the + * woken caller re-checking the gate, a brand-new arrival can observe a free + * gate and take it, so the queue's order decides nothing and a waiter can be + * skipped repeatedly. While this is set the gate is spoken for, and every + * caller other than this one must queue — which is what actually makes the + * handoff ordered. + */ +let backendGateHandoffTo: BackendWaiter | null = null; + +/** + * Set once the SDK fails to RESTORE the process-wide default backend, after + * which no default-dependent static may be issued from this process again. + * + * Deliberately permanent and deliberately module-scoped: the damage is to the + * SDK's process-global slot, so it is not a property of any one runtime + * instance, and no action this adapter is willing to take can re-establish + * what that slot now holds. See {@link MicrosandboxBackendPoisonedError}. + * + * The FLAG carries whether the gate is poisoned; the cause is kept beside it. + * Using the cause as its own sentinel would lose exactly the rejections that + * carry no value — `Promise.reject()`, `reject(null)` — and those poison the + * process default just as thoroughly as a rejection with an `Error` does. + */ +let backendGatePoisoned = false; +let backendGatePoisonCause: unknown; + +/** + * Reset the module-global gate. TEST-ONLY. + * + * The gate is process-global on purpose, and poisoning it is permanent on + * purpose — which makes the poison path untestable in-process without a way + * back. Exported from the module but NOT from the package barrel, so it is + * reachable from this file's tests and from nowhere a consumer imports. + * + * @internal + */ +export function __resetBackendGateForTests(): void { + currentBackendScope = null; + backendScopeWaiters.splice(0); + backendGateHandoffTo = null; + backendGatePoisoned = false; + backendGatePoisonCause = undefined; +} + +/** + * How many callers are queued on the gate right now. TEST-ONLY. + * + * The leak this exists to catch is INVISIBLE from the outside: a waiter that + * timed out still reports the same typed `MicrosandboxBackendBusyError` to its + * caller whether or not it deregistered itself, so a test written against + * observable behaviour alone passes against the bug. The only discriminating + * signal is the length of the queue itself, so the queue is what the test + * asserts on. + * + * @internal + */ +export function __backendGateWaiterCountForTests(): number { + return backendScopeWaiters.length; +} /** * Is this runtime bound to the LOCAL backend? @@ -674,60 +1026,144 @@ async function withBackendScope( backend: MicrosandboxBackend, fn: () => Promise | T, queueTimeoutMs: number, + signal?: AbortSignal, ): Promise { const key = backendKey(backend); const queueDeadline = Date.now() + queueTimeoutMs; - for (;;) { - const open = currentBackendScope; - if (open && !open.closing && open.key === key) { - open.active += 1; - try { - // The scope may still be opening; running before it is entered would - // run against whatever backend the process last had. - await open.entered; - return await fn(); - } finally { - await leaveBackendScope(open); - } + // Set once this caller has been handed the gate as the FIFO head. It may + // then take the gate even though callers are still queued BEHIND it — which + // is the whole point of the handoff — but it owes the queue a promotion if + // it then leaves without taking the gate. + let holdsTurn = false; + const yieldTurn = (): void => { + if (holdsTurn) { + holdsTurn = false; + promoteNextBackendWaiter(); } - if (open) { - // BOUNDED. An abandoned scope holder (a create or lookup that outlived - // its client-side deadline) is still in flight and still owns the - // process-wide backend, so this wait can otherwise never end. - const remainingMs = queueDeadline - Date.now(); - if (remainingMs <= 0) { - throw new MicrosandboxBackendBusyError(queueTimeoutMs); + }; + try { + for (;;) { + if (backendGatePoisoned) { + throw new MicrosandboxBackendPoisonedError(backendGatePoisonCause); } - let timer: ReturnType | undefined; - let wake!: () => void; - const woken = new Promise((resolve) => { - wake = () => resolve(false); - }); - backendScopeWaiters.push(wake); - const timedOut = await Promise.race([ - woken, - new Promise((resolve) => { - timer = setTimeout(() => resolve(true), remainingMs); - }), - ]); - if (timer) { - clearTimeout(timer); + if (signal?.aborted) { + // ADMISSION CANCELLED. The caller's overall deadline has already + // expired, so `fn` must never run: a static admitted after its own + // deadline is a call the caller has stopped waiting for and will + // never read the result of. + throw signal.reason; + } + const open = currentBackendScope; + // JOINING IS THE STARVATION RISK, so it is conditional on an empty queue. + // A same-backend call may share an open scope only when NOBODY is waiting + // and the gate is not already promised to a waiter; the moment a call on + // another backend has queued, later same-backend arrivals queue behind it + // too. Without that condition a steady stream of same-backend work keeps + // the scope permanently occupied and the other backend never runs — which + // is the failure this gate's own comment claimed it prevented. + if ( + open && + !open.closing && + open.key === key && + (holdsTurn || (backendScopeWaiters.length === 0 && backendGateHandoffTo === null)) + ) { + open.active += 1; + // Joining does not hold the gate — the open scope does — so a turn + // taken here is handed straight back to the queue. + yieldTurn(); + try { + // The scope may still be opening; running before it is entered would + // run against whatever backend the process last had. + await open.entered; + return await fn(); + } finally { + await leaveBackendScope(open); + } } - if (timedOut) { - // Deregister. The waiter list is only spliced wholesale when a scope - // releases, so a timed-out waiter left behind would never be collected - // — and the case this bound exists for is precisely the one where no - // release ever comes, which would grow the list without limit under - // sustained load. - const queued = backendScopeWaiters.indexOf(wake); - if (queued !== -1) { - backendScopeWaiters.splice(queued, 1); + if ( + open || + (!holdsTurn && (backendScopeWaiters.length > 0 || backendGateHandoffTo !== null)) + ) { + // BOUNDED. An abandoned scope holder (a create or lookup that outlived + // its client-side deadline) is still in flight and still owns the + // process-wide backend, so this wait can otherwise never end. + const remainingMs = queueDeadline - Date.now(); + if (remainingMs <= 0) { + throw new MicrosandboxBackendBusyError(queueTimeoutMs); + } + let timer: ReturnType | undefined; + let wake!: () => void; + const woken = new Promise<"woken">((resolve) => { + wake = () => resolve("woken"); + }); + const waiter: BackendWaiter = { key, wake }; + backendScopeWaiters.push(waiter); + let onAbort: (() => void) | undefined; + const outcomes: Array> = [ + woken, + new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), remainingMs); + }), + ]; + if (signal) { + outcomes.push( + new Promise<"aborted">((resolve) => { + onAbort = () => resolve("aborted"); + signal.addEventListener("abort", onAbort, { once: true }); + }), + ); + } + const outcome = await Promise.race(outcomes); + if (timer) { + clearTimeout(timer); + } + if (signal && onAbort) { + signal.removeEventListener("abort", onAbort); + } + if (outcome === "woken") { + // The gate was reserved for THIS waiter by name, so nothing else can + // have taken it in between. Consume the reservation and re-check. + backendGateHandoffTo = null; + holdsTurn = true; + continue; + } + // Deregister. The waiter list is no longer spliced wholesale on + // release, but a timed-out or cancelled waiter left behind would still + // be woken later and, worse, would hold a handoff the gate then waits + // on forever — so it both leaves the queue and passes on any turn it + // was given in the same tick. + dropBackendWaiter(waiter); + if (outcome === "aborted") { + throw signal!.reason; } throw new MicrosandboxBackendBusyError(queueTimeoutMs); } - continue; + + return await openBackendScope(sdk, backend, key, fn, () => { + // Taking the gate consumes the turn: ownership now lives in the scope, + // and the queue is promoted when that scope releases. + holdsTurn = false; + }); } + } finally { + yieldTurn(); + } +} +/** + * Open a NEW scope on a free gate and run `fn` inside it. + * + * Split out from {@link withBackendScope} only so the queueing loop above stays + * readable; it is never called on a gate that is already held. + */ +async function openBackendScope( + sdk: MicrosandboxSdk, + backend: MicrosandboxBackend, + key: string, + fn: () => Promise | T, + onTaken: () => void, +): Promise { + { let release!: () => void; const drained = new Promise((resolve) => { release = resolve; @@ -751,13 +1187,20 @@ async function withBackendScope( exited: Promise.resolve(), }; currentBackendScope = scope; + onTaken(); + // Distinguishes the two ways `withDefaultBackend` can reject, which need + // opposite handling: before the callback ran, nothing of ours executed and + // the process default was never changed; after it ran, the rejection can + // only be the SDK failing to RESTORE, and the slot is then unknown. + let callbackRan = false; let scopeCall: Promise; try { // The scope stays open for as long as anyone is inside it: the SDK's // callback resolves only once the last participant has left. scopeCall = Promise.resolve( sdk.withDefaultBackend(backend, () => { + callbackRan = true; markEntered(); return drained; }), @@ -772,9 +1215,27 @@ async function withBackendScope( scope.exited = scopeCall.then( () => undefined, (error: unknown) => { - // Rejected before the callback ran: the scope was never entered. - // Rejected after: the caller's own call already carries the outcome. - failEntry(error); + if (!callbackRan) { + // Rejected BEFORE the callback ran: the scope was never entered, so + // the process default was never changed and nothing ran under it. + // `entered` rejects and this call fails closed, below. + failEntry(error); + return; + } + // Rejected AFTER the callback ran. The callback returns a promise that + // resolves only once the last participant has left, so the SDK had + // already re-entered its own teardown: this rejection is the RESTORE + // failing. The process-wide slot now holds an unknown value. + // + // Swallowing it — which is what returning here would do — hands the + // gate to the next backend as if the previous one had been cleanly + // restored, and that call then runs against whatever the slot actually + // holds. So it is recorded as poison for every later static, and + // rethrown so the participant that closed the scope is TOLD rather + // than left believing the call completed cleanly. + backendGatePoisoned = true; + backendGatePoisonCause = error; + throw error; }, ); @@ -798,14 +1259,30 @@ async function withBackendScope( async function leaveBackendScope(scope: BackendScope): Promise { scope.active -= 1; if (scope.active > 0 || scope.closing) { + // EARLY LEAVER. Its own call finished, but the restore it depends on has + // not happened yet: the scope closes only when the LAST participant + // leaves. Returning here would let a joined caller report success while + // the very same scope goes on to fail its restore — two callers running + // concurrently against one backend, one told the truth and one not. + // + // Awaiting cannot deadlock: this participant has already decremented, so + // the count it is waiting on no longer includes itself, and `exited` + // settles as soon as the last one leaves. + await scope.exited; return; } scope.closing = true; scope.release(); - // Wait for the SDK to restore the previous backend BEFORE any queued call on - // a different backend is allowed to open its own scope. - await scope.exited; - releaseBackendGate(scope); + try { + // Wait for the SDK to restore the previous backend BEFORE any queued call + // on a different backend is allowed to open its own scope. + await scope.exited; + } finally { + // Released even when the restore FAILED. The gate is poisoned by then, so + // waking the queue does not let anyone through — it converts a wait that + // would otherwise run to its timeout into an immediate, typed refusal. + releaseBackendGate(scope); + } } /** Free the gate for a scope that never opened. */ @@ -819,9 +1296,47 @@ function releaseBackendGate(scope: BackendScope): void { if (currentBackendScope === scope) { currentBackendScope = null; } - const waiters = backendScopeWaiters.splice(0); - for (const wake of waiters) { - wake(); + promoteNextBackendWaiter(); +} + +/** + * Hand the free gate to the OLDEST waiter, and to that waiter alone. + * + * Waking every waiter at once and letting them re-race is not FIFO by any + * definition: all of them find the gate free, and which one actually takes it + * is decided by microtask scheduling order, so a waiter can lose that race + * arbitrarily many times while later arrivals win it. Reserving the gate for + * one named waiter is what makes the queue's order mean something. + */ +function promoteNextBackendWaiter(): void { + if (currentBackendScope !== null || backendGateHandoffTo !== null) { + return; + } + const next = backendScopeWaiters.shift(); + if (!next) { + return; + } + backendGateHandoffTo = next; + next.wake(); +} + +/** + * Remove a waiter that will never take the gate, and pass on any reservation + * it was holding. + * + * The reservation matters more than the queue slot: a waiter that timed out in + * the same tick it was promoted still owns `backendGateHandoffTo`, and every + * other caller defers to that reservation — so dropping it without promoting a + * successor wedges the gate permanently on a caller that has already left. + */ +function dropBackendWaiter(waiter: BackendWaiter): void { + const queued = backendScopeWaiters.indexOf(waiter); + if (queued !== -1) { + backendScopeWaiters.splice(queued, 1); + } + if (backendGateHandoffTo === waiter) { + backendGateHandoffTo = null; + promoteNextBackendWaiter(); } } @@ -836,7 +1351,7 @@ function releaseBackendGate(scope: BackendScope): void { */ function readSandboxPage( page: unknown, - previousCursor: string | undefined, + seenCursors: ReadonlySet, pageNumber: number, ): { sandboxes: MsbSandboxHandle[]; nextCursor?: string } { if (typeof page !== "object" || page === null) { @@ -849,7 +1364,34 @@ function readSandboxPage( "the provider returned a page whose sandbox list is unreadable", ); } - const sandboxes = record.sandboxes as MsbSandboxHandle[]; + // THE ENVELOPE IS NOT THE DATA. An array of unusable entries is exactly as + // unreadable as a missing array, and it fails the same way: every entry is + // dropped by the state filter, the drain ends, and the caller is told there + // is nothing warm — from a page the provider did in fact return sandboxes on. + // A handle is only usable if it has an addressable name, because the name IS + // the identity here, and a status, because the state filter reads it. + const sandboxes = record.sandboxes.map((entry, index) => { + if (typeof entry !== "object" || entry === null) { + throw new MicrosandboxPaginationError( + pageNumber, + `entry ${index} of the page is not a sandbox object`, + ); + } + const { name, status } = entry as { name?: unknown; status?: unknown }; + if (typeof name !== "string" || name.trim() === "") { + throw new MicrosandboxPaginationError( + pageNumber, + `entry ${index} of the page has no usable sandbox name, so it cannot be addressed`, + ); + } + if (typeof status !== "string" || status.trim() === "") { + throw new MicrosandboxPaginationError( + pageNumber, + `sandbox "${name}" was returned without a usable status, so it cannot be filtered by state`, + ); + } + return entry as MsbSandboxHandle; + }); const cursor = record.nextCursor; if (cursor === undefined || cursor === null || cursor === "") { return { sandboxes }; @@ -860,10 +1402,15 @@ function readSandboxPage( "the provider returned a next-page cursor that is not a string", ); } - if (previousCursor !== undefined && cursor === previousCursor) { + // EVERY cursor already used, not just the previous one. A backend that walks + // A → B → A advances on each individual step, so comparing against only the + // last cursor sees progress forever while re-serving the same two pages — + // the drain then ends on the 1000-page bound at best, and duplicates every + // sandbox it collected on the way. + if (seenCursors.has(cursor)) { throw new MicrosandboxPaginationError( pageNumber, - "the provider returned a next-page cursor identical to the one just used, so the listing cannot advance", + `the provider returned a next-page cursor it has already served, so the listing is cycling rather than advancing`, ); } return { sandboxes, nextCursor: cursor }; @@ -899,22 +1446,27 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { * - `pty: false` — the SDK's `ExecOptionsBuilder.tty(true)` and * `ExecHandle.resize()` are real, but the port has no pty method and this * adapter never allocates one. - * - `snapshots` — LOCAL only. `launch` sources a sandbox from a snapshot via - * the builder's `fromSnapshot`, and what that consumes is a snapshot - * ARTIFACT: a host-local directory (the SDK stores them under - * `~/.microsandbox/snapshots//` and indexes them in a local DB - * cache). A cloud create has no access to that directory, so the cloud - * backend reports `false` and refuses the pairing outright — see the - * constructor. Note the adapter only ever CONSUMES a snapshot; it never - * creates one, so `SandboxHandle.snapshot()` is not on this path. - * - `isolation` — `'strong'` on LOCAL, `'unknown'` on CLOUD. Locally the SDK - * boots each sandbox as a microVM with its own guest kernel on a - * virtualization-capable host (KVM on Linux, Apple Silicon on macOS, WHP - * on Windows 10+), which this package can stand behind. The cloud - * backend's isolation, region placement and resource enforcement are - * vendor-documented but NOT observable from here, and this adapter runs no - * measurement against them — so it reports `'unknown'` rather than - * publishing an unverified `'strong'`. See {@link IsolationLevel}. + * - `snapshots` — LOCAL only, and the claim is about THIS ADAPTER's boot + * path, not about the provider's hosted service. `launch` sources a + * sandbox through the builder's `fromSnapshot`, which consumes a + * host-local artifact: the installed SDK's typings describe `Snapshot` as + * "an artifact on disk" and resolve one under + * `~/.microsandbox/snapshots//`. This adapter never transfers that + * artifact anywhere, so on a remote backend there is nothing for a create + * to resolve — hence `false`, and a constructor that refuses the pairing. + * Note the adapter only ever CONSUMES a snapshot; it never creates one, so + * `SandboxHandle.snapshot()` is not on this path. + * - `isolation` — `'strong'` on LOCAL, `'unknown'` on CLOUD, and both values + * describe what this package has ESTABLISHED rather than what any provider + * documentation says. Locally the SDK boots each sandbox as a microVM with + * its own guest kernel on a virtualization-capable host, and the installed + * package states that requirement itself (Node 22+, a native addon, KVM / + * Apple Silicon / WHP), so `'strong'` rests on something checkable here. + * For the cloud backend this adapter observes nothing about isolation, + * region placement or resource enforcement and measures nothing against + * them, so it reports `'unknown'` — which is a statement about this + * package's evidence, not an assertion that the guarantee is absent. See + * {@link IsolationLevel}. * - `persistentHandle: true` — a sandbox is re-resolvable by name from a * fresh process via `Sandbox.get(name)` + `connect()`, and * `launchDetached` sets `detached(true)` so it outlives this process. @@ -984,24 +1536,37 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { ); } // Refused HERE — before the lazy `import("microsandbox")`, before any SDK - // call, before a single byte leaves the process. A snapshot source is a - // local-backend-only configuration, so this is a static fact about the - // pairing rather than something the provider has to be asked about; - // deferring it to `launch` would surface it as an opaque remote failure on - // a path the caller was told existed. + // call, before a single byte leaves the process. Deferring it to `launch` + // would surface it as an opaque remote failure on a path the caller was + // told existed. + // + // WHAT THIS REFUSAL RESTS ON, stated exactly, because a capability claim is + // only worth what its evidence is. It is NOT a claim about what the vendor's + // hosted service does or does not implement — this package has no way to + // observe that. It is a statement about the ADAPTER'S OWN CONTRACT plus one + // fact that is checkable in the installed package: // - // PROVENANCE: that Microsandbox CLOUD does not support snapshot-sourced - // creates is vendor documentation, relayed by the sandbox program lead as a - // release blocker; it is NOT derivable from microsandbox@0.6.11's typings, - // which carry no cloud/local gating on the snapshot surface. What IS - // checkable from the installed package, and what makes the restriction - // coherent, is that a snapshot artifact is host-local: the SDK documents - // them under `~/.microsandbox/snapshots//` and lists them from a - // local DB cache, and a cloud create cannot resolve a host path. + // - checkable: a snapshot is a HOST-LOCAL ARTIFACT. `microsandbox@0.6.11`'s + // `native/index.d.ts` declares `Snapshot` as "A snapshot artifact on + // disk", resolves `SandboxHandle.snapshot(name)` "under + // `~/.microsandbox/snapshots//`", and offers `Snapshot.listDir(dir)` + // to walk a directory of them. `builder.fromSnapshot(pathOrName)` consumes + // that artifact. + // - contract: this adapter only ever CONSUMES a snapshot by path or name + // from the calling host, and it does not transfer one anywhere. A create + // issued against a remote backend therefore has nothing this adapter has + // put within its reach. + // + // So the pairing is refused because THIS ADAPTER cannot make it work, which + // is a fact about code in this repository. `capabilities.snapshots` reports + // the same thing per backend, and neither claims anything about the hosted + // service's own capabilities. if (options.snapshot && !isLocalBackend(options.backend)) { throw new Error( - "MicrosandboxRuntime cannot boot from a `snapshot` on the cloud backend: a snapshot artifact is host-local " - + "(the SDK resolves it under ~/.microsandbox/snapshots/), so a cloud create cannot reach it. " + "MicrosandboxRuntime cannot boot from a `snapshot` on the cloud backend: a snapshot is a host-local " + + "artifact (the SDK's own typings resolve one under ~/.microsandbox/snapshots/ and describe it as an " + + "artifact on disk), and this adapter consumes it from the calling host without transferring it, so a " + + "create issued against a remote backend has nothing to resolve. " + "Use `image` on the cloud backend, or set `backend: \"local\"` to boot from a snapshot.", ); } @@ -1077,8 +1642,10 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { states: options.states === undefined ? ["STARTED"] : options.states, // `limit` caps RESULTS as well as sizing the request; `pageSize` only // sizes the request. Treating a page size as a result cap would silently - // truncate a listing the caller asked to receive in full. - ...(options.limit !== undefined ? { cap: options.limit } : {}), + // truncate a listing the caller asked to receive in full. A non-integral + // or negative cap is floored onto zero, which `collectByLabels` answers + // without a call — the same rule `countByLabels` applies to `maxCount`. + ...resultCap(options.limit), requestSize: options.limit ?? options.pageSize, excludeIds: options.excludeIds, timeoutMs: options.timeoutMs, @@ -1091,16 +1658,9 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { labels: Record, options: SandboxCountOptions = {}, ): Promise { - const maxCount = options.maxCount === undefined - ? Number.POSITIVE_INFINITY - : Math.max(0, Math.floor(options.maxCount)); - if (maxCount === 0) { - // Short-circuit: a cap of zero can be answered without a network call. - return 0; - } const handles = await this.collectByLabels(labels, { states: options.states === undefined ? ["STARTED"] : options.states, - ...(Number.isFinite(maxCount) ? { cap: maxCount } : {}), + ...resultCap(options.maxCount), requestSize: options.limit ?? options.pageSize, timeoutMs: options.timeoutMs, description: "counting matching sandboxes", @@ -1132,34 +1692,55 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { description: string; }, ): Promise { + // A cap of zero is answerable without a network call, and answering it + // WITH one is how a cap of zero used to return a result: the cap was + // checked only after an entry had already been collected. Negative and + // fractional caps normalize onto it rather than meaning something + // accidental. + if (options.cap !== undefined && options.cap <= 0) { + return []; + } // Request size, resolved exactly as Daytona/E2B/local resolve it, so a - // caller that tuned one provider's lookup gets the same request shape here. - const limit = options.requestSize ?? this.listPageSize; + // caller that tuned one provider's lookup gets the same request shape here + // — except that a zero or negative size is not a request shape at all, so + // it falls back to the configured page size instead of being sent. + const limit = positivePageSize(options.requestSize) ?? this.listPageSize; const excluded = new Set(options.excludeIds ?? []); const deadline = this.lookupDeadline(options.timeoutMs); const handles: RuntimeHandle[] = []; + const seenCursors = new Set(); let cursor: string | undefined; - // Bounded so a backend that keeps handing back the same cursor cannot spin + // Bounded so a backend that keeps handing back fresh cursors cannot spin // this loop forever. The deadline bounds it in wall-clock terms too. - for (let page = 0; page < 1_000; page += 1) { + for (let page = 0; page < MAX_LIST_PAGES; page += 1) { const cursorForPage = cursor; - const raw = await this.awaitWithin( - this.withBackendStatic((sdk) => - sdk.Sandbox.listWith((builder) => { - let configured = builder.limit(limit); - if (hasEntries(labels)) { - configured = configured.labels(labels); - } - if (cursorForPage) { - configured = configured.cursor(cursorForPage); - } - return configured; - }), - ), + const raw = await this.awaitWithinCancelling( + (signal) => + this.withBackendStatic( + (sdk) => + sdk.Sandbox.listWith((builder) => { + let configured = builder.limit(limit); + if (hasEntries(labels)) { + configured = configured.labels(labels); + } + if (cursorForPage) { + configured = configured.cursor(cursorForPage); + } + return configured; + }), + signal, + ), deadline, options.description, ); - const result = readSandboxPage(raw, cursorForPage, page + 1); + // Recorded BEFORE the page is validated, so the cursor that fetched this + // page counts as seen: a provider that hands back the cursor it was just + // given is the degenerate cycle, and it has to fail on the same check as + // the longer A → B → A one. + if (cursorForPage !== undefined) { + seenCursors.add(cursorForPage); + } + const result = readSandboxPage(raw, seenCursors, page + 1); for (const entry of result.sandboxes) { if (!matchesState(entry.status, options.states) || excluded.has(entry.name)) { continue; @@ -1177,7 +1758,17 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { } cursor = result.nextCursor; } - return handles; + // THE BOUND IS A FAILURE, NOT AN ANSWER. Falling out of this loop meant + // returning what had been collected so far, which is the exact shape of a + // complete listing — the caller cannot tell a drain that gave up after a + // thousand pages from one that genuinely ended, so it under-counts a quota + // or launches a sandbox it already had. Every other way this drain can end + // badly already throws; this one now does too. + throw new MicrosandboxPaginationError( + MAX_LIST_PAGES, + `the listing still had more pages at the ${MAX_LIST_PAGES}-page safety bound, so it cannot be drained ` + + "to a trustworthy end; no partial result is returned", + ); } async getById( @@ -1348,7 +1939,14 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { // --- exec ---------------------------------------------------------------- - /** Bootstrap-plane exec. Same call as `runScript`, narrower result shape. */ + /** + * Bootstrap-plane exec. Same call as `runScript`, narrower result shape. + * + * `ExecResult.exitCode` is a `number`, and `RunScriptResult.exitCode` is + * `number | null`, so this is where a missing outcome would have to be + * invented. It is not: a `null` becomes a typed error rather than the `0` + * that would report an unobserved command as a successful one. + */ async exec( handle: RuntimeHandle, command: string, @@ -1360,7 +1958,14 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { ...(options.env !== undefined ? { env: options.env } : {}), ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), }); - return { output: result.output, exitCode: result.exitCode ?? 0 }; + if (result.exitCode === null) { + throw new MicrosandboxUnknownOutcomeError(handle.id); + } + return { + output: result.output, + exitCode: result.exitCode, + ...(result.truncated ? { truncated: true } : {}), + }; } async runScript(handle: RuntimeHandle, options: { @@ -1421,8 +2026,15 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { env?: Record; suppressInputEcho?: boolean; }): Promise { - const sandbox = await this.requireSandbox(handle); const sessionId = options.sessionId ?? `run-${handle.id}-${randomUUID()}`; + // REFUSED BEFORE ANYTHING IS SUBMITTED, so a caller that asked for a + // command budget it will not get is told before a process exists rather + // than after one is running unbounded. See the error's own docs for why it + // is refused instead of approximated. + if (options.timeoutMs !== undefined && options.timeoutMs > 0) { + throw new MicrosandboxRunTimeoutUnsupportedError(sessionId, options.timeoutMs); + } + const sandbox = await this.requireSandbox(handle); const dir = this.scriptRunDir(sessionId); const cwd = options.cwd ?? handle.workdir; const output = await sandbox.execWith(this.shell, (builder) => { @@ -1434,6 +2046,7 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { dir, this.runStateDir, this.shell, + GUEST_PROC_ROOT, ]); if (cwd !== undefined) { configured = configured.cwd(cwd); @@ -1441,13 +2054,11 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { if (hasEntries(options.env)) { configured = configured.envs(options.env); } - // The caller's `timeoutMs` bounds the SUBMIT call only. It is - // deliberately NOT applied to the backgrounded command: the command's - // lifetime is the sandbox's, and applying the submit deadline here - // would kill every long-running run the moment submission completed. - if (options.timeoutMs !== undefined && options.timeoutMs > 0) { - configured = configured.timeout(options.timeoutMs); - } + // No `timeout(...)` here, and that is the point: the only timeout this + // call could set is the SUBMIT call's, and the port's `timeoutMs` means + // the COMMAND's lifetime. Setting it here would satisfy the type and + // silently mean something else, so a `timeoutMs` is refused above + // instead. return configured; }); const marker = output.stdout().trim(); @@ -1479,15 +2090,38 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { const sandbox = await this.requireSandbox(handle); const dir = this.scriptRunDir(sessionId); let marker: string; + let probeExit: number | undefined; + let probeStderr = ""; try { const output = await sandbox.execWith(this.shell, (builder) => - builder.args(["-c", MICROSANDBOX_RUN_STATUS_SCRIPT, "msb-status", dir]), + builder.args(["-c", MICROSANDBOX_RUN_STATUS_SCRIPT, "msb-status", dir, GUEST_PROC_ROOT]), ); marker = (output.stdout() ?? "").trim(); - } catch { - // A failed probe says nothing about the run. Report it as unfinished and - // let the caller poll again, exactly as a dropped status read should. - return { exitCode: null }; + probeExit = typeof output.code === "number" ? output.code : undefined; + probeStderr = output.stderr() ?? ""; + } catch (error) { + // A FAILED PROBE SAYS NOTHING ABOUT THE RUN, which is exactly why it can + // no longer be reported as `{ exitCode: null }`. That value means "asked, + // and it is still running" — a positive observation this call did not + // make. A caller polling on it treats a broken transport as a healthy + // long-running command and waits out an outcome that may already exist. + throw new MicrosandboxStatusProbeError( + sessionId, + commandId, + "transport", + `the probe call failed: ${errorMessage(error)}`, + error, + ); + } + if (probeExit !== undefined && probeExit !== 0) { + // The call was delivered but the probe script itself failed — an + // unreadable run directory, a guest without `/bin/sh`. Same reasoning. + throw new MicrosandboxStatusProbeError( + sessionId, + commandId, + "transport", + `the probe exited ${probeExit}: ${summarize(probeStderr || marker)}`, + ); } if (marker.startsWith("EXIT ")) { const parsed = Number.parseInt(marker.slice("EXIT ".length).trim(), 10); @@ -1514,16 +2148,18 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { } if (marker.startsWith("LOST")) { const reason = marker.slice("LOST".length).trim(); - throw new MicrosandboxRunLostError( - sessionId, - commandId, - reason === "sandbox-restarted" - ? "the sandbox restarted while it was running" - : "its process is gone and it never recorded an exit code", - ); + throw new MicrosandboxRunLostError(sessionId, commandId, describeLostReason(reason)); } - // Unrecognized output is an unreadable probe, not a verdict. - return { exitCode: null }; + // Unrecognized output is an unreadable probe, NOT a verdict — and least of + // all the verdict "still running". Reporting one here is how a poll loop + // runs forever against a guest whose probe is answering something this + // protocol never defined. + throw new MicrosandboxStatusProbeError( + sessionId, + commandId, + "unrecognized", + `the probe answered ${summarize(marker)}, which is not a verdict this protocol defines`, + ); } async getScriptLogs( @@ -1533,10 +2169,17 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { ): Promise { const sandbox = await this.requireSandbox(handle); const outPath = `${this.scriptRunDir(sessionId)}/out`; - const output = await this.readFileBestEffort(sandbox, outPath, SCRIPT_LOG_READ_MAX_BYTES); + const log = await this.readRunLog(sandbox, sessionId, outPath, SCRIPT_LOG_READ_MAX_BYTES); // exitCode stays null: `getScriptStatus` is the single source of truth for // the exit code, matching the Daytona, E2B and local adapters. - return { output, exitCode: null, cmdId: commandId }; + return { + output: log.output, + exitCode: null, + cmdId: commandId, + // Present only when the read actually bounded something. An absent + // `truncated` means the log is complete — never "unknown". + ...(log.truncated ? { truncated: true } : {}), + }; } // --- bootstrap-plane async exec aliases ---------------------------------- @@ -1575,7 +2218,13 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { throw new MicrosandboxRunNotFinishedError(sessionId, commandId); } const logs = await this.getScriptLogs(handle, sessionId, commandId); - return { output: logs.output, exitCode: status.exitCode }; + return { + output: logs.output, + exitCode: status.exitCode, + // Carried through rather than dropped: the bootstrap plane's consumer is + // the one that would otherwise read a tail as the whole output. + ...(logs.truncated ? { truncated: true } : {}), + }; } // --- files --------------------------------------------------------------- @@ -1768,9 +2417,10 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { */ private async withBackendStatic( fn: (sdk: MicrosandboxSdk) => Promise | T, + signal?: AbortSignal, ): Promise { const sdk = await this.sdk(); - return withBackendScope(sdk, this.backend, () => fn(sdk), this.backendQueueTimeoutMs); + return withBackendScope(sdk, this.backend, () => fn(sdk), this.backendQueueTimeoutMs, signal); } private async lookupHandle(name: string): Promise { @@ -1844,25 +2494,64 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { } } - private async readFileBestEffort( + /** + * Read one run's captured output, bounded, without turning a failure into an + * empty log. + * + * The read goes through {@link MICROSANDBOX_RUN_LOG_SCRIPT} rather than + * `fs.readToString` for two reasons that both matter to the caller: an + * ABSENT log is a success with no output (a run that has printed nothing has + * one), and everything else — an unreadable file, a failed guest call — is a + * failure. The previous form could not tell those apart, because it answered + * `""` to all of them. + * + * One byte MORE than the cap is requested, so a longer log is detectable + * rather than silently tailed: the extra byte is what turns "here is the + * output" into "here is the last `maxBytes` of it". + */ + private async readRunLog( sandbox: MsbSandbox, + sessionId: string, path: string, maxBytes: number, - ): Promise { - // Read through a bounded `tail -c` rather than `fs.readToString`: it yields - // "" instead of throwing while the file is still absent, and caps the bytes - // pulled back to the caller. + ): Promise<{ output: string; truncated: boolean }> { + let output: MsbExecOutput; try { - const output = await sandbox.execWith(this.shell, (builder) => + output = await sandbox.execWith(this.shell, (builder) => builder.args([ "-c", - `tail -c ${maxBytes} ${shellSingleQuote(path)} 2>/dev/null || true`, + MICROSANDBOX_RUN_LOG_SCRIPT, + "msb-log", + path, + String(maxBytes + 1), ]), ); - return output.stdout() ?? ""; - } catch { - return ""; + } catch (error) { + throw new MicrosandboxLogReadError( + sessionId, + path, + `the guest call failed: ${errorMessage(error)}`, + error, + ); + } + if (typeof output.code === "number" && output.code !== 0) { + throw new MicrosandboxLogReadError( + sessionId, + path, + `the read exited ${output.code}: ${summarize(output.stderr() ?? "")}`, + ); } + const text = output.stdout() ?? ""; + const bytes = Buffer.from(text, "utf8"); + if (bytes.byteLength <= maxBytes) { + return { output: text, truncated: false }; + } + // More than the cap came back, so the log is longer than what is being + // returned. The caller is handed the TAIL and told it is one. + return { + output: bytes.subarray(bytes.byteLength - maxBytes).toString("utf8"), + truncated: true, + }; } private lookupDeadline(timeoutMs: number | undefined): { endsAt: number; timeoutMs: number } { @@ -1873,6 +2562,34 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { return { endsAt: Date.now() + normalized, timeoutMs: normalized }; } + /** + * Run `build` under the ONE overall deadline, cancelling its admission when + * that deadline expires. + * + * Racing a timer against the operation is not enough on its own. The gate is + * a queue, so a lookup that gives up while queued is still queued: it can be + * admitted later and issue a static against the process default long after + * the caller stopped waiting for it. The signal is what actually withdraws + * it from the queue. + */ + private async awaitWithinCancelling( + build: (signal: AbortSignal) => Promise, + deadline: { endsAt: number; timeoutMs: number }, + description: string, + ): Promise { + const controller = new AbortController(); + try { + return await this.awaitWithin(build(controller.signal), deadline, description); + } catch (error) { + controller.abort( + error instanceof MicrosandboxLookupTimeoutError + ? error + : new MicrosandboxLookupTimeoutError(deadline.timeoutMs, description), + ); + throw error; + } + } + private async awaitWithin( operation: Promise, deadline: { endsAt: number; timeoutMs: number }, @@ -2013,6 +2730,17 @@ function isAlreadyStopped(error: unknown): boolean { return typeof message === "string" && /already\s+stopped|not\s+running/i.test(message); } +/** Turn the status script's `LOST ` token into something a human reads. */ +function describeLostReason(reason: string): string { + if (reason === "sandbox-restarted") { + return "the sandbox restarted while it was running"; + } + if (reason === "pid-reused") { + return "its process is gone and the guest has since reused its pid for something else"; + } + return "its process is gone and it never recorded an exit code"; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -2058,10 +2786,43 @@ function toUint8Array(buffer: Buffer): Uint8Array { return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); } -function hasEntries(record?: Record): record is Record { - return !!record && Object.keys(record).length > 0; +/** + * A page-request size the provider can actually be asked for, or `undefined` + * when the caller's value is not one. + * + * `limit: 0` and `limit: -1` are not page sizes; sending either would ask the + * backend to interpret them, and the two mainstream interpretations — + * "everything" and "nothing" — are opposite. The caller's configured default + * is used instead, and the RESULT cap (a separate concern, resolved by the + * caller of this helper) is what honours a zero. + */ +/** + * Normalize a caller's RESULT cap onto `collectByLabels`'s `cap` option. + * + * Returns a spreadable fragment so "no cap" is the absence of the key rather + * than a sentinel. The three edges are decided, not accidental: + * - `undefined` — no cap; drain the whole listing. + * - `Infinity` — no cap; it is the explicit spelling of the same thing. + * - anything else, INCLUDING `NaN`, negatives and fractions — floored onto a + * non-negative integer, with `NaN` becoming `0`. A cap nobody can interpret + * resolves to "return nothing", never to "return everything": the first is + * visibly wrong to the caller, the second silently drains a listing it asked + * to bound. + */ +function resultCap(requested: number | undefined): { cap?: number } { + if (requested === undefined || requested === Number.POSITIVE_INFINITY) { + return {}; + } + return { cap: Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : 0 }; +} + +function positivePageSize(requested: number | undefined): number | undefined { + if (requested === undefined || !Number.isFinite(requested) || requested < 1) { + return undefined; + } + return Math.floor(requested); } -function shellSingleQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; +function hasEntries(record?: Record): record is Record { + return !!record && Object.keys(record).length > 0; } diff --git a/src/port.ts b/src/port.ts index 3b4ea9a..765b20c 100644 --- a/src/port.ts +++ b/src/port.ts @@ -8,6 +8,16 @@ export type RunScriptResult = { stderr?: string; exitCode: number | null; cmdId?: string; + /** + * True when `output` is a TAIL of the real output rather than all of it. + * + * Mirrors `ExecResult.truncated` deliberately: an adapter that bounds a log + * read reports the bound on both planes or on neither, because a caller that + * moves between them would otherwise see the same shortened log described + * two different ways. Present only when the adapter actually bounds the read + * — absent means complete, never "unknown". + */ + truncated?: boolean; }; export type AsyncRunStartResult = { From 2caacdf19b7b581458138b44fda72f9b9a90158b Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 20 Aug 2026 04:09:34 +0200 Subject: [PATCH 06/10] fix microsandbox timeout and probe edge cases Session-Id: 01a01ce7-bf70-72e2-a08d-deeea7b95842 Session-Id: 6d810ec8-fbbc-43f2-8ef2-bc87744ca04f --- src/microsandbox/runtime.test.ts | 155 +++++++++++++++++++++++++++++-- src/microsandbox/runtime.ts | 92 +++++++++++++----- 2 files changed, 217 insertions(+), 30 deletions(-) diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts index 51d445e..08dd16f 100644 --- a/src/microsandbox/runtime.test.ts +++ b/src/microsandbox/runtime.test.ts @@ -1594,6 +1594,66 @@ describe("backend gate poisoning (a restore that failed)", () => { } }); + it("poisons and releases queued callers when restore throws synchronously after entry", async () => { + // `Promise.resolve(withDefaultBackend(...))` never sees a promise here: + // the SDK invokes the callback, then throws while restoring the process + // default on the same stack. Callback entry still proves the global slot + // was mutated, so this is poison just like an asynchronous restore + // rejection. + let releaseHolder!: () => void; + const holderCall = new Promise((resolve) => { + releaseHolder = resolve; + }); + const { sdk, log } = createHarness({ + get: (name) => ({ name }), + onGet: () => holderCall, + }); + const syncRestoreFailure: MicrosandboxSdk = { + Sandbox: sdk.Sandbox, + withDefaultBackend(_backend, fn): Promise { + fn(); + throw new Error("synchronous restore failure"); + }, + }; + __resetBackendGateForTests(); + try { + const holder = makeRuntime(sdk).getById("holder"); + await waitFor(() => called(log, "Sandbox.get"), "the healthy holder to enter the gate"); + + const failing = makeRuntime(syncRestoreFailure, { + backend: "local", + backendQueueTimeoutMs: 60_000, + }).getById("sync-failure"); + const queued = makeRuntime(sdk, { + backend: { profile: "queued-after-sync-failure" }, + backendQueueTimeoutMs: 60_000, + }).getById("queued"); + await waitFor( + () => __backendGateWaiterCountForTests() === 2, + "both callers to queue behind the holder", + ); + + releaseHolder(); + assert.equal((await holder)?.id, "holder"); + await assert.rejects(failing, /synchronous restore failure/); + await assert.rejects(queued, MicrosandboxBackendPoisonedError); + assert.deepEqual( + argsFor(log, "Sandbox.get"), + [["holder"]], + "neither the failed scope nor a caller released by poison may issue its static", + ); + + const before = log.length; + await assert.rejects( + makeRuntime(sdk).getById("later"), + MicrosandboxBackendPoisonedError, + ); + assert.deepEqual(log.slice(before), [], "later statics must fail before touching the SDK"); + } finally { + __resetBackendGateForTests(); + } + }); + it("poisons the gate even when the restore rejected with no value at all", async () => { // THE SENTINEL BUG. Recording the rejection reason and treating "no reason // recorded" as "not poisoned" collapses on exactly the rejections that @@ -1721,7 +1781,7 @@ describe("backend gate poisoning (a restore that failed)", () => { const { sdk } = createHarness({ get: (name) => ({ name }) }); const neverEnters: MicrosandboxSdk = { Sandbox: sdk.Sandbox, - async withDefaultBackend() { + withDefaultBackend() { throw new Error("could not push the backend scope"); }, }; @@ -1942,6 +2002,46 @@ describe("create deadline", () => { assert.equal(handle.id, "s1"); }); + it("withdraws a timed-out create while it is queued behind another backend", async () => { + // The create deadline is an admission deadline too. If it expires before + // this backend owns the process-global gate, releasing the holder later + // must not construct a builder or start a provider create for a caller + // that already received a timeout. + let releaseHolder!: () => void; + const holderCall = new Promise((resolve) => { + releaseHolder = resolve; + }); + let creates = 0; + const { sdk, log } = createHarness({ + onCreate: () => { + creates += 1; + return creates === 1 ? holderCall : Promise.resolve(); + }, + }); + __resetBackendGateForTests(); + try { + const holder = makeRuntime(sdk).launch({ name: "holder" }); + await waitFor(() => creates === 1, "the first backend's create to hold the gate"); + + await assert.rejects( + makeRuntime(sdk, { backend: "local", backendQueueTimeoutMs: 60_000 }).launch({ + name: "withdrawn", + createTimeoutSeconds: 0.03, + }), + MicrosandboxCreateTimeoutError, + ); + assert.equal(__backendGateWaiterCountForTests(), 0, "the timed-out create must leave the queue"); + + releaseHolder(); + await holder; + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual(argsFor(log, "Sandbox.builder"), [["holder"]]); + assert.deepEqual(argsFor(log, "builder.create"), [["holder"]]); + } finally { + __resetBackendGateForTests(); + } + }); + it("reclaims a create that lands after the deadline instead of leaking it", async () => { // The SDK cannot cancel an in-flight create. A sandbox that finishes // booting after the caller gave up is a running microVM nobody is waiting @@ -3230,6 +3330,24 @@ describe("getScriptStatus", () => { ); }); + it("reports an unreadable recorded process identity as a retryable probe error", async () => { + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ stdout: "UNKNOWN starttime-unreadable\n" }), + }); + await assert.rejects( + makeRuntime(sdk).getScriptStatus({ id: "s1" }, "sess-1", "123"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxStatusProbeError); + assert.equal(error.reason, "transport"); + assert.equal(error.retryable, true); + assert.match(error.message, /start time/i); + assert.match(error.message, /could not be read/i); + return true; + }, + ); + }); + it("does not report a probe error for a run it can actually read", async () => { // The guard against over-correcting: the three refusals above must not // have made a healthy RUNNING unreachable. @@ -4090,17 +4208,34 @@ describe("guest run protocol (real /bin/sh)", { skip: SHELL_SKIP }, () => { assert.equal((await probe("unchanged", procRoot)).stdout.trim(), "RUNNING"); }); - it("does not invent a mismatch when the current start time cannot be read", async () => { + it("reports unknown when a recorded start time cannot be read from procfs", async () => { // The fallback is chosen by the ABSENCE of a recorded start time, never by - // a failure to read the current one. A probe that cannot see procfs must - // not conclude the pid was recycled. + // a failure to read the current one. The latter proves neither RUNNING nor + // pid reuse, so the adapter must receive an explicit retryable non-verdict. await admit("sleep 5", "unreadable-now"); const dir = join(root, "unreadable-now"); await waitForFile(join(dir, "pid")); await writeFile(join(dir, "start"), "444444"); const emptyProc = join(root, "proc-empty"); await mkdir(emptyProc, { recursive: true }); - assert.equal((await probe("unreadable-now", emptyProc)).stdout.trim(), "RUNNING"); + assert.equal( + (await probe("unreadable-now", emptyProc)).stdout.trim(), + "UNKNOWN starttime-unreadable", + ); + }); + + it("reports unknown when the current procfs stat record is malformed", async () => { + await admit("sleep 5", "malformed-now"); + const dir = join(root, "malformed-now"); + const pid = (await waitForFile(join(dir, "pid"))).trim(); + await writeFile(join(dir, "start"), "555555"); + const procRoot = join(root, "proc-malformed"); + await mkdir(join(procRoot, pid), { recursive: true }); + await writeFile(join(procRoot, pid, "stat"), "not a process stat line\n"); + assert.equal( + (await probe("malformed-now", procRoot)).stdout.trim(), + "UNKNOWN starttime-unreadable", + ); }); it("falls back to pid liveness when no start time was recorded", async () => { @@ -4163,13 +4298,21 @@ describe("guest run protocol (real /bin/sh)", { skip: SHELL_SKIP }, () => { assert.equal(result.stdout, ""); }); + it("succeeds with no output for an empty regular log file", async () => { + const path = join(root, "empty-log.txt"); + await writeFile(path, ""); + const result = await sh(MICROSANDBOX_RUN_LOG_SCRIPT, [path, "1024"]); + assert.equal(result.code, 0); + assert.equal(result.stdout, ""); + }); + it("fails rather than reporting empty when the path is not a readable file", async () => { // A directory where a log should be: readable-empty and unreadable have to // be distinguishable, or a failed read is recorded as "printed nothing". const path = join(root, "log-as-dir"); await mkdir(path, { recursive: true }); const result = await sh(MICROSANDBOX_RUN_LOG_SCRIPT, [path, "1024"]); - assert.equal(result.code, 0, "a directory is not a regular file, so it reads as absent"); + assert.notEqual(result.code, 0, "an existing directory is a read failure, not an absent log"); assert.equal(result.stdout, ""); }); diff --git a/src/microsandbox/runtime.ts b/src/microsandbox/runtime.ts index e760bcf..0fdd90c 100644 --- a/src/microsandbox/runtime.ts +++ b/src/microsandbox/runtime.ts @@ -249,7 +249,7 @@ export const MICROSANDBOX_RUN_ADMIT_SCRIPT = [ * Report one async run's outcome. * * Argument: `$1` run directory. Prints exactly one of `EXIT `, - * `RUNNING`, `MISSING`, or `LOST `. + * `RUNNING`, `MISSING`, `UNKNOWN `, or `LOST `. * * The exit file is checked first and again last: the wrapper writes it as its * final act, so re-reading after the liveness probe closes the window where a @@ -268,8 +268,9 @@ export const MICROSANDBOX_RUN_ADMIT_SCRIPT = [ * probe degrades to boot id + pid liveness — exactly the behaviour before start * time existed. The fallback is chosen by the ABSENCE of a recorded start time, * never by a failure to read the current one: if a start time was recorded and - * the current read fails while the pid is alive, the probe reports RUNNING - * rather than inventing a mismatch. + * the current read fails while the pid is alive, the probe emits an explicit + * UNKNOWN marker. That is neither proof of continued life nor proof of pid + * reuse, so the adapter turns it into a retryable status-probe error. * * @internal Exported only for the protocol tests (see above). */ @@ -303,8 +304,11 @@ export const MICROSANDBOX_RUN_STATUS_SCRIPT = [ // answer; without one the probe says RUNNING, as it did before. ' recorded=$(cat "$dir/start" 2>/dev/null) || recorded=""', ' if [ -n "$recorded" ]; then', - " current=$(msb_starttime \"$proc_root\" \"$run_pid\") || current=''", - ' if [ -n "$current" ] && [ "$current" != "$recorded" ]; then', + ' if ! current=$(msb_starttime "$proc_root" "$run_pid"); then', + ' printf "UNKNOWN starttime-unreadable\\n"', + " exit 0", + " fi", + ' if [ "$current" != "$recorded" ]; then', ' printf "LOST pid-reused\\n"', " exit 0", " fi", @@ -347,8 +351,13 @@ export const MICROSANDBOX_RUN_LOG_SCRIPT = [ "cap=$2", // Absent is not an error: a run that has not yet written its first byte, and // a run that printed nothing at all, both legitimately have no log. - 'if [ ! -f "$path" ]; then exit 0; fi', - 'tail -c "$cap" "$path"', + // `-f` alone cannot make that distinction: it is false for BOTH a missing + // path and an existing directory/device/socket. A symlink is rejected too; + // the run protocol writes a regular file at this exact path. + 'if [ -L "$path" ]; then exit 1; fi', + 'if [ -f "$path" ]; then exec tail -c "$cap" "$path"; fi', + 'if [ -e "$path" ]; then exit 1; fi', + "exit 0", ].join("\n"); // --- structural microsandbox SDK surface (microsandbox@0.6.11) -------------- @@ -774,10 +783,10 @@ export class MicrosandboxUnknownOutcomeError extends Error { * * Distinct from {@link MicrosandboxRunLostError}, which IS a verdict — the run * is over and cannot complete. This error says the adapter learned NOTHING: - * the guest call failed, or it answered something this protocol does not - * define. Returning "still running" for either would be a positive claim the - * probe never made, and a poll loop reading it would wait for an outcome that - * may already have happened. + * the guest call failed, a required guest-state read was unavailable, or it + * answered something this protocol does not define. Returning "still running" + * for any of those would be a positive claim the probe never made, and a poll + * loop reading it would wait for an outcome that may already have happened. * * It is safe to retry: nothing about the run was changed by asking. */ @@ -785,7 +794,10 @@ export class MicrosandboxStatusProbeError extends Error { override readonly name = "MicrosandboxStatusProbeError"; readonly sessionId: string; readonly commandId: string; - /** `"transport"` — the probe call itself failed. `"unrecognized"` — it answered off-protocol. */ + /** + * `"transport"` — the probe call or a required guest-state read failed. + * `"unrecognized"` — it answered off-protocol. + */ readonly reason: "transport" | "unrecognized"; /** Retrying is harmless; the probe has no side effects on the run. */ readonly retryable = true; @@ -1206,9 +1218,18 @@ async function openBackendScope( }), ); } catch (error) { - // A synchronous failure to push the scope — the SDK's own missing-native- - // bindings guard throws exactly like this. Nothing ran, so free the gate - // rather than wedging every later call in the process. + if (callbackRan) { + // The SDK entered our callback and then threw on the SAME stack while + // restoring the previous process default. `Promise.resolve(...)` + // never receives a value in this form, so the asynchronous rejection + // handler below cannot observe it; callback entry is nevertheless the + // decisive proof that the global slot was changed and is now unknown. + backendGatePoisoned = true; + backendGatePoisonCause = error; + } + // Pre-callback, this is a harmless synchronous failure to push the scope. + // Post-callback, freeing the now-poisoned gate wakes queued callers so + // they fail immediately with MicrosandboxBackendPoisonedError. abandonBackendScope(scope); throw error; } @@ -1830,7 +1851,17 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { await pending; } const workdir = options.workdir ?? this.defaultWorkdir; + const timeoutMs = options.createTimeoutSeconds + ? options.createTimeoutSeconds * 1000 + : undefined; + const controller = timeoutMs === undefined ? undefined : new AbortController(); + let createStarted = false; const create = this.withBackendStatic(async (sdk) => { + // Once this flips, `builder.create()` is reached in the same synchronous + // turn: every preceding builder method is synchronous. A deadline after + // this point cannot cancel provider work, so the late-create watcher + // below must retain responsibility for its eventual outcome. + createStarted = true; let builder = sdk.Sandbox.builder(name); builder = this.snapshot ? builder.fromSnapshot(this.snapshot) @@ -1864,19 +1895,18 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { builder = builder.replace(); } return builder.create(); - }); + }, controller?.signal); // The builder has no create-timeout setter, and `maxDuration` is a sandbox // LIFETIME budget — mapping the caller's boot deadline onto it would kill // every long-lived sandbox the moment that deadline elapsed. So the create // deadline is enforced here instead. let sandbox: MsbSandbox; - if (options.createTimeoutSeconds) { - const timeoutMs = options.createTimeoutSeconds * 1000; + if (timeoutMs !== undefined) { try { - sandbox = await withDeadline(create, timeoutMs, name); + sandbox = await withDeadline(create, timeoutMs, name, controller); } catch (error) { - if (error instanceof MicrosandboxCreateTimeoutError) { + if (error instanceof MicrosandboxCreateTimeoutError && createStarted) { this.reclaimLateCreate(name, create); } throw error; @@ -2139,6 +2169,14 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { if (marker === "RUNNING") { return { exitCode: null }; } + if (marker === "UNKNOWN starttime-unreadable") { + throw new MicrosandboxStatusProbeError( + sessionId, + commandId, + "transport", + "the run recorded a process start time, but its current start time could not be read from procfs", + ); + } if (marker === "MISSING") { throw new MicrosandboxRunLostError( sessionId, @@ -2755,16 +2793,22 @@ async function withDeadline( promise: Promise, timeoutMs: number, sandboxName: string, + controller?: AbortController, ): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ promise, new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new MicrosandboxCreateTimeoutError(sandboxName, timeoutMs)), - timeoutMs, - ); + timer = setTimeout(() => { + const error = new MicrosandboxCreateTimeoutError(sandboxName, timeoutMs); + // A caller still waiting for the backend gate has not issued any + // provider work, so aborting withdraws it from admission entirely. + // Once its callback started, AbortSignal intentionally cannot cancel + // the provider promise; reclaimLateCreate keeps watching that path. + controller?.abort(error); + reject(error); + }, timeoutMs); }), ]); } finally { From d390fc117ab76f04151da65235bcea1dc68f1d91 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 20 Aug 2026 19:07:30 +0200 Subject: [PATCH 07/10] fix(microsandbox): backend-safe destroy on cloud, and observable reclamation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destroy path could return an error to the caller while leaving a running sandbox on the hosted backend, and the late-create reclamation could silently drop the same failure. Both were reproduced against the live Microsandbox cloud on head f56e748: a normal destroy returned an error after 759ms and the sandbox was still `running` 47s later; a late-create reclamation completed without a signal and the sandbox was still `running` 90s later. ROOT CAUSE `forceDestroy` called `SandboxHandle.kill()` unconditionally. The hosted backend does not implement kill and answers it with `UnsupportedError` (code `"unsupported"`), and the catch at src/microsandbox/runtime.ts:2398 only pardoned `sandboxNotFound`/`alreadyStopped` — so the error re-threw before `remove()` was ever reached. Local backend supports kill, so all mocked/contract tests and the local smoke were blind to it. The `reclaimLateCreate` catch was even worse: it swallowed every teardown failure, so on the hosted backend the "a late create is reclaimed" promise the caller reads in the timeout error was structurally false with no signal anywhere. FIX `forceDestroy` now tries `kill()` (stronger where supported), and on `Unsupported`/`UnsupportedOperation` falls back to `stop()`, then `remove()`. `already-stopped`/`not-found` on the fallback are treated the same way as they are on the first step — both mean the sandbox is quiescent by the time `remove()` runs. Any other error on the fallback re-throws, because remove-of-a-running-sandbox would fail on the provider anyway and the caller retains responsibility. The reclamation's silent catch is replaced by an optional `onReclaimFailure(name, error)` runtime option. The hook is called synchronously inside the reclamation catch, so a throwing hook still fails here rather than surprising an unrelated caller of `launch`; a hook that throws is itself swallowed so it cannot escalate a background failure into a process-level unhandled rejection. TESTS Nine new mocked cases in the `destroy` and late-create suites reproduce the SDK-shaped `UnsupportedError` (both `kill` and stop-fallback) and assert the destroy still tears the sandbox down inside the test body — NOT in an after-hook. The existing smoke's after-hook (runtime.test.ts: 4646-4653) swallowed cleanup errors, which is why the leak hid; the new assertions are on the synchronous return path of `runtime.destroy`. VERIFICATION - Typecheck + full unit suite: 358 tests, 356 pass, 0 fail, 2 live-gated skips. - Live must-fire / must-not-fire probe against Microsandbox cloud (predicate = `Sandbox.listWith` + `Sandbox.get` read directly off the SDK, n=1 per scenario): * predicate self-test: PASS (a leak WOULD be seen) * normal destroy: PASS — provider confirms gone within 5s (down from never-gone on head f56e748) * late-create reclamation: PASS — reclaimed within 15s (down from never-reclaimed on head f56e748) - External account listing after probe: 0 sandboxes. BASE Rebased onto merged main (feat(e2b): implement full runtime parity, #12). Overlap resolved in favour of the merged E2B contract per lead's ruling: `e2b` peerDep tightened to `>=2.35.0 <3.0.0`, microsandbox peerDep added alongside. Other `main` changes to `types.ts`/`port.ts` (`IsolationLevel: 'unknown'`, `RunScriptResult.truncated`, `AsyncRunStartResult.reconciled`) are unchanged by this commit. Session-Id: sandbox-10-destroy-leak-0820 Session-Id: 6d810ec8-fbbc-43f2-8ef2-bc87744ca04f --- package-lock.json | 6 +- src/microsandbox/runtime.test.ts | 295 +++++++++++++++++++++++++++++++ src/microsandbox/runtime.ts | 119 ++++++++++++- 3 files changed, 411 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index a03b36d..d80d9c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ }, "peerDependencies": { "@daytonaio/sdk": ">=0.180.0 <0.181.0", - "e2b": ">=2.35.0 <3.0.0" + "e2b": ">=2.35.0 <3.0.0", + "microsandbox": ">=0.6.11 <0.7.0" }, "peerDependenciesMeta": { "@daytonaio/sdk": { @@ -29,6 +30,9 @@ }, "e2b": { "optional": true + }, + "microsandbox": { + "optional": true } } }, diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts index 08dd16f..6e5e035 100644 --- a/src/microsandbox/runtime.test.ts +++ b/src/microsandbox/runtime.test.ts @@ -2133,6 +2133,177 @@ describe("create deadline", () => { ); assert.deepEqual(order, ["builder.create", "handle.remove", "builder.create"]); }); + + // ------------------------------------------------------------------------- + // The late-create reclamation used to swallow every teardown error in a + // bare `try {} catch {}`, so a hosted-backend leak (kill unsupported → + // rethrow → catch) surfaced with zero signal. The `onReclaimFailure` hook + // makes the failure observable while keeping the reclamation promise + // detached (so a hook throwing does not become an unhandled rejection). + // + // The must-fire (control) is the reclamation-fails path: with a broken + // teardown the hook receives the error. The must-not-fire is the happy + // path: with a working teardown the hook is never called, because there + // was no failure to report. + // ------------------------------------------------------------------------- + it("reports a failed reclamation through onReclaimFailure instead of swallowing it", async () => { + let finishCreate!: () => void; + const { sdk } = createHarness({ + get: (name) => ({ + name, + // Reproduces the cloud shape without a second-step fallback: the + // reclamation MUST fail so the hook is what proves the failure was + // observable at all. + onKill: async () => { + throw sdkError("unsupported", "kill not supported"); + }, + onStop: async () => { + throw sdkError("runtime", "backend refused stop"); + }, + }), + onCreate: () => + new Promise((resolve) => { + finishCreate = resolve; + }), + }); + const observations: Array<{ name: string; code?: unknown; message?: string }> = []; + const runtime = makeRuntime(sdk, { + onReclaimFailure: (name, error) => { + observations.push({ + name, + ...(typeof error === "object" && error !== null && "code" in error + ? { code: (error as { code?: unknown }).code } + : {}), + ...(error instanceof Error ? { message: error.message } : {}), + }); + }, + }); + await assert.rejects( + runtime.launch({ name: "wedged", createTimeoutSeconds: 0.02 }), + MicrosandboxCreateTimeoutError, + ); + finishCreate(); + await waitFor(() => observations.length > 0, "the reclamation to notify the hook"); + assert.equal(observations.length, 1); + assert.equal(observations[0]?.name, "wedged"); + // The hook is CALLED WITH the underlying error object, so a downstream + // logger can dispatch by SDK error code — the machine-readable half of + // the observability guarantee. + assert.equal(observations[0]?.code, "runtime"); + }); + + it("does not call onReclaimFailure when the reclamation succeeds", async () => { + // Must-not-fire control. If the hook fires on a happy reclamation, every + // operator that wires it up as a leak alert starts paging on non-leaks. + let finishCreate!: () => void; + const { sdk } = createHarness({ + get: (name) => ({ name }), + onCreate: () => + new Promise((resolve) => { + finishCreate = resolve; + }), + }); + const observations: string[] = []; + const runtime = makeRuntime(sdk, { + onReclaimFailure: (name) => { + observations.push(name); + }, + }); + await assert.rejects( + runtime.launch({ name: "slow", createTimeoutSeconds: 0.02 }), + MicrosandboxCreateTimeoutError, + ); + finishCreate(); + // Wait for the reclamation to actually complete, otherwise a fast + // must-not-fire misses the failure it was supposed to catch. + await waitFor(() => { + // A working reclamation calls kill+remove; wait for the last step. + return true; + }, "reclamation to progress"); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.deepEqual(observations, []); + }); + + it("does not surface a throwing hook as an unhandled rejection", async () => { + // A misbehaving onReclaimFailure must not escalate a background + // reclamation failure into a process-level crash — the reclamation + // promise is detached and any rejection here has no `.catch` to reach. + let finishCreate!: () => void; + const { sdk } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + throw sdkError("unsupported", "kill not supported"); + }, + onStop: async () => { + throw sdkError("runtime", "backend refused stop"); + }, + }), + onCreate: () => + new Promise((resolve) => { + finishCreate = resolve; + }), + }); + const rejections: unknown[] = []; + const record = (reason: unknown) => { + rejections.push(reason); + }; + process.on("unhandledRejection", record); + try { + const runtime = makeRuntime(sdk, { + onReclaimFailure: () => { + throw new Error("hook exploded"); + }, + }); + await assert.rejects( + runtime.launch({ name: "boom", createTimeoutSeconds: 0.02 }), + MicrosandboxCreateTimeoutError, + ); + finishCreate(); + // Two macrotask turns: Node schedules unhandled-rejection reports one + // tick after the promise settles. + await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual(rejections, [], "a throwing hook must not escape the reclamation catch"); + } finally { + process.off("unhandledRejection", record); + } + }); + + it("reclaims through the fallback stop when the backend refuses kill", async () => { + // End-to-end regression matching what the live probe checks against + // Microsandbox cloud: launch with a boot deadline shorter than the SDK + // create, watch the reclamation land through the stop+remove fallback, + // and confirm no hook was fired because nothing failed. + let finishCreate!: () => void; + const { sdk, log } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + throw sdkError("unsupported", "kill not supported"); + }, + }), + onCreate: () => + new Promise((resolve) => { + finishCreate = resolve; + }), + }); + const observations: string[] = []; + const runtime = makeRuntime(sdk, { + onReclaimFailure: (name) => { + observations.push(name); + }, + }); + await assert.rejects( + runtime.launch({ name: "recover", createTimeoutSeconds: 0.02 }), + MicrosandboxCreateTimeoutError, + ); + finishCreate(); + await waitFor(() => called(log, "handle.remove"), "the fallback stop+remove to complete"); + assert.deepEqual(firstArgs(log, "handle.stop"), ["recover"]); + assert.deepEqual(firstArgs(log, "handle.remove"), ["recover"]); + assert.deepEqual(observations, [], "a successful reclamation must not page anyone"); + }); }); describe("label lookup", () => { @@ -3902,6 +4073,130 @@ describe("destroy", () => { await runtime.runScript({ id: "s1" }, { command: "true" }); assert.equal(called(log, "handle.connectWithTimeout"), true); }); + + // ------------------------------------------------------------------------- + // Cloud-backend fidelity: the hosted backend does not implement + // `SandboxHandle.kill` and answers it with `UnsupportedError` (code + // `"unsupported"`, message `"Sandbox::kill is not supported by this + // backend: use Sandbox::stop"`). The previous forceDestroy re-threw that + // error before `remove()` was ever reached, so every cloud destroy left the + // sandbox running. These tests reproduce the SDK-shaped failure the cloud + // backend surfaces and assert the destroy still tears the sandbox down. + // + // The assertions run IN THE TEST BODY. The regression the smoke suite + // missed at src/microsandbox/runtime.test.ts:4646-4653 was hidden by an + // `after()` that swallowed cleanup errors, so the resource stayed on the + // provider without any test going red. Every check below is on the + // synchronous return path of `runtime.destroy`, not on an after-hook. + // ------------------------------------------------------------------------- + it("falls back to stop+remove when the backend answers kill with Unsupported", async () => { + const { sdk, log } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + throw sdkError("unsupported", "Sandbox::kill is not supported by this backend: use Sandbox::stop"); + }, + }), + }); + const runtime = await ownedRuntime(sdk); + // Must NOT throw — a rejection here is the exact leak PR #10 was blocked + // on: the sandbox was left running and no `remove` ever fired. + await runtime.destroy({ id: "s1" }); + // Order matters: kill is tried first (cheaper on backends that support + // it), then stop as the fallback, then remove clears the record. If the + // fallback stop is missing, remove would run against a still-running + // sandbox and the provider would reject it. + const teardown = names(log).filter( + (n) => n === "handle.kill" || n === "handle.stop" || n === "handle.remove", + ); + assert.deepEqual(teardown, ["handle.kill", "handle.stop", "handle.remove"]); + }); + + it("also falls back on UnsupportedOperation, which the SDK reserves for related refusals", async () => { + // Not observed on cloud today, but the SDK's `MicrosandboxErrorCode` + // enumerates both `"unsupported"` and `"unsupportedOperation"` under the + // same neighborhood, so the fallback classifier accepts either. + const { sdk, log } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + throw sdkError("unsupportedOperation", "operation not supported"); + }, + }), + }); + const runtime = await ownedRuntime(sdk); + await runtime.destroy({ id: "s1" }); + assert.equal(called(log, "handle.stop"), true); + assert.equal(called(log, "handle.remove"), true); + }); + + it("still removes when the fallback stop reports the sandbox already stopped", async () => { + // The stop fallback ONLY runs on kill-unsupported, so the stop path also + // has to accept `already-stopped`/`not-found` — a common race with an + // idle-timeout that stopped the sandbox in the window between the two + // calls. Otherwise the removed-name would not be reclaimed. + const { sdk, log } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + throw sdkError("unsupported", "kill not supported"); + }, + onStop: async () => { + throw new Error("sandbox is already stopped"); + }, + }), + }); + const runtime = await ownedRuntime(sdk); + await runtime.destroy({ id: "s1" }); + assert.equal(called(log, "handle.remove"), true); + }); + + it("does not remove when the fallback stop fails for any other reason", async () => { + // A stop that fails for an unrelated reason means the sandbox may still be + // running — remove would then fail against a running sandbox and orphan + // its record. Surface the underlying error so the caller retains + // responsibility. + const { sdk, log } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + throw sdkError("unsupported", "kill not supported"); + }, + onStop: async () => { + throw sdkError("runtime", "backend refused stop"); + }, + }), + }); + const runtime = await ownedRuntime(sdk); + await assert.rejects(runtime.destroy({ id: "s1" }), /backend refused stop/); + assert.equal(called(log, "handle.remove"), false); + }); + + it("keeps ownership when the cloud teardown fails so the caller can retry", async () => { + // Same guarantee as the local `hypervisor wedged` case: after a failed + // teardown, the registry still marks the sandbox as owned, so a retry + // can still tear it down. Cloud-shaped this time: kill throws + // `unsupported`, stop throws `runtime`. + let failNext = true; + const { sdk, log } = createHarness({ + get: (name) => ({ + name, + onKill: async () => { + throw sdkError("unsupported", "kill not supported"); + }, + onStop: async () => { + if (failNext) { + failNext = false; + throw sdkError("runtime", "backend refused stop"); + } + }, + }), + }); + const runtime = await ownedRuntime(sdk); + await assert.rejects(runtime.destroy({ id: "s1" }), /backend refused stop/); + await runtime.destroy({ id: "s1" }); + assert.equal(called(log, "handle.remove"), true, "the retry must still be allowed to delete"); + }); }); describe("reattach", () => { diff --git a/src/microsandbox/runtime.ts b/src/microsandbox/runtime.ts index 0fdd90c..c340ba5 100644 --- a/src/microsandbox/runtime.ts +++ b/src/microsandbox/runtime.ts @@ -549,6 +549,27 @@ export type MicrosandboxRuntimeOptions = { backendQueueTimeoutMs?: number; /** Injection seam for tests / a wrapping adapter — defaults to lazy `import("microsandbox")`. */ sdk?: MicrosandboxSdk; + /** + * Called when the reclamation of a late-succeeded create — the one triggered + * by `MicrosandboxCreateTimeoutError` — could not tear the sandbox down. + * + * The reclamation path used to swallow every error silently, on the theory + * that a leaked microVM is at worst best-effort waste. It is not: a + * silently swallowed teardown is a leak that no downstream observer can see + * or bill, which is exactly the failure mode PR #10 was blocked on. Now the + * silent catch is replaced by this hook, called synchronously with the + * sandbox NAME (never argv, never a secret) and the underlying error, so + * consumers can log, page, or re-queue an operator cleanup. The reclamation + * promise still resolves — the caller of `launch` already has its typed + * timeout error, and throwing here would produce an unhandled rejection + * on a promise the caller cannot await. + * + * The hook is CALLED SYNCHRONOUSLY inside the reclamation's async catch, + * so it must not throw or the runtime will emit an unhandled rejection. Any + * async follow-up (log flush, alert dispatch) should be scheduled by the + * hook and awaited elsewhere. + */ + onReclaimFailure?: (sandboxName: string, error: unknown) => void; }; /** @@ -1532,6 +1553,7 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { private readonly listPageSize: number; private readonly backendQueueTimeoutMs: number; private readonly injectedSdk?: MicrosandboxSdk; + private readonly onReclaimFailure?: (sandboxName: string, error: unknown) => void; private sdkPromise?: Promise; // What this runtime knows about each sandbox name: the live instance @@ -1632,6 +1654,9 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { if (options.sdk !== undefined) { this.injectedSdk = options.sdk; } + if (options.onReclaimFailure !== undefined) { + this.onReclaimFailure = options.onReclaimFailure; + } } // --- lookup -------------------------------------------------------------- @@ -1939,10 +1964,32 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { async () => { try { await this.forceDestroy(name); - } catch { - // Best effort. The sandbox stays addressable under its deterministic - // name, so an operator (or a later launch of the same name) can still - // reclaim it. + } catch (error) { + // The reclamation cannot re-throw here — the promise it lives on is + // detached and no caller is awaiting it, so a rejection becomes an + // unhandled rejection instead of ever reaching a `.catch`. But + // "cannot re-throw" was previously implemented as SILENTLY DROP, + // which is what let the cloud-backend destroy leak land: the caller + // saw a clean timeout error, the sandbox stayed running, and no + // observer was told. The hook makes the failure recoverable in the + // sense that actually matters — a downstream logger or pager sees + // it — while a later `launch` of the same name still waits on + // `pendingReclaims` and gets a shot at reclaiming it. + // + // Called INSIDE this catch so a throwing hook still crashes here + // rather than in the caller — a runtime option's misbehaviour must + // not surprise an unrelated caller of `launch`. + if (this.onReclaimFailure) { + try { + this.onReclaimFailure(name, error); + } catch { + // Deliberately swallowed: a hook that throws its own error must + // not be able to escalate a background reclamation failure into + // an unhandled rejection. The original teardown failure is + // already visible via any prior hook call and via the sandbox + // still being addressable by name on the provider. + } + } } }, () => { @@ -2379,11 +2426,30 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { } /** - * Kill + remove by name, with no ownership check. + * Stop-if-running + remove by name, with no ownership check. * * Private on purpose: the only callers are `destroy` (which has already * checked) and the reclamation of a create that landed after its deadline * (which is reclaiming a sandbox this runtime itself asked for). + * + * Backend-safe order — this is what makes the destroy path work on the + * hosted cloud backend. `SandboxHandle.kill()` is not implemented there: + * the SDK answers it with `UnsupportedError` (code `"unsupported"`, + * message `"Sandbox::kill is not supported by this backend: use + * Sandbox::stop"`), and the previous code re-threw that error before + * `remove()` was ever reached, so every cloud destroy left a running + * sandbox behind. `stop()` works on both backends, so the sequence is: + * + * 1. Try `kill()` — a stronger, faster teardown that local supports. + * 2. If the backend answers `Unsupported`, fall back to `stop()` — the + * call the hosted backend documents. + * 3. Either way, `remove()` clears the record (which requires the + * sandbox to be stopped, and cannot delete a still-running one). + * + * `already-stopped` and `not-found` on the first-step call are both fine + * because they mean the same thing for the caller: the sandbox is + * quiescent by the time `remove()` runs. Any other error is fatal to the + * teardown and re-thrown, so the caller retains responsibility. */ private async forceDestroy(name: string): Promise { const entry = await this.lookupHandle(name); @@ -2393,9 +2459,22 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { try { await entry.kill(); } catch (error) { - // `remove` requires a stopped sandbox; a kill that failed because it was - // already stopped must not block the removal that frees the name. - if (!isSandboxNotFound(error) && !isAlreadyStopped(error)) { + if (isSandboxNotFound(error) || isAlreadyStopped(error)) { + // Already quiescent; drop to remove. + } else if (isUnsupportedByBackend(error)) { + // Hosted backend path: kill is not implemented, so `stop` is the + // documented equivalent. A `not-found`/`already-stopped` here is + // still fine — same reasoning as the kill catch above. Any other + // error means the sandbox is NOT quiescent and remove would fail + // anyway, so we surface it. + try { + await entry.stop(); + } catch (stopError) { + if (!isSandboxNotFound(stopError) && !isAlreadyStopped(stopError)) { + throw stopError; + } + } + } else { throw error; } } @@ -2768,6 +2847,30 @@ function isAlreadyStopped(error: unknown): boolean { return typeof message === "string" && /already\s+stopped|not\s+running/i.test(message); } +/** + * True when the SDK signals the backend does not implement a lifecycle call + * the adapter tried to use, e.g. `SandboxHandle.kill` on the hosted + * (cloud) backend which throws `UnsupportedError` with code `"unsupported"`. + * + * Uses the SDK's typed code first (authoritative when present, per + * `MicrosandboxErrorCode` in `errors.d.ts`), and only falls back to the + * error name when a producer forwarded the exception without preserving the + * code — the message text is deliberately not inspected because the + * caller-visible sentence "use Sandbox::stop" is a formatting detail of one + * SDK version, not an interoperable contract. + */ +function isUnsupportedByBackend(error: unknown): boolean { + if (typeof error !== "object" || error === null) { + return false; + } + const code = (error as { code?: unknown }).code; + if (typeof code === "string") { + return code === "unsupported" || code === "unsupportedOperation"; + } + const name = (error as { name?: unknown }).name; + return name === "UnsupportedError" || name === "UnsupportedOperationError"; +} + /** Turn the status script's `LOST ` token into something a human reads. */ function describeLostReason(reason: string): string { if (reason === "sandbox-restarted") { From cad92bcdca5ad10538d51d11969e665d9618af70 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 21 Aug 2026 23:39:45 +0200 Subject: [PATCH 08/10] test(microsandbox): pin resolved capability modes to all-unknown The merge with main's structured capability-modes feature (#17) added a modes field to resolveSandboxRuntimeCapabilities()'s return value that this test predates. Same fix main already applied to E2B's equivalent guard. Session-Id: e9385da2-3504-42c7-9668-5f0129c683dd --- src/microsandbox/runtime.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts index 6e5e035..15ff3bd 100644 --- a/src/microsandbox/runtime.test.ts +++ b/src/microsandbox/runtime.test.ts @@ -4284,6 +4284,16 @@ describe("capabilities", () => { detachedLaunch: true, warmLease: true, lifecycle: true, + // This adapter declares no capability modes, so every one resolves to + // "unknown". Filling them in here would be a claim about microsandbox + // that no live probe in this repository supports. + modes: { + outputStreams: "unknown", + filesystem: "unknown", + lifetime: "unknown", + interactive: "unknown", + snapshots: "unknown", + }, }); }); From 1e308f565c57bed08d89fe2284cbcaac9f071768 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 21 Aug 2026 23:43:42 +0200 Subject: [PATCH 09/10] fix(microsandbox): reject unicode-split session ids and unknown-exit-code log reads Three review findings from cubic-dev-ai and chatgpt-codex-connector on this PR, verified against current behavior and fixed: - encodeRunSegment: the encoding regex lacked the /u flag, so it walked UTF-16 code units instead of code points. An astral session id (e.g. an emoji) was split into its two surrogate halves, each of which Buffer.from(..., "utf8") independently maps to U+FFFD - collapsing every distinct astral session id onto the same encoded run directory. Verified with a standalone repro before fixing. - readRunLog: a guest read that completed without a numeric exit code fell through to treating stdout as successful output, contradicting this file's own stated discipline (see MicrosandboxUnknownOutcomeError) that an unreported outcome must never be defaulted to success. Now raises MicrosandboxLogReadError, matching the sibling non-zero-exit case. - Left findByLabels's limit-as-page-size-only behavior unchanged: Daytona and E2B's findByLabels also never treat limit as a result cap (only findAllByLabels/countByLabels do), so this is established, consistent behavior across all three adapters, not a microsandbox-specific bug. Also fixed the must-not-fire reclamation test that predicate-trapped on `() => true` (resolves on the first poll) instead of waiting for the actual handle.remove signal used by every sibling reclamation test. Session-Id: e9385da2-3504-42c7-9668-5f0129c683dd --- src/microsandbox/runtime.test.ts | 44 ++++++++++++++++++++++++++------ src/microsandbox/runtime.ts | 13 ++++++++-- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts index 15ff3bd..185c89a 100644 --- a/src/microsandbox/runtime.test.ts +++ b/src/microsandbox/runtime.test.ts @@ -2196,7 +2196,7 @@ describe("create deadline", () => { // Must-not-fire control. If the hook fires on a happy reclamation, every // operator that wires it up as a leak alert starts paging on non-leaks. let finishCreate!: () => void; - const { sdk } = createHarness({ + const { sdk, log } = createHarness({ get: (name) => ({ name }), onCreate: () => new Promise((resolve) => { @@ -2214,13 +2214,9 @@ describe("create deadline", () => { MicrosandboxCreateTimeoutError, ); finishCreate(); - // Wait for the reclamation to actually complete, otherwise a fast - // must-not-fire misses the failure it was supposed to catch. - await waitFor(() => { - // A working reclamation calls kill+remove; wait for the last step. - return true; - }, "reclamation to progress"); - await new Promise((resolve) => setTimeout(resolve, 30)); + // Wait for the reclamation to actually complete (kill+remove), otherwise + // a fast must-not-fire misses the failure it was supposed to catch. + await waitFor(() => called(log, "handle.remove"), "reclamation to complete"); assert.deepEqual(observations, []); }); @@ -3163,6 +3159,20 @@ describe("startScript (durable async exec)", () => { assert.notEqual(dirs[0], dirs[1], "two different session ids must never share a directory"); }); + it("keeps distinct astral session ids on distinct directories", async () => { + // A non-unicode-aware regex walks UTF-16 code units, not code points, so + // it would split an astral character into its two surrogate halves and + // encode each one separately. `Buffer.from` turns a lone surrogate into + // the same replacement character regardless of which surrogate it was, + // so every astral character would collapse onto one encoded segment. + const { sdk, log } = createHarness({ get: (name) => ({ name }) }); + const runtime = makeRuntime(sdk); + await runtime.startScript({ id: "s1" }, { command: "true", sessionId: "😀" }); + await runtime.startScript({ id: "s1" }, { command: "true", sessionId: "🙃" }); + const dirs = argsFor(log, "exec.args").map((args) => (args[0] as string[])[4]); + assert.notEqual(dirs[0], dirs[1], "two different astral session ids must never share a directory"); + }); + it("escapes the percent sign it uses as the escape character", async () => { const { sdk, log } = createHarness({ get: (name) => ({ name }) }); const runtime = makeRuntime(sdk); @@ -3620,6 +3630,24 @@ describe("getScriptLogs", () => { ); }); + it("reports a log read with no exit code as a failure, not as success", async () => { + // A read that completed without a numeric exit code told us nothing about + // whether it succeeded. Defaulting to "success" here would hand the + // caller stdout it cannot trust as if it were a verified log. + const { sdk } = createHarness({ + get: (name) => ({ name }), + exec: () => ({ stdout: "maybe partial", stderr: "" }), + }); + await assert.rejects( + makeRuntime(sdk).getScriptLogs({ id: "s1" }, "sess-1", "cmd-1"), + (error: unknown) => { + assert.ok(error instanceof MicrosandboxLogReadError); + assert.match(error.message, /without an exit code/); + return true; + }, + ); + }); + it("still returns empty output for a log that is genuinely absent", async () => { // The one case where empty IS the truth: the run has written nothing yet. // It must stay distinguishable from the failures above by NOT throwing. diff --git a/src/microsandbox/runtime.ts b/src/microsandbox/runtime.ts index c340ba5..e69680d 100644 --- a/src/microsandbox/runtime.ts +++ b/src/microsandbox/runtime.ts @@ -2651,7 +2651,16 @@ export class MicrosandboxRuntime implements SandboxRuntime, WorkflowRuntime { error, ); } - if (typeof output.code === "number" && output.code !== 0) { + if (typeof output.code !== "number") { + throw new MicrosandboxLogReadError( + sessionId, + path, + `the read completed without an exit code, so its outcome is unknown: ${ + summarize(output.stderr() ?? "") + }`, + ); + } + if (output.code !== 0) { throw new MicrosandboxLogReadError( sessionId, path, @@ -2809,7 +2818,7 @@ function combineOutput(stdout: string, stderr: string): string { * never be confused with an encoded one. */ function encodeRunSegment(sessionId: string): string { - const encoded = sessionId.replace(/[^A-Za-z0-9_-]/g, (character) => + const encoded = sessionId.replace(/[^A-Za-z0-9_-]/gu, (character) => [...Buffer.from(character, "utf8")] .map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`) .join(""), From e5d902fee391e6c00964973943ec3e3c12c4f0a7 Mon Sep 17 00:00:00 2001 From: Miya Date: Fri, 21 Aug 2026 23:45:03 +0200 Subject: [PATCH 10/10] fix(microsandbox): use unknownCode harness flag in the new log-read test The exec mock only produces an undefined code via the unknownCode flag; an absent code field defaults to 0 (`outcome.code ?? 0`), so the previous test body exercised the already-covered zero-exit path instead of the new unknown-exit-code guard. Session-Id: e9385da2-3504-42c7-9668-5f0129c683dd --- src/microsandbox/runtime.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/microsandbox/runtime.test.ts b/src/microsandbox/runtime.test.ts index 185c89a..9d51edb 100644 --- a/src/microsandbox/runtime.test.ts +++ b/src/microsandbox/runtime.test.ts @@ -3636,7 +3636,7 @@ describe("getScriptLogs", () => { // caller stdout it cannot trust as if it were a verified log. const { sdk } = createHarness({ get: (name) => ({ name }), - exec: () => ({ stdout: "maybe partial", stderr: "" }), + exec: () => ({ unknownCode: true, stdout: "maybe partial", stderr: "" }), }); await assert.rejects( makeRuntime(sdk).getScriptLogs({ id: "s1" }, "sess-1", "cmd-1"),