diff --git a/docs/guide/sandboxes.md b/docs/guide/sandboxes.md index 3a84047d8..87ee211db 100644 --- a/docs/guide/sandboxes.md +++ b/docs/guide/sandboxes.md @@ -12,7 +12,7 @@ import { getSandbox } from 'void/sandbox'; export const POST = defineHandler(async (c) => { const { command } = await c.req.json<{ command: string }>(); - const sandbox = getSandbox('default'); + const sandbox = await getSandbox('default'); const result = await sandbox.exec(command); return c.json(result); @@ -58,7 +58,8 @@ Available fields: ```ts import { getSandbox } from 'void/sandbox'; -const sandbox = getSandbox(`user-${user.id}`); +// inside an async handler +const sandbox = await getSandbox(`user-${user.id}`); await sandbox.writeFile('/tmp/input.txt', 'hello'); const result = await sandbox.exec('cat /tmp/input.txt'); ``` diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 559fafa07..18db8ae58 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -664,7 +664,24 @@ export function voidPlugin(options?: { 'void/queues': 'export const queues = {};', 'void/storage': 'export const storage = {}; export const createStorage = () => ({});', 'void/isr': 'export const revalidate = async () => {};', - 'void/sandbox': 'export const sandbox = {}; export const getSandbox = () => ({});', + // Client stub must mirror the real runtime/sandbox.ts surface so any + // client-side `instanceof SandboxLimitError` check doesn't crash with a + // TypeError, and async `await getSandbox(...)` callers don't silently + // mis-handle a sync return. Throwing here makes accidental client imports + // fail loudly rather than returning a misleading bare `{}`. + 'void/sandbox': ` + export class SandboxLimitError extends Error { + constructor(reason = 'concurrency', message) { + super(message ?? 'Sandbox limit reached: ' + reason); + this.name = 'SandboxLimitError'; + this.reason = reason; + } + } + export async function getSandbox() { + throw new Error('void/sandbox is server-only and cannot be imported by browser code.'); + } + export const sandbox = { get: getSandbox }; + `, 'void/live': '', }; const STUB_RESOLVE_FILTER = new RegExp( diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index aaf537d90..4d4eab1ac 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -2299,7 +2299,7 @@ export function compileWorkerEntry( const sandboxRuntimePath = typeof sandbox === 'object' ? (sandbox.runtimePath ?? 'void/sandbox') : 'void/sandbox'; const sandboxImport = hasSandbox - ? `import { Sandbox as __VoidSandbox } from "@cloudflare/sandbox";\nimport { __setSandboxBindingName } from ${JSON.stringify(sandboxRuntimePath)};\n` + ? `import { VoidPlatformSandbox as __VoidSandbox, __setSandboxBindingName } from ${JSON.stringify(sandboxRuntimePath)};\n` : ''; const sandboxExport = hasSandbox ? sandboxClassName === 'Sandbox' diff --git a/packages/void/src/runtime/sandbox.ts b/packages/void/src/runtime/sandbox.ts index 1f3197c3a..fa576ef1b 100644 --- a/packages/void/src/runtime/sandbox.ts +++ b/packages/void/src/runtime/sandbox.ts @@ -3,6 +3,7 @@ import { getSandbox as getCloudflareSandbox, type SandboxOptions, } from '@cloudflare/sandbox'; +import { getRawRuntimeEnv } from './env-raw.ts'; import { requireRuntimeBinding } from './env.ts'; export { Sandbox }; @@ -10,26 +11,396 @@ export type { SandboxOptions }; export const DEFAULT_SANDBOX_ID = 'default'; export const DEFAULT_SANDBOX_BINDING = 'SANDBOX'; +export const SANDBOX_USAGE_QUEUE_BINDING = 'USAGE_EVENTS_QUEUE'; +export const SANDBOX_GATE_BINDING = '__VOID_PROXY'; + +// 5 min balances queue write volume against the crash-loss accounting window: +// at most one heartbeat-interval of container runtime is lost if the DO dies +// between flushes (orphan recovery in onStart() picks up the rest). +export const HEARTBEAT_MS: number = 5 * 60_000; -export type VoidSandbox = Sandbox; export type VoidSandboxOptions = SandboxOptions & { binding?: string }; +export type SandboxUsageEvent = Readonly<{ + // requests/responseBytes are literal zeros so the platform's existing + // `groupEventsByUser` consumer (which expects `UsageEvent`) can ingest + // sandbox runtime events without a separate shape. + requests: 0; + responseBytes: 0; + userId?: string; + projectId?: string; + slug?: string; + sandboxRuntimeSeconds: number; + sandboxInstanceId: string; +}>; + +/** + * Thrown by `getSandbox()` when the platform gate denies a new acquire because + * the user has hit a concurrency or runtime budget cap. Surface this to the + * caller so they can render a friendly error; do not retry transparently. + */ +export class SandboxLimitError extends Error { + override readonly name = 'SandboxLimitError'; + readonly reason: 'concurrency' | 'runtime_budget'; + + constructor(reason: 'concurrency' | 'runtime_budget', message?: string) { + super(message ?? `Sandbox limit reached: ${reason}`); + this.reason = reason; + } +} + let sandboxBindingName = DEFAULT_SANDBOX_BINDING; export function __setSandboxBindingName(bindingName: string): void { sandboxBindingName = bindingName; } -export function getSandbox( +// --- Storage keys --- + +const STORAGE_KEY_START_TS = '__voidStartTs'; +const STORAGE_KEY_LAST_FLUSH_TS = '__voidLastFlushTs'; + +// --- Internal helpers --- + +function getUsageContext(): { userId?: string; projectId?: string; slug?: string } { + // Resolve the raw env so we can read platform-injected bindings that the + // public `getRuntimeBinding()` helper intentionally hides from user code. + const runtimeEnv = getRawRuntimeEnv(); + return { + userId: runtimeEnv.__VOID_USER_ID as string | undefined, + projectId: runtimeEnv.__PROJECT_ID as string | undefined, + slug: runtimeEnv.__VOID_PROJECT_SLUG as string | undefined, + }; +} + +function getSandboxInstanceId(ctx: DurableObjectState): string { + return ctx.id.name ?? ctx.id.toString(); +} + +/** + * Flush the accumulated runtime delta to the usage queue. No-op when the + * usage-queue binding is not bound (e.g. local dev without the platform + * proxy). Errors are swallowed and logged — usage emission must never crash + * the sandbox lifecycle. + */ +async function emitRuntimeSeconds( + ctx: DurableObjectState, + sandboxRuntimeSeconds: number, +): Promise { + if (sandboxRuntimeSeconds <= 0) { + return; + } + const runtimeEnv = getRawRuntimeEnv(); + const queue = runtimeEnv[SANDBOX_USAGE_QUEUE_BINDING] as Queue | undefined; + if (!queue) { + return; + } + const event: SandboxUsageEvent = { + requests: 0, + responseBytes: 0, + sandboxRuntimeSeconds, + sandboxInstanceId: getSandboxInstanceId(ctx), + ...getUsageContext(), + }; + try { + await queue.send(event); + } catch (error) { + // Best-effort metering — never break the sandbox on queue failure. + console.error('[void/sandbox] failed to emit usage event:', error); + } +} + +/** + * Resolve the `{ gate, projectId, aiToken }` triple needed to call a gate + * route. Returns `null` when any piece is missing — local dev (no proxy + * binding) and partial deploys (binding present but identity vars absent) + * both fall through to the no-op path so a missing platform setup never + * blocks sandbox acquisition. + * + * Wire field is named `aiToken` because the shared proxy auth middleware + * (packages/proxy/src/index.ts) verifies HMAC against `body.aiToken` for + * every route. Naming it `proxyToken` here would diverge from the existing + * AI/ISR convention without changing the on-the-wire contract. + */ +function resolveGateIdentity(): { gate: Fetcher; projectId: string; aiToken: string } | null { + const runtimeEnv = getRawRuntimeEnv(); + const gate = runtimeEnv[SANDBOX_GATE_BINDING] as Fetcher | undefined; + if (!gate) { + return null; + } + const projectId = runtimeEnv.__PROJECT_ID as string | undefined; + const aiToken = runtimeEnv.__VOID_PROXY_TOKEN as string | undefined; + if (!projectId || !aiToken) { + return null; + } + return { gate, projectId, aiToken }; +} + +/** + * Release notification to the gate so it can drop the active lease counter. + * Returns the in-flight fetch as a Promise (errors swallowed) so callers can + * route it through `ctx.waitUntil()` and keep it alive past lifecycle + * teardown. Failure is silent — the gate maintains its own TTL fallback. + */ +function notifyGateRelease(ctx: DurableObjectState): Promise { + const identity = resolveGateIdentity(); + if (!identity) { + return Promise.resolve(); + } + const sandboxId = getSandboxInstanceId(ctx); + return identity.gate + .fetch('https://gate/sandbox/release', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + projectId: identity.projectId, + aiToken: identity.aiToken, + sandboxId, + }), + }) + .then(() => undefined) + .catch(() => { + // Best-effort — the gate has its own lease-expiry safety net. + }); +} + +/** + * Heartbeat-driven keepalive ping for the gate's concurrency slot. The DO's + * `STALE_MS` (1h) sweep would otherwise reap slots for long-running sandbox + * sessions whose `acquiredAt` ages past the threshold between acquire and + * release, letting new acquires exceed `sandboxMaxConcurrentInstances`. The + * 5 min heartbeat sits well inside the sweep window, so each successful + * refresh resets the stale clock. Returns the in-flight fetch as a Promise + * (errors swallowed) so callers can route it through `ctx.waitUntil()` and + * keep the alarm path itself off the critical path — a failed refresh just + * means the slot ages out naturally, which matches the pre-existing failure + * mode if the gate goes down entirely. + */ +function notifyGateRefresh(ctx: DurableObjectState): Promise { + const identity = resolveGateIdentity(); + if (!identity) { + return Promise.resolve(); + } + const sandboxId = getSandboxInstanceId(ctx); + return identity.gate + .fetch('https://gate/sandbox/refresh', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + projectId: identity.projectId, + aiToken: identity.aiToken, + sandboxId, + }), + }) + .then(() => undefined) + .catch(() => { + // Best-effort — a failed refresh just means the slot ages out via the + // gate's existing STALE_MS sweep. No retry, no surfaced error. + }); +} + +// --- VoidPlatformSandbox --- + +/** + * Sandbox variant that participates in Void platform metering: it records + * container runtime seconds into DO storage, emits periodic heartbeats to + * the usage queue, and notifies the gate when the container stops. + * + * Generated code from `void inference` wires user `Sandbox` subclasses to + * extend this class on platform deploys so per-tenant accounting works + * without user opt-in. On local dev (no usage-queue / gate bindings), all + * overrides degrade to no-ops and the sandbox behaves like the upstream + * `Sandbox` class. + */ +export class VoidPlatformSandbox extends Sandbox { + override async onStart(): Promise { + const now = Date.now(); + const stored = await this.ctx.storage.get([ + STORAGE_KEY_START_TS, + STORAGE_KEY_LAST_FLUSH_TS, + ]); + + // Crash recovery: if a stale start timestamp survives in storage, the + // previous instance died without onStop firing. Emit one reconciliation + // event covering the orphaned window so usage isn't undercounted, then + // reset the timestamps for the new run. + const orphanedAnchor = + stored.get(STORAGE_KEY_LAST_FLUSH_TS) ?? stored.get(STORAGE_KEY_START_TS); + if (orphanedAnchor !== undefined && orphanedAnchor < now) { + const sandboxRuntimeSeconds = (now - orphanedAnchor) / 1000; + await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds); + // Clear the orphan anchor BEFORE super.onStart() so a failed super + // (bad image, platform scheduling error) doesn't leave the same + // anchor in storage for the next retry to re-emit, double-counting + // runtime that's already been billed. + await this.ctx.storage.delete([STORAGE_KEY_START_TS, STORAGE_KEY_LAST_FLUSH_TS]); + } + + try { + await super.onStart(); + } catch (err) { + // super.onStart() failures (bad image, scheduling error) leave the + // gate's acquire counter stuck — `getSandbox()` POSTs to /acquire + // BEFORE the first method call that triggers onStart, so this is the + // first lifecycle hook that runs. Without releasing here, neither + // onStop nor onActivityExpired fires for a failed startup, and the + // lease leaks until the gate's STALE_MS sweep — surfacing as false + // concurrency-denied errors on the user's next retry. Route through + // waitUntil because Workers may already be tearing down during throw. + this.ctx.waitUntil(notifyGateRelease(this.ctx)); + throw err; + } + + // Persist start/flush timestamps and arm the heartbeat alarm only AFTER + // super.onStart() succeeds. If super throws (container failed to start), + // we must not leave anchors behind — the next onStart() would treat them + // as an orphaned window and bill wall-clock time during which no + // container ever ran. Re-read the clock so timestamps reflect the actual + // container-ready instant rather than the pre-super entry point. + // + // Heartbeat alarm is set after super for the same single-alarm-slot + // reason the alarm() override relies on: upstream Sandbox/Container may + // arm its own alarm (e.g. for sleepAfter idle management) and clobber + // ours if we set it first. + const startedAt = Date.now(); + await this.ctx.storage.put({ + [STORAGE_KEY_START_TS]: startedAt, + [STORAGE_KEY_LAST_FLUSH_TS]: startedAt, + }); + await this.ctx.storage.setAlarm(startedAt + HEARTBEAT_MS); + } + + override async alarm(alarmProps?: { isRetry: boolean; retryCount: number }): Promise { + // Container's alarm() dispatches any scheduled() tasks. Run it first so + // upstream behaviour (idle timeouts, user-scheduled work) is preserved + // before we layer our metering heartbeat on top. + await super.alarm(alarmProps); + + const lastFlush = await this.ctx.storage.get(STORAGE_KEY_LAST_FLUSH_TS); + if (typeof lastFlush !== 'number') { + // Heartbeat fired without an active start window — nothing to meter + // and no reason to re-arm (onStart() will set the alarm again). + return; + } + const now = Date.now(); + const sandboxRuntimeSeconds = Math.max(0, (now - lastFlush) / 1000); + await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds); + // Fire-and-forget keepalive to the gate's concurrency slot so the + // STALE_MS sweep doesn't reap long-running sessions. waitUntil keeps + // the request alive past this hook without gating heartbeat completion + // on the gate's response — heartbeat-emitted metering must always make + // forward progress even if the gate is unreachable. + this.ctx.waitUntil(notifyGateRefresh(this.ctx)); + await Promise.all([ + this.ctx.storage.put(STORAGE_KEY_LAST_FLUSH_TS, now), + this.ctx.storage.setAlarm(now + HEARTBEAT_MS), + ]); + } + + // Sandbox narrows Container's `onStop(params)` to zero-arg — exitCode/reason aren't visible here. + override async onStop(): Promise { + await this.flushFinal(); + // Route the release POST through waitUntil so it survives lifecycle + // teardown. A bare `void fetch(...)` here can be canceled when the hook + // returns, stranding the gate's acquire counter until TTL sweep — the + // exact false-concurrency-denial path this metering layer must avoid. + this.ctx.waitUntil(notifyGateRelease(this.ctx)); + await super.onStop(); + } + + override async onActivityExpired(): Promise { + await this.flushFinal(); + this.ctx.waitUntil(notifyGateRelease(this.ctx)); + await super.onActivityExpired(); + } + + private async flushFinal(): Promise { + const lastFlush = await this.ctx.storage.get(STORAGE_KEY_LAST_FLUSH_TS); + if (typeof lastFlush === 'number') { + const sandboxRuntimeSeconds = Math.max(0, (Date.now() - lastFlush) / 1000); + await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds); + } + await this.ctx.storage.delete(STORAGE_KEY_START_TS); + await this.ctx.storage.delete(STORAGE_KEY_LAST_FLUSH_TS); + } +} + +// --- getSandbox --- + +/** + * Resolve the platform-provided sandbox stub for the given id, optionally + * coordinating with the platform gate to enforce per-user concurrency and + * runtime budgets. + * + * When the gate binding (`__VOID_PROXY` by default) is present AND the + * platform's `__PROJECT_ID` + `__VOID_PROXY_TOKEN` identity vars are + * available, we POST to `https://gate/sandbox/acquire` with the HMAC-signed + * body the shared proxy auth middleware expects. A `409` response means the + * user has exceeded a budget — we throw `SandboxLimitError` so the caller + * can render a clear message. Any other non-OK status (or a thrown transport + * error from the service binding) is treated as "gate unavailable" and we + * fall through to returning the stub so a broken gate doesn't break sandbox + * access entirely. + * + * When the gate binding is absent (local dev) or the identity vars are + * missing (partial deploy), the gate check is skipped entirely and we + * return the stub directly. + * + * The stub is constructed BEFORE calling the gate so we can derive the + * normalized DO name (`stub.id.name`) and pass it to the gate as the + * `sandboxId`. This keeps acquire and release using the same key — without + * it, mixed-case caller ids (e.g. `MyProject-123`) would acquire a lease + * under the raw id but release it under the normalized lowercase form that + * `ctx.id.name` reports inside the DO, leaking the lease until TTL. + * Constructing the stub does NOT start a container — that only happens on + * the first method call against the stub. + */ +export async function getSandbox( id: string = DEFAULT_SANDBOX_ID, options?: VoidSandboxOptions, -): T { +): Promise { const { binding = sandboxBindingName, ...sandboxOptions } = options ?? {}; + const namespace = requireRuntimeBinding>(binding); - return getCloudflareSandbox(namespace, id, { + const stub = getCloudflareSandbox(namespace, id, { normalizeId: true, ...sandboxOptions, }); + // Use the stub's normalized DO id (matches what `ctx.id.name` reports + // inside the DO) so acquire and release agree on the lease key. + const sandboxId = (stub as unknown as DurableObjectStub).id.name ?? id; + + const identity = resolveGateIdentity(); + if (identity) { + try { + const res = await identity.gate.fetch('https://gate/sandbox/acquire', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + projectId: identity.projectId, + aiToken: identity.aiToken, + sandboxId, + }), + }); + if (res.status === 409) { + const body = (await res.json().catch(() => ({}))) as { reason?: string }; + const reason = + body.reason === 'concurrency' || body.reason === 'runtime_budget' + ? body.reason + : 'concurrency'; + throw new SandboxLimitError(reason); + } + // Non-OK non-409 falls through: a broken gate must not block sandbox + // acquisition; the gate's lease TTL backstops accounting. + } catch (error) { + if (error instanceof SandboxLimitError) { + throw error; + } + // Transport errors get the same fail-open treatment as a non-OK response. + } + } + + return stub; } export const sandbox: { get: typeof getSandbox } = { diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 0eb36fb68..96574a160 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -2244,26 +2244,56 @@ describe('compileWorkerEntry', () => { expect(code).toContain('__setLiveBindingName("VOID_LIVE_abcd1234");'); }); - it('re-exports the Cloudflare Sandbox Durable Object class when enabled', () => { + it('re-exports the VoidPlatformSandbox Durable Object class when enabled', () => { const code = compileWorkerEntry(false, undefined, 'assets', [], 'void/_env', undefined, true); - expect(code).toContain('import { Sandbox as __VoidSandbox } from "@cloudflare/sandbox"'); + // Platform sandbox wraps Cloudflare's Sandbox with metering/gate hooks. + // Importing it (instead of the bare Sandbox class) lets user subclasses + // inherit those overrides without opting in. + expect(code).toContain( + 'import { VoidPlatformSandbox as __VoidSandbox, __setSandboxBindingName } from "void/sandbox"', + ); + expect(code).not.toContain('from "@cloudflare/sandbox"'); expect(code).toContain('export { __VoidSandbox as Sandbox };'); expect(code).toContain('__setSandboxBindingName("SANDBOX");'); }); - it('exports the configured Cloudflare Sandbox Durable Object class', () => { + it('exports the configured Sandbox Durable Object subclass via VoidPlatformSandbox', () => { const code = compileWorkerEntry(false, undefined, 'assets', [], 'void/_env', undefined, { bindingName: 'CODE', className: 'CodeSandbox', runtimePath: 'void/sandbox', }); - expect(code).toContain('import { Sandbox as __VoidSandbox } from "@cloudflare/sandbox"'); + expect(code).toContain( + 'import { VoidPlatformSandbox as __VoidSandbox, __setSandboxBindingName } from "void/sandbox"', + ); + expect(code).not.toContain('from "@cloudflare/sandbox"'); + // User subclass syntax (`class X extends Sandbox`) stays unchanged because + // VoidPlatformSandbox itself extends Sandbox. expect(code).toContain('export class CodeSandbox extends __VoidSandbox {}'); expect(code).toContain('__setSandboxBindingName("CODE");'); }); + it('uses the resolved sandbox runtime path when one is provided', () => { + const code = compileWorkerEntry(false, undefined, 'assets', [], 'void/_env', undefined, { + runtimePath: '/abs/path/to/sandbox.mjs', + }); + + expect(code).toContain( + 'import { VoidPlatformSandbox as __VoidSandbox, __setSandboxBindingName } from "/abs/path/to/sandbox.mjs"', + ); + }); + + it('omits the sandbox import and export when sandbox is disabled', () => { + const code = compileWorkerEntry(); + + expect(code).not.toContain('__VoidSandbox'); + expect(code).not.toContain('VoidPlatformSandbox'); + expect(code).not.toContain('__setSandboxBindingName'); + expect(code).not.toContain('from "@cloudflare/sandbox"'); + }); + it('generates queue() export in worker entry when hasQueues=true', () => { const code = compileWorkerEntry(true); diff --git a/packages/void/test/unit/sandbox-runtime.test.ts b/packages/void/test/unit/sandbox-runtime.test.ts new file mode 100644 index 000000000..ac10b36af --- /dev/null +++ b/packages/void/test/unit/sandbox-runtime.test.ts @@ -0,0 +1,703 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +// `vi.mock` is hoisted above all imports, so the @cloudflare/sandbox mock +// must be installed before we import the runtime module. We expose a swappable +// `getSandboxImpl` so individual tests can change its behaviour without +// re-stubbing the module. +// +// The runtime now reads `stub.id.name` to derive the normalized sandbox id +// for the gate call, so the default impl returns a stub that mirrors the SDK +// shape: the id is lowercased when `normalizeId: true` is passed, matching +// the SDK's `effectiveId = sanitizedId.toLowerCase()` path. +function makeDefaultStub(...args: ReadonlyArray): { id: { name: string | undefined } } { + const callerId = args[1] as string | undefined; + const options = args[2] as { normalizeId?: boolean } | undefined; + const effectiveId = callerId && options?.normalizeId ? callerId.toLowerCase() : callerId; + return { id: { name: effectiveId } }; +} +let getSandboxImpl: (...args: ReadonlyArray) => unknown = makeDefaultStub; + +vi.mock('@cloudflare/sandbox', () => { + class FakeSandbox { + ctx: unknown; + env: unknown; + constructor(ctx: unknown, env: unknown) { + this.ctx = ctx; + this.env = env; + } + onStart(): Promise { + return Promise.resolve(); + } + onStop(): Promise { + return Promise.resolve(); + } + onActivityExpired(): Promise { + return Promise.resolve(); + } + alarm(_props?: { isRetry: boolean; retryCount: number }): Promise { + return Promise.resolve(); + } + } + return { + Sandbox: FakeSandbox, + getSandbox: (...args: ReadonlyArray) => getSandboxImpl(...args), + }; +}); + +// Inline storage stand-in for `DurableObjectState.storage`. +type StorageMap = Map; +function createMockCtx(idName?: string) { + const storage: StorageMap = new Map(); + const alarms: Array = []; + const waitUntilTasks: Array> = []; + return { + id: { + name: idName, + toString: () => idName ?? 'do-id-stringified', + }, + storage: { + get: vi.fn(async (keyOrKeys: string | Array) => { + if (Array.isArray(keyOrKeys)) { + const result = new Map(); + for (const k of keyOrKeys) { + if (storage.has(k)) { + result.set(k, storage.get(k)); + } + } + return result; + } + return storage.get(keyOrKeys); + }), + put: vi.fn(async (keyOrEntries: string | Record, value?: unknown) => { + if (typeof keyOrEntries === 'string') { + storage.set(keyOrEntries, value); + } else { + for (const [k, v] of Object.entries(keyOrEntries)) { + storage.set(k, v); + } + } + }), + delete: vi.fn(async (keyOrKeys: string | Array) => { + if (Array.isArray(keyOrKeys)) { + let count = 0; + for (const k of keyOrKeys) { + if (storage.delete(k)) { + count++; + } + } + return count; + } + return storage.delete(keyOrKeys); + }), + setAlarm: vi.fn(async (when: number) => { + alarms.push(when); + }), + getAlarm: vi.fn(async () => alarms[alarms.length - 1] ?? null), + }, + // Stand-in for `DurableObjectState.waitUntil` — captures the promise so + // tests can await it and observe lifecycle-spawned fetches (e.g. the gate + // release POST issued from onStop/onActivityExpired). + waitUntil: vi.fn((p: Promise) => { + waitUntilTasks.push(p); + }), + _storageMap: storage, + _alarms: alarms, + _waitUntilTasks: waitUntilTasks, + }; +} + +describe('runtime sandbox: getSandbox()', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + getSandboxImpl = makeDefaultStub; + }); + + it('returns the DO stub when no gate binding is present (local dev)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { getSandbox } = await import('../../src/runtime/sandbox.ts'); + + const namespaceMarker = Symbol('namespace'); + const stub = { __returned: true, id: { name: 'my-id' } }; + const stubFn = vi.fn((..._args: ReadonlyArray) => stub); + getSandboxImpl = stubFn; + + await envContext.run({ SANDBOX: { __ns: namespaceMarker } }, async () => { + const result = await getSandbox('my-id'); + expect(result).toBe(stub); + }); + + expect(stubFn).toHaveBeenCalledOnce(); + // Stub was called with (namespace, id, options). + const callArgs = stubFn.mock.calls[0]; + expect((callArgs[0] as { __ns: symbol }).__ns).toBe(namespaceMarker); + expect(callArgs[1]).toBe('my-id'); + expect(callArgs[2]).toMatchObject({ normalizeId: true }); + }); + + it('returns the DO stub when the gate returns 200', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { getSandbox } = await import('../../src/runtime/sandbox.ts'); + + const stub = { __with_gate: true, id: { name: 'sb-1' } }; + const stubFn = vi.fn((..._args: ReadonlyArray) => stub); + getSandboxImpl = stubFn; + + const gateFetch = vi.fn( + async (..._args: ReadonlyArray) => new Response(null, { status: 200 }), + ); + + await envContext.run( + { + SANDBOX: { __ns: true }, + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-1', + __VOID_PROXY_TOKEN: 'token-1', + }, + async () => { + const result = await getSandbox('sb-1'); + expect(result).toBe(stub); + }, + ); + + expect(gateFetch).toHaveBeenCalledOnce(); + const callArgs = gateFetch.mock.calls[0]; + const url = callArgs[0] as string; + const init = callArgs[1] as RequestInit; + expect(url).toBe('https://gate/sandbox/acquire'); + expect(init.method).toBe('POST'); + // Body must carry the HMAC-verifiable {projectId, aiToken} pair the + // proxy auth middleware requires, alongside the caller's sandboxId. + expect(JSON.parse(init.body as string)).toEqual({ + projectId: 'proj-1', + aiToken: 'token-1', + sandboxId: 'sb-1', + }); + }); + + it('throws SandboxLimitError(concurrency) when the gate denies with 409', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { getSandbox, SandboxLimitError } = await import('../../src/runtime/sandbox.ts'); + + const gateFetch = vi.fn( + async (..._args: ReadonlyArray) => + new Response(JSON.stringify({ reason: 'concurrency' }), { + status: 409, + headers: { 'content-type': 'application/json' }, + }), + ); + + await envContext.run( + { + SANDBOX: { __ns: true }, + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-1', + __VOID_PROXY_TOKEN: 'token-1', + }, + async () => { + await expect(getSandbox('sb-x')).rejects.toBeInstanceOf(SandboxLimitError); + await expect(getSandbox('sb-x')).rejects.toMatchObject({ reason: 'concurrency' }); + }, + ); + }); + + it('throws SandboxLimitError(runtime_budget) when the gate denies with that reason', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { getSandbox, SandboxLimitError } = await import('../../src/runtime/sandbox.ts'); + + const gateFetch = vi.fn( + async (..._args: ReadonlyArray) => + new Response(JSON.stringify({ reason: 'runtime_budget' }), { + status: 409, + headers: { 'content-type': 'application/json' }, + }), + ); + + await envContext.run( + { + SANDBOX: { __ns: true }, + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-1', + __VOID_PROXY_TOKEN: 'token-1', + }, + async () => { + const err = await getSandbox('sb-x').catch((e: unknown) => e); + expect(err).toBeInstanceOf(SandboxLimitError); + expect((err as InstanceType).reason).toBe('runtime_budget'); + }, + ); + }); + + it('falls through and returns the DO stub when the gate responds non-409 non-OK (fail-open)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { getSandbox } = await import('../../src/runtime/sandbox.ts'); + + const stub = { __fail_open: true, id: { name: 'sb-any' } }; + const stubFn = vi.fn((..._args: ReadonlyArray) => stub); + getSandboxImpl = stubFn; + + const gateFetch = vi.fn( + async (..._args: ReadonlyArray) => new Response('upstream blew up', { status: 500 }), + ); + + await envContext.run( + { + SANDBOX: { __ns: true }, + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-1', + __VOID_PROXY_TOKEN: 'token-1', + }, + async () => { + // Documents the deliberate fail-open behaviour: a broken gate must not + // block sandbox acquisition. The gate's lease TTL backstops accounting. + const result = await getSandbox('sb-any'); + expect(result).toBe(stub); + }, + ); + + expect(gateFetch).toHaveBeenCalledOnce(); + expect(stubFn).toHaveBeenCalledOnce(); + }); + + it('falls through and returns the DO stub when the gate fetch throws (fail-open on transport error)', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { getSandbox } = await import('../../src/runtime/sandbox.ts'); + + const stub = { __fail_open_throw: true, id: { name: 'sb-throw' } }; + const stubFn = vi.fn((..._args: ReadonlyArray) => stub); + getSandboxImpl = stubFn; + + // Service-binding transport errors (proxy unreachable, internal throw) + // must NOT block sandbox acquisition — same fail-open intent as a 500 + // response. Only the explicit 409 → SandboxLimitError signal should + // propagate to the caller. + const gateFetch = vi.fn(async (..._args: ReadonlyArray) => { + throw new Error('service binding unreachable'); + }); + + await envContext.run( + { + SANDBOX: { __ns: true }, + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-1', + __VOID_PROXY_TOKEN: 'token-1', + }, + async () => { + const result = await getSandbox('sb-throw'); + expect(result).toBe(stub); + }, + ); + + expect(gateFetch).toHaveBeenCalledOnce(); + expect(stubFn).toHaveBeenCalledOnce(); + }); + + it('uses the normalized DO name (lowercase) as the gate sandboxId so acquire matches release', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { getSandbox, VoidPlatformSandbox } = await import('../../src/runtime/sandbox.ts'); + + // The SDK lowercases the id when `normalizeId: true`, so the resulting + // DO is keyed by `myproject-123`. Inside the DO, `ctx.id.name` reports + // the normalized form — `notifyGateRelease()` posts that form back to + // the gate. Acquire MUST use the same key, otherwise the lease leaks + // until the gate's TTL sweeps it. We assert both halves here. + const CALLER_ID = 'MyProject-123'; + const NORMALIZED_ID = 'myproject-123'; + + // Mock the SDK stub so its id.name is the normalized form (matching the + // real SDK behaviour). getSandbox() should read that for the gate body. + const stub = { __normalized: true, id: { name: NORMALIZED_ID } }; + getSandboxImpl = (..._args: ReadonlyArray) => stub; + + const gateFetch = vi.fn( + async (..._args: ReadonlyArray) => new Response(null, { status: 200 }), + ); + + let releaseCtx: ReturnType | undefined; + await envContext.run( + { + SANDBOX: { __ns: true }, + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-norm', + __VOID_PROXY_TOKEN: 'token-norm', + }, + async () => { + // 1. Acquire with the mixed-case caller id. + await getSandbox(CALLER_ID); + + // 2. Release via the DO lifecycle — ctx.id.name is the normalized form. + releaseCtx = createMockCtx(NORMALIZED_ID); + const env = { + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-norm', + __VOID_PROXY_TOKEN: 'token-norm', + }; + const sandbox = new VoidPlatformSandbox(releaseCtx as never, env as never); + await sandbox.onStop(); + }, + ); + + // Release is routed through ctx.waitUntil() — drain the captured promise + // so the gate fetch lands before we assert on it. + await Promise.all(releaseCtx?._waitUntilTasks ?? []); + + expect(gateFetch).toHaveBeenCalledTimes(2); + const acquireBody = JSON.parse((gateFetch.mock.calls[0][1] as RequestInit).body as string) as { + sandboxId: string; + }; + const releaseBody = JSON.parse((gateFetch.mock.calls[1][1] as RequestInit).body as string) as { + sandboxId: string; + }; + expect(acquireBody.sandboxId).toBe(NORMALIZED_ID); + expect(releaseBody.sandboxId).toBe(NORMALIZED_ID); + expect(acquireBody.sandboxId).toBe(releaseBody.sandboxId); + }); + + it('skips the gate call and returns the DO stub when __VOID_PROXY_TOKEN is missing', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { getSandbox } = await import('../../src/runtime/sandbox.ts'); + + const stub = { __no_token: true, id: { name: 'sb-skip' } }; + const stubFn = vi.fn((..._args: ReadonlyArray) => stub); + getSandboxImpl = stubFn; + + const gateFetch = vi.fn( + async (..._args: ReadonlyArray) => new Response(null, { status: 200 }), + ); + + // Mirrors a partial / legacy deploy: the proxy service binding is wired + // up but the HMAC identity vars never landed. Without those we can't + // form an authenticated request, so we must skip the gate entirely + // (same no-op semantics as local dev with no proxy binding). + await envContext.run( + { + SANDBOX: { __ns: true }, + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-1', + // __VOID_PROXY_TOKEN intentionally absent + }, + async () => { + const result = await getSandbox('sb-skip'); + expect(result).toBe(stub); + }, + ); + + expect(gateFetch).not.toHaveBeenCalled(); + expect(stubFn).toHaveBeenCalledOnce(); + }); +}); + +describe('runtime sandbox: VoidPlatformSandbox', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('onStart() records start timestamp in storage even when no usage queue is bound', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { VoidPlatformSandbox, HEARTBEAT_MS } = await import('../../src/runtime/sandbox.ts'); + + const ctx = createMockCtx('sandbox-instance-A'); + const env = {}; + + await envContext.run(env, async () => { + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await expect(sandbox.onStart()).resolves.toBeUndefined(); + }); + + const startTs = ctx._storageMap.get('__voidStartTs'); + const lastFlushTs = ctx._storageMap.get('__voidLastFlushTs'); + expect(typeof startTs).toBe('number'); + expect(typeof lastFlushTs).toBe('number'); + expect(ctx._alarms.length).toBe(1); + expect(ctx._alarms[0]).toBe((startTs as number) + HEARTBEAT_MS); + }); + + it('onStart() followed by onStop() emits one runtime event with sandboxRuntimeSeconds > 0', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { VoidPlatformSandbox } = await import('../../src/runtime/sandbox.ts'); + + const ctx = createMockCtx('sandbox-instance-B'); + const queueSend = vi.fn(async (..._args: ReadonlyArray) => undefined); + const env = { + USAGE_EVENTS_QUEUE: { send: queueSend }, + }; + + await envContext.run(env, async () => { + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await sandbox.onStart(); + // Advance wall-clock so the elapsed runtime is observable. + vi.advanceTimersByTime(7_500); + await sandbox.onStop(); + }); + + expect(queueSend).toHaveBeenCalledOnce(); + const event = queueSend.mock.calls[0][0] as { + sandboxRuntimeSeconds: number; + sandboxInstanceId: string; + requests: number; + responseBytes: number; + }; + expect(event.sandboxRuntimeSeconds).toBeGreaterThan(0); + expect(event.sandboxInstanceId).toBe('sandbox-instance-B'); + expect(event.requests).toBe(0); + expect(event.responseBytes).toBe(0); + // Storage cleared on stop. + expect(ctx._storageMap.has('__voidStartTs')).toBe(false); + expect(ctx._storageMap.has('__voidLastFlushTs')).toBe(false); + }); + + it('onStop() with no usage-queue binding does not throw and still notifies gate if present', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { VoidPlatformSandbox } = await import('../../src/runtime/sandbox.ts'); + + const ctx = createMockCtx('sandbox-instance-C'); + const gateFetch = vi.fn( + async (..._args: ReadonlyArray) => new Response(null, { status: 204 }), + ); + const env = { + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-release', + __VOID_PROXY_TOKEN: 'token-release', + }; + + await envContext.run(env, async () => { + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await sandbox.onStart(); + vi.advanceTimersByTime(1_000); + await expect(sandbox.onStop()).resolves.toBeUndefined(); + }); + + // The release POST is routed through `ctx.waitUntil()` (so it survives + // lifecycle teardown). Awaiting the captured promise both confirms the + // hook handed us one and lets us assert on the in-flight fetch. + expect(ctx._waitUntilTasks.length).toBe(1); + await Promise.all(ctx._waitUntilTasks); + expect(gateFetch).toHaveBeenCalledOnce(); + const releaseCall = gateFetch.mock.calls[0]; + expect(releaseCall[0]).toBe('https://gate/sandbox/release'); + // Same {projectId, aiToken, sandboxId} contract as /acquire — the shared + // proxy auth middleware protects both routes. + const releaseInit = releaseCall[1] as RequestInit; + expect(JSON.parse(releaseInit.body as string)).toEqual({ + projectId: 'proj-release', + aiToken: 'token-release', + sandboxId: 'sandbox-instance-C', + }); + }); + + it('onStart() emits a single reconciliation event when storage holds an orphaned anchor', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { VoidPlatformSandbox } = await import('../../src/runtime/sandbox.ts'); + + const ctx = createMockCtx('sandbox-instance-orphan'); + // Simulate a crash: the previous instance wrote an anchor 10s ago and + // never got to onStop. The new onStart() should pick up the orphaned + // window and emit one reconciliation event. + const ORPHAN_LAG_MS = 10_000; + const now = Date.now(); + const orphanedAnchor = now - ORPHAN_LAG_MS; + ctx._storageMap.set('__voidStartTs', orphanedAnchor); + ctx._storageMap.set('__voidLastFlushTs', orphanedAnchor); + + const queueSend = vi.fn(async (..._args: ReadonlyArray) => undefined); + const env = { USAGE_EVENTS_QUEUE: { send: queueSend } }; + + await envContext.run(env, async () => { + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await sandbox.onStart(); + }); + + expect(queueSend).toHaveBeenCalledOnce(); + const event = queueSend.mock.calls[0][0] as { sandboxRuntimeSeconds: number }; + expect(event.sandboxRuntimeSeconds).toBe(ORPHAN_LAG_MS / 1000); + // Anchors reset to the new "now". + expect(ctx._storageMap.get('__voidStartTs')).toBe(now); + expect(ctx._storageMap.get('__voidLastFlushTs')).toBe(now); + }); + + it('onStart() clears orphan anchor before super.onStart() so a failed super does not re-bill on retry', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { VoidPlatformSandbox } = await import('../../src/runtime/sandbox.ts'); + const { Sandbox } = await import('@cloudflare/sandbox'); + + const ctx = createMockCtx('sandbox-instance-orphan-throw'); + // Pre-populate an orphan anchor 10s in the past, simulating a previous + // instance that crashed without firing onStop. + const ORPHAN_LAG_MS = 10_000; + const orphanedAnchor = Date.now() - ORPHAN_LAG_MS; + ctx._storageMap.set('__voidStartTs', orphanedAnchor); + ctx._storageMap.set('__voidLastFlushTs', orphanedAnchor); + + const queueSend = vi.fn(async (..._args: ReadonlyArray) => undefined); + const env = { USAGE_EVENTS_QUEUE: { send: queueSend } }; + + // First onStart(): the orphan reconciliation event must be emitted and + // the anchors cleared BEFORE super throws. We assert both pieces so a + // future regression that delays the delete after super gets caught. + const onStartSpy = vi + .spyOn(Sandbox.prototype as { onStart: () => Promise }, 'onStart') + .mockRejectedValueOnce(new Error('container failed to start')); + + await envContext.run(env, async () => { + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await expect(sandbox.onStart()).rejects.toThrow('container failed to start'); + }); + + // One reconciliation event for the orphan window. + expect(queueSend).toHaveBeenCalledOnce(); + // Anchors cleared in storage despite super throwing — this is the bug fix. + expect(ctx._storageMap.has('__voidStartTs')).toBe(false); + expect(ctx._storageMap.has('__voidLastFlushTs')).toBe(false); + + // Second onStart() retry with super now succeeding. There must NOT be a + // second reconciliation event because storage no longer holds an orphan + // anchor — this is the double-billing the fix prevents. + onStartSpy.mockResolvedValueOnce(); + + await envContext.run(env, async () => { + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await expect(sandbox.onStart()).resolves.toBeUndefined(); + }); + + // Still just the original single emit — no extra reconciliation. + expect(queueSend).toHaveBeenCalledOnce(); + // Fresh start anchors written by the successful retry. + expect(typeof ctx._storageMap.get('__voidStartTs')).toBe('number'); + expect(typeof ctx._storageMap.get('__voidLastFlushTs')).toBe('number'); + }); + + it('alarm() emits a heartbeat delta and re-arms the next alarm', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { VoidPlatformSandbox, HEARTBEAT_MS } = await import('../../src/runtime/sandbox.ts'); + + const ctx = createMockCtx('sandbox-instance-D'); + const queueSend = vi.fn(async (..._args: ReadonlyArray) => undefined); + const env = { USAGE_EVENTS_QUEUE: { send: queueSend } }; + + await envContext.run(env, async () => { + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await sandbox.onStart(); + vi.advanceTimersByTime(HEARTBEAT_MS); + await sandbox.alarm(); + }); + + expect(queueSend).toHaveBeenCalledOnce(); + const event = queueSend.mock.calls[0][0] as { sandboxRuntimeSeconds: number }; + // Heartbeat delta should be ~HEARTBEAT_MS / 1000 seconds. + expect(event.sandboxRuntimeSeconds).toBeCloseTo(HEARTBEAT_MS / 1000, 0); + // Alarm re-armed: at least one extra setAlarm beyond the onStart() one. + expect(ctx._alarms.length).toBeGreaterThanOrEqual(2); + }); + + it('alarm() fires a fire-and-forget refresh POST to the gate via waitUntil', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { VoidPlatformSandbox, HEARTBEAT_MS } = await import('../../src/runtime/sandbox.ts'); + + const ctx = createMockCtx('sandbox-instance-refresh'); + const gateFetch = vi.fn( + async (..._args: ReadonlyArray) => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const env = { + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-refresh', + __VOID_PROXY_TOKEN: 'token-refresh', + }; + + await envContext.run(env, async () => { + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await sandbox.onStart(); + vi.advanceTimersByTime(HEARTBEAT_MS); + await sandbox.alarm(); + }); + + // Refresh is routed through ctx.waitUntil() so it survives the alarm + // hook returning. Drain the captured promise before asserting on the + // in-flight fetch. + expect(ctx._waitUntilTasks.length).toBe(1); + await Promise.all(ctx._waitUntilTasks); + + expect(gateFetch).toHaveBeenCalledOnce(); + const refreshCall = gateFetch.mock.calls[0]; + expect(refreshCall[0]).toBe('https://gate/sandbox/refresh'); + const refreshInit = refreshCall[1] as RequestInit; + expect(refreshInit.method).toBe('POST'); + // Same {projectId, aiToken, sandboxId} shape as acquire/release — the + // shared HMAC auth middleware protects all three routes. + expect(JSON.parse(refreshInit.body as string)).toEqual({ + projectId: 'proj-refresh', + aiToken: 'token-refresh', + sandboxId: 'sandbox-instance-refresh', + }); + }); + + it('onStart() releases the gate lease via waitUntil when super.onStart() throws', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { VoidPlatformSandbox } = await import('../../src/runtime/sandbox.ts'); + const { Sandbox } = await import('@cloudflare/sandbox'); + + const ctx = createMockCtx('sandbox-instance-startup-fail'); + const gateFetch = vi.fn( + async (..._args: ReadonlyArray) => new Response(null, { status: 204 }), + ); + const env = { + __VOID_PROXY: { fetch: gateFetch }, + __PROJECT_ID: 'proj-fail', + __VOID_PROXY_TOKEN: 'token-fail', + }; + + // Make super.onStart() reject — getSandbox() already POSTed /acquire + // before the first DO method call, so neither onStop nor + // onActivityExpired will fire for this instance. Without a release here + // the slot leaks until the gate's STALE_MS sweep. + vi.spyOn( + Sandbox.prototype as { onStart: () => Promise }, + 'onStart', + ).mockRejectedValueOnce(new Error('container failed to start')); + + await envContext.run(env, async () => { + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await expect(sandbox.onStart()).rejects.toThrow('container failed to start'); + }); + + // Release routed through waitUntil — drain it and assert on the fetch. + expect(ctx._waitUntilTasks.length).toBe(1); + await Promise.all(ctx._waitUntilTasks); + + expect(gateFetch).toHaveBeenCalledOnce(); + const releaseCall = gateFetch.mock.calls[0]; + expect(releaseCall[0]).toBe('https://gate/sandbox/release'); + const releaseInit = releaseCall[1] as RequestInit; + expect(JSON.parse(releaseInit.body as string)).toEqual({ + projectId: 'proj-fail', + aiToken: 'token-fail', + sandboxId: 'sandbox-instance-startup-fail', + }); + }); +}); diff --git a/playground/kitchen-sink/routes/api/sandbox.ts b/playground/kitchen-sink/routes/api/sandbox.ts index 5b6214f29..4fa7e8971 100644 --- a/playground/kitchen-sink/routes/api/sandbox.ts +++ b/playground/kitchen-sink/routes/api/sandbox.ts @@ -1,5 +1,5 @@ import { defineHandler } from 'void'; -import { getSandbox } from 'void/sandbox'; +import { getSandbox, SandboxLimitError } from 'void/sandbox'; import { sampleRequests } from '../../lib/sandbox-demo.ts'; const MAX_CODE_LENGTH = 8_000; @@ -45,9 +45,9 @@ export const POST = defineHandler(async (c) => { ); } - const sandbox = getSandbox('kitchen-sink-code-lab'); - try { + const sandbox = await getSandbox('kitchen-sink-code-lab'); + await sandbox.mkdir(WORKDIR, { recursive: true }); await sandbox.writeFile( `${WORKDIR}/input.json`, @@ -79,6 +79,12 @@ export const POST = defineHandler(async (c) => { duration: result.duration, }); } catch (err) { + // Quota denials from the platform gate are user-facing 4xx, not server + // errors. Reference implementations get copied — surfacing this as a 500 + // teaches the wrong pattern. + if (err instanceof SandboxLimitError) { + return c.json({ ok: false, error: err.message, reason: err.reason }, 429); + } const message = err instanceof Error ? err.message : String(err); return c.json({ ok: false, error: message }, 500); }