From 8dc9de92d5231b497b90cb4384e663aa758ca735 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 15:27:44 +0800 Subject: [PATCH 01/10] feat(sandbox): platform-bound runtime + codegen swap for usage limits Adds VoidPlatformSandbox extends Sandbox in void/sandbox that emits container-runtime metering events to the platform USAGE_EVENTS_QUEUE binding and gates new acquisitions on a platform sandbox-gate (via the __VOID_PROXY service binding). getSandbox() becomes async (Promise) to support the gate round-trip and now throws SandboxLimitError with a typed reason ('concurrency' | 'runtime_budget') on denial. Codegen swaps the user-worker entry's Sandbox import from @cloudflare/sandbox to void/sandbox so existing user subclasses inherit the platform overrides transparently. When the platform bindings are absent (local dev), all paths no-op silently. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/router/compile.ts | 2 +- packages/void/src/runtime/sandbox.ts | 237 +++++++++++- packages/void/test/unit/compile.test.ts | 38 +- .../void/test/unit/sandbox-runtime.test.ts | 357 ++++++++++++++++++ 4 files changed, 626 insertions(+), 8 deletions(-) create mode 100644 packages/void/test/unit/sandbox-runtime.test.ts 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..10b888643 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,21 +11,251 @@ 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 `__VOID_*` bindings + // that the public `getRuntimeBinding()` helper intentionally hides from + // user code (see env.ts). + const runtimeEnv = getRawRuntimeEnv(); + return { + userId: runtimeEnv.__VOID_USER_ID as string | undefined, + projectId: runtimeEnv.__VOID_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); + } +} + +/** + * Fire-and-forget release notification to the gate so it can drop the active + * lease counter. Failure is silent — the gate maintains its own TTL fallback. + */ +function notifyGateRelease(ctx: DurableObjectState): void { + const runtimeEnv = getRawRuntimeEnv(); + const gate = runtimeEnv[SANDBOX_GATE_BINDING] as Fetcher | undefined; + if (!gate) { + return; + } + const sandboxId = getSandboxInstanceId(ctx); + void gate + .fetch('https://gate/sandbox/release', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sandboxId }), + }) + .catch(() => { + // Best-effort — the gate has its own lease-expiry safety net. + }); +} + +// --- 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 prevStart = await this.ctx.storage.get(STORAGE_KEY_START_TS); + const prevFlush = await this.ctx.storage.get(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 = prevFlush ?? prevStart; + if (orphanedAnchor !== undefined && orphanedAnchor < now) { + const sandboxRuntimeSeconds = (now - orphanedAnchor) / 1000; + await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds); + } + + await this.ctx.storage.put(STORAGE_KEY_START_TS, now); + await this.ctx.storage.put(STORAGE_KEY_LAST_FLUSH_TS, now); + await this.ctx.storage.setAlarm(now + HEARTBEAT_MS); + + await super.onStart(); + } + + 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 (DO was hibernated + // or never onStart'd). Nothing to meter. + return; + } + const now = Date.now(); + const sandboxRuntimeSeconds = Math.max(0, (now - lastFlush) / 1000); + await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds); + await this.ctx.storage.put(STORAGE_KEY_LAST_FLUSH_TS, now); + await 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(); + notifyGateRelease(this.ctx); + await super.onStop(); + } + + override async onActivityExpired(): Promise { + await this.flushFinal(); + 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, we POST to + * `https://gate/sandbox/acquire` first. 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 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), the gate check is skipped + * entirely and we return the stub directly. + */ +export async function getSandbox( id: string = DEFAULT_SANDBOX_ID, options?: VoidSandboxOptions, -): T { +): Promise { const { binding = sandboxBindingName, ...sandboxOptions } = options ?? {}; + + const runtimeEnv = getRawRuntimeEnv(); + const gate = runtimeEnv[SANDBOX_GATE_BINDING] as Fetcher | undefined; + if (gate) { + const res = await gate.fetch('https://gate/sandbox/acquire', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sandboxId: id }), + }); + 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-409 non-OK responses fall through: the gate is best-effort, and + // refusing to return a stub on a gate hiccup would be worse than letting + // the request proceed. The gate's own lease TTL backstops accounting. + } + const namespace = requireRuntimeBinding>(binding); return getCloudflareSandbox(namespace, id, { normalizeId: true, 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..34f3fc52e --- /dev/null +++ b/packages/void/test/unit/sandbox-runtime.test.ts @@ -0,0 +1,357 @@ +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. +let getSandboxImpl: (...args: ReadonlyArray) => unknown = () => ({}); + +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 = []; + return { + id: { + name: idName, + toString: () => idName ?? 'do-id-stringified', + }, + storage: { + get: vi.fn(async (key: string) => storage.get(key)), + put: vi.fn(async (key: string, value: unknown) => { + storage.set(key, value); + }), + delete: vi.fn(async (key: string) => { + storage.delete(key); + }), + setAlarm: vi.fn(async (when: number) => { + alarms.push(when); + }), + getAlarm: vi.fn(async () => alarms[alarms.length - 1] ?? null), + }, + _storageMap: storage, + _alarms: alarms, + }; +} + +describe('runtime sandbox: getSandbox()', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + getSandboxImpl = () => ({}); + }); + + 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 }; + 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 }; + 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 }, + }, + 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'); + expect(JSON.parse(init.body as string)).toEqual({ 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 }, + }, + 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 }, + }, + 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 }; + 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 }, + }, + 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(); + }); +}); + +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 } }; + + 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(); + }); + + // Flush the microtask queue so the fire-and-forget POST runs. The release + // call is a synchronous `void gate.fetch(...)` — it lands as a microtask, + // not a timer, so `vi.runAllTimersAsync()` would not advance it. + await Promise.resolve(); + expect(gateFetch).toHaveBeenCalledOnce(); + expect(gateFetch.mock.calls[0][0]).toBe('https://gate/sandbox/release'); + }); + + 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('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); + }); +}); From 5f090c2d2feb9a4d49c2b95783d76d8814c44c7b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 15:39:07 +0800 Subject: [PATCH 02/10] fix(sandbox): include projectId + aiToken in gate request bodies The platform proxy's auth middleware requires { projectId, aiToken } in the request body for HMAC verification (same as the AI and ISR paths). Without these fields, /sandbox/acquire and /sandbox/release return 400 and the framework's fail-open behavior turns the entire concurrency gate into a no-op in production. Match the runtime/ai.ts and runtime/isr.ts convention: read __VOID_PROJECT_ID and __VOID_PROXY_TOKEN from raw env, include both in acquire/release bodies. Skip the gate call entirely when either is absent (local dev without platform bindings). Co-Authored-By: Claude Opus 4.7 --- packages/void/src/runtime/sandbox.ts | 71 ++++++++++++++----- .../void/test/unit/sandbox-runtime.test.ts | 67 ++++++++++++++++- 2 files changed, 117 insertions(+), 21 deletions(-) diff --git a/packages/void/src/runtime/sandbox.ts b/packages/void/src/runtime/sandbox.ts index 10b888643..b478e8bd4 100644 --- a/packages/void/src/runtime/sandbox.ts +++ b/packages/void/src/runtime/sandbox.ts @@ -112,21 +112,50 @@ async function emitRuntimeSeconds( } /** - * Fire-and-forget release notification to the gate so it can drop the active - * lease counter. Failure is silent — the gate maintains its own TTL fallback. + * 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 notifyGateRelease(ctx: DurableObjectState): void { +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.__VOID_PROJECT_ID as string | undefined; + const aiToken = runtimeEnv.__VOID_PROXY_TOKEN as string | undefined; + if (!projectId || !aiToken) { + return null; + } + return { gate, projectId, aiToken }; +} + +/** + * Fire-and-forget release notification to the gate so it can drop the active + * lease counter. Failure is silent — the gate maintains its own TTL fallback. + */ +function notifyGateRelease(ctx: DurableObjectState): void { + const identity = resolveGateIdentity(); + if (!identity) { return; } const sandboxId = getSandboxInstanceId(ctx); - void gate + void identity.gate .fetch('https://gate/sandbox/release', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ sandboxId }), + body: JSON.stringify({ + projectId: identity.projectId, + aiToken: identity.aiToken, + sandboxId, + }), }) .catch(() => { // Best-effort — the gate has its own lease-expiry safety net. @@ -219,15 +248,18 @@ export class VoidPlatformSandbox extends Sandbox { * coordinating with the platform gate to enforce per-user concurrency and * runtime budgets. * - * When the gate binding (`__VOID_PROXY` by default) is present, we POST to - * `https://gate/sandbox/acquire` first. 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 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 (`__VOID_PROXY` by default) is present AND the + * platform's `__VOID_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 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), the gate check is skipped - * entirely and we return the stub directly. + * 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. */ export async function getSandbox( id: string = DEFAULT_SANDBOX_ID, @@ -235,13 +267,16 @@ export async function getSandbox( ): Promise { const { binding = sandboxBindingName, ...sandboxOptions } = options ?? {}; - const runtimeEnv = getRawRuntimeEnv(); - const gate = runtimeEnv[SANDBOX_GATE_BINDING] as Fetcher | undefined; - if (gate) { - const res = await gate.fetch('https://gate/sandbox/acquire', { + const identity = resolveGateIdentity(); + if (identity) { + const res = await identity.gate.fetch('https://gate/sandbox/acquire', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ sandboxId: id }), + body: JSON.stringify({ + projectId: identity.projectId, + aiToken: identity.aiToken, + sandboxId: id, + }), }); if (res.status === 409) { const body = (await res.json().catch(() => ({}))) as { reason?: string }; diff --git a/packages/void/test/unit/sandbox-runtime.test.ts b/packages/void/test/unit/sandbox-runtime.test.ts index 34f3fc52e..9a0e1875f 100644 --- a/packages/void/test/unit/sandbox-runtime.test.ts +++ b/packages/void/test/unit/sandbox-runtime.test.ts @@ -108,6 +108,8 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, + __VOID_PROJECT_ID: 'proj-1', + __VOID_PROXY_TOKEN: 'token-1', }, async () => { const result = await getSandbox('sb-1'); @@ -121,7 +123,13 @@ describe('runtime sandbox: getSandbox()', () => { const init = callArgs[1] as RequestInit; expect(url).toBe('https://gate/sandbox/acquire'); expect(init.method).toBe('POST'); - expect(JSON.parse(init.body as string)).toEqual({ sandboxId: 'sb-1' }); + // 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 () => { @@ -141,6 +149,8 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, + __VOID_PROJECT_ID: 'proj-1', + __VOID_PROXY_TOKEN: 'token-1', }, async () => { await expect(getSandbox('sb-x')).rejects.toBeInstanceOf(SandboxLimitError); @@ -166,6 +176,8 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, + __VOID_PROJECT_ID: 'proj-1', + __VOID_PROXY_TOKEN: 'token-1', }, async () => { const err = await getSandbox('sb-x').catch((e: unknown) => e); @@ -192,6 +204,8 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, + __VOID_PROJECT_ID: 'proj-1', + __VOID_PROXY_TOKEN: 'token-1', }, async () => { // Documents the deliberate fail-open behaviour: a broken gate must not @@ -204,6 +218,40 @@ describe('runtime sandbox: getSandbox()', () => { expect(gateFetch).toHaveBeenCalledOnce(); expect(stubFn).toHaveBeenCalledOnce(); }); + + 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 }; + 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 }, + __VOID_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', () => { @@ -283,7 +331,11 @@ describe('runtime sandbox: VoidPlatformSandbox', () => { const gateFetch = vi.fn( async (..._args: ReadonlyArray) => new Response(null, { status: 204 }), ); - const env = { __VOID_PROXY: { fetch: gateFetch } }; + const env = { + __VOID_PROXY: { fetch: gateFetch }, + __VOID_PROJECT_ID: 'proj-release', + __VOID_PROXY_TOKEN: 'token-release', + }; await envContext.run(env, async () => { const sandbox = new VoidPlatformSandbox(ctx as never, env as never); @@ -297,7 +349,16 @@ describe('runtime sandbox: VoidPlatformSandbox', () => { // not a timer, so `vi.runAllTimersAsync()` would not advance it. await Promise.resolve(); expect(gateFetch).toHaveBeenCalledOnce(); - expect(gateFetch.mock.calls[0][0]).toBe('https://gate/sandbox/release'); + 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 () => { From 0a9a75f10cdef677b037c6dcf6aef2bb22c14f33 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 16:16:54 +0800 Subject: [PATCH 03/10] fix(sandbox): normalize gate id + handle gate fetch exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues found in PR review (voidzero-dev/void#210): 1. Acquire used the raw caller-supplied id while release used ctx.id.name (the normalized lowercase DO name), so mixed-case ids leaked their concurrency lease until the alarm reconciliation swept the slot. Acquire now derives sandboxId from the SDK stub so it matches what onStop/onActivityExpired pass to release. 2. The gate fetch was not wrapped in try/catch, so a service-binding transport error (proxy unreachable, internal throw) broke sandbox acquisition entirely — contradicting the documented fail-open intent that only explicit 409 denials should propagate. Only SandboxLimitError now escapes; all other errors fall through. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/runtime/sandbox.ts | 86 +++++++++---- .../void/test/unit/sandbox-runtime.test.ts | 117 +++++++++++++++++- 2 files changed, 170 insertions(+), 33 deletions(-) diff --git a/packages/void/src/runtime/sandbox.ts b/packages/void/src/runtime/sandbox.ts index b478e8bd4..d527fc80a 100644 --- a/packages/void/src/runtime/sandbox.ts +++ b/packages/void/src/runtime/sandbox.ts @@ -253,13 +253,23 @@ export class VoidPlatformSandbox extends Sandbox { * 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 is treated as "gate - * unavailable" and we fall through to returning the stub so a broken gate - * doesn't break sandbox access entirely. + * 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, @@ -267,35 +277,57 @@ export async function getSandbox( ): Promise { const { binding = sandboxBindingName, ...sandboxOptions } = options ?? {}; + const namespace = requireRuntimeBinding>(binding); + const stub = getCloudflareSandbox(namespace, id, { + normalizeId: true, + ...sandboxOptions, + }); + // The SDK lowercases the id when normalizeId is true and resolves the DO + // via `idFromName(effectiveId)`, so the stub's underlying DO id name + // matches what `ctx.id.name` returns inside the DO instance (and what + // `notifyGateRelease` posts to the gate). The stub is a Proxy over the + // DurableObjectStub, so `.id` is exposed at runtime even though the + // public `Sandbox` type doesn't declare it. Fall back to the caller's + // id only on the off-chance the underlying DO id has no `name` + // (anonymous), which shouldn't happen for sandbox stubs but keeps us safe. + const stubId = (stub as unknown as DurableObjectStub).id; + const sandboxId = stubId.name ?? id; + const identity = resolveGateIdentity(); if (identity) { - 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: id, - }), - }); - 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); + 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-409 non-OK responses fall through: the gate is best-effort, and + // refusing to return a stub on a gate hiccup would be worse than letting + // the request proceed. The gate's own lease TTL backstops accounting. + } catch (error) { + // SandboxLimitError is the one signal the caller wants — re-raise it. + // Everything else (service-binding throw, json parse blowup, network + // glitch) is treated the same as a non-OK response: fail open so a + // broken gate doesn't block sandbox acquisition. + if (error instanceof SandboxLimitError) { + throw error; + } } - // Non-409 non-OK responses fall through: the gate is best-effort, and - // refusing to return a stub on a gate hiccup would be worse than letting - // the request proceed. The gate's own lease TTL backstops accounting. } - const namespace = requireRuntimeBinding>(binding); - return getCloudflareSandbox(namespace, id, { - normalizeId: true, - ...sandboxOptions, - }); + return stub; } export const sandbox: { get: typeof getSandbox } = { diff --git a/packages/void/test/unit/sandbox-runtime.test.ts b/packages/void/test/unit/sandbox-runtime.test.ts index 9a0e1875f..0cf734ee0 100644 --- a/packages/void/test/unit/sandbox-runtime.test.ts +++ b/packages/void/test/unit/sandbox-runtime.test.ts @@ -4,7 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' // 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. -let getSandboxImpl: (...args: ReadonlyArray) => unknown = () => ({}); +// +// 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 { @@ -65,7 +76,7 @@ describe('runtime sandbox: getSandbox()', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - getSandboxImpl = () => ({}); + getSandboxImpl = makeDefaultStub; }); it('returns the DO stub when no gate binding is present (local dev)', async () => { @@ -74,7 +85,7 @@ describe('runtime sandbox: getSandbox()', () => { const { getSandbox } = await import('../../src/runtime/sandbox.ts'); const namespaceMarker = Symbol('namespace'); - const stub = { __returned: true }; + const stub = { __returned: true, id: { name: 'my-id' } }; const stubFn = vi.fn((..._args: ReadonlyArray) => stub); getSandboxImpl = stubFn; @@ -96,7 +107,7 @@ describe('runtime sandbox: getSandbox()', () => { const { envContext } = await import('../../src/runtime/env-raw.ts'); const { getSandbox } = await import('../../src/runtime/sandbox.ts'); - const stub = { __with_gate: true }; + const stub = { __with_gate: true, id: { name: 'sb-1' } }; const stubFn = vi.fn((..._args: ReadonlyArray) => stub); getSandboxImpl = stubFn; @@ -192,7 +203,7 @@ describe('runtime sandbox: getSandbox()', () => { const { envContext } = await import('../../src/runtime/env-raw.ts'); const { getSandbox } = await import('../../src/runtime/sandbox.ts'); - const stub = { __fail_open: true }; + const stub = { __fail_open: true, id: { name: 'sb-any' } }; const stubFn = vi.fn((..._args: ReadonlyArray) => stub); getSandboxImpl = stubFn; @@ -219,12 +230,106 @@ describe('runtime sandbox: getSandbox()', () => { 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 }, + __VOID_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 }), + ); + + await envContext.run( + { + SANDBOX: { __ns: true }, + __VOID_PROXY: { fetch: gateFetch }, + __VOID_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. + const ctx = createMockCtx(NORMALIZED_ID); + const env = { + __VOID_PROXY: { fetch: gateFetch }, + __VOID_PROJECT_ID: 'proj-norm', + __VOID_PROXY_TOKEN: 'token-norm', + }; + const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + await sandbox.onStop(); + }, + ); + + // Flush the microtask queue so the fire-and-forget release POST lands. + await Promise.resolve(); + + 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 }; + const stub = { __no_token: true, id: { name: 'sb-skip' } }; const stubFn = vi.fn((..._args: ReadonlyArray) => stub); getSandboxImpl = stubFn; From 521cb5d451ad9f1ee796643d761f6b9230c36879 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 17:02:38 +0800 Subject: [PATCH 04/10] refactor(sandbox): simplify identity + storage ops + trim comments - Read project id from the existing __PROJECT_ID slot injected for AI/ISR instead of introducing a parallel __VOID_PROJECT_ID binding. The two carry the same value and are deployed atomically. - Parallelize onStart() storage IO (multi-key get + object-form put) and pair the alarm put + setAlarm; only re-arm when an active window exists. - Trim narrative comments to leave WHY-only notes. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/runtime/sandbox.ts | 60 +++++++++---------- .../void/test/unit/sandbox-runtime.test.ts | 41 +++++++++---- 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/packages/void/src/runtime/sandbox.ts b/packages/void/src/runtime/sandbox.ts index d527fc80a..1fef26c89 100644 --- a/packages/void/src/runtime/sandbox.ts +++ b/packages/void/src/runtime/sandbox.ts @@ -63,13 +63,12 @@ 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 `__VOID_*` bindings - // that the public `getRuntimeBinding()` helper intentionally hides from - // user code (see env.ts). + // 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.__VOID_PROJECT_ID as string | undefined, + projectId: runtimeEnv.__PROJECT_ID as string | undefined, slug: runtimeEnv.__VOID_PROJECT_SLUG as string | undefined, }; } @@ -129,7 +128,7 @@ function resolveGateIdentity(): { gate: Fetcher; projectId: string; aiToken: str if (!gate) { return null; } - const projectId = runtimeEnv.__VOID_PROJECT_ID as string | undefined; + const projectId = runtimeEnv.__PROJECT_ID as string | undefined; const aiToken = runtimeEnv.__VOID_PROXY_TOKEN as string | undefined; if (!projectId || !aiToken) { return null; @@ -178,22 +177,26 @@ function notifyGateRelease(ctx: DurableObjectState): void { export class VoidPlatformSandbox extends Sandbox { override async onStart(): Promise { const now = Date.now(); - const prevStart = await this.ctx.storage.get(STORAGE_KEY_START_TS); - const prevFlush = await this.ctx.storage.get(STORAGE_KEY_LAST_FLUSH_TS); + 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 = prevFlush ?? prevStart; + 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); } - await this.ctx.storage.put(STORAGE_KEY_START_TS, now); - await this.ctx.storage.put(STORAGE_KEY_LAST_FLUSH_TS, now); - await this.ctx.storage.setAlarm(now + HEARTBEAT_MS); + await Promise.all([ + this.ctx.storage.put({ [STORAGE_KEY_START_TS]: now, [STORAGE_KEY_LAST_FLUSH_TS]: now }), + this.ctx.storage.setAlarm(now + HEARTBEAT_MS), + ]); await super.onStart(); } @@ -206,15 +209,17 @@ export class VoidPlatformSandbox extends Sandbox { const lastFlush = await this.ctx.storage.get(STORAGE_KEY_LAST_FLUSH_TS); if (typeof lastFlush !== 'number') { - // Heartbeat fired without an active start window (DO was hibernated - // or never onStart'd). Nothing to meter. + // 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); - await this.ctx.storage.put(STORAGE_KEY_LAST_FLUSH_TS, now); - await this.ctx.storage.setAlarm(now + HEARTBEAT_MS); + 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. @@ -249,7 +254,7 @@ export class VoidPlatformSandbox extends Sandbox { * runtime budgets. * * When the gate binding (`__VOID_PROXY` by default) is present AND the - * platform's `__VOID_PROJECT_ID` + `__VOID_PROXY_TOKEN` identity vars are + * 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 @@ -282,16 +287,9 @@ export async function getSandbox( normalizeId: true, ...sandboxOptions, }); - // The SDK lowercases the id when normalizeId is true and resolves the DO - // via `idFromName(effectiveId)`, so the stub's underlying DO id name - // matches what `ctx.id.name` returns inside the DO instance (and what - // `notifyGateRelease` posts to the gate). The stub is a Proxy over the - // DurableObjectStub, so `.id` is exposed at runtime even though the - // public `Sandbox` type doesn't declare it. Fall back to the caller's - // id only on the off-chance the underlying DO id has no `name` - // (anonymous), which shouldn't happen for sandbox stubs but keeps us safe. - const stubId = (stub as unknown as DurableObjectStub).id; - const sandboxId = stubId.name ?? id; + // 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) { @@ -313,17 +311,13 @@ export async function getSandbox( : 'concurrency'; throw new SandboxLimitError(reason); } - // Non-409 non-OK responses fall through: the gate is best-effort, and - // refusing to return a stub on a gate hiccup would be worse than letting - // the request proceed. The gate's own lease TTL backstops accounting. + // Non-OK non-409 falls through: a broken gate must not block sandbox + // acquisition; the gate's lease TTL backstops accounting. } catch (error) { - // SandboxLimitError is the one signal the caller wants — re-raise it. - // Everything else (service-binding throw, json parse blowup, network - // glitch) is treated the same as a non-OK response: fail open so a - // broken gate doesn't block sandbox acquisition. if (error instanceof SandboxLimitError) { throw error; } + // Transport errors get the same fail-open treatment as a non-OK response. } } diff --git a/packages/void/test/unit/sandbox-runtime.test.ts b/packages/void/test/unit/sandbox-runtime.test.ts index 0cf734ee0..5ae098a1a 100644 --- a/packages/void/test/unit/sandbox-runtime.test.ts +++ b/packages/void/test/unit/sandbox-runtime.test.ts @@ -55,9 +55,26 @@ function createMockCtx(idName?: string) { toString: () => idName ?? 'do-id-stringified', }, storage: { - get: vi.fn(async (key: string) => storage.get(key)), - put: vi.fn(async (key: string, value: unknown) => { - storage.set(key, value); + 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 (key: string) => { storage.delete(key); @@ -119,7 +136,7 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, - __VOID_PROJECT_ID: 'proj-1', + __PROJECT_ID: 'proj-1', __VOID_PROXY_TOKEN: 'token-1', }, async () => { @@ -160,7 +177,7 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, - __VOID_PROJECT_ID: 'proj-1', + __PROJECT_ID: 'proj-1', __VOID_PROXY_TOKEN: 'token-1', }, async () => { @@ -187,7 +204,7 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, - __VOID_PROJECT_ID: 'proj-1', + __PROJECT_ID: 'proj-1', __VOID_PROXY_TOKEN: 'token-1', }, async () => { @@ -215,7 +232,7 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, - __VOID_PROJECT_ID: 'proj-1', + __PROJECT_ID: 'proj-1', __VOID_PROXY_TOKEN: 'token-1', }, async () => { @@ -251,7 +268,7 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, - __VOID_PROJECT_ID: 'proj-1', + __PROJECT_ID: 'proj-1', __VOID_PROXY_TOKEN: 'token-1', }, async () => { @@ -290,7 +307,7 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, - __VOID_PROJECT_ID: 'proj-norm', + __PROJECT_ID: 'proj-norm', __VOID_PROXY_TOKEN: 'token-norm', }, async () => { @@ -301,7 +318,7 @@ describe('runtime sandbox: getSandbox()', () => { const ctx = createMockCtx(NORMALIZED_ID); const env = { __VOID_PROXY: { fetch: gateFetch }, - __VOID_PROJECT_ID: 'proj-norm', + __PROJECT_ID: 'proj-norm', __VOID_PROXY_TOKEN: 'token-norm', }; const sandbox = new VoidPlatformSandbox(ctx as never, env as never); @@ -345,7 +362,7 @@ describe('runtime sandbox: getSandbox()', () => { { SANDBOX: { __ns: true }, __VOID_PROXY: { fetch: gateFetch }, - __VOID_PROJECT_ID: 'proj-1', + __PROJECT_ID: 'proj-1', // __VOID_PROXY_TOKEN intentionally absent }, async () => { @@ -438,7 +455,7 @@ describe('runtime sandbox: VoidPlatformSandbox', () => { ); const env = { __VOID_PROXY: { fetch: gateFetch }, - __VOID_PROJECT_ID: 'proj-release', + __PROJECT_ID: 'proj-release', __VOID_PROXY_TOKEN: 'token-release', }; From 2cd796650a79e58b6c37fdd272b9d7ffb60c9c75 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 17:13:45 +0800 Subject: [PATCH 05/10] chore(playground): migrate kitchen-sink sandbox route to async getSandbox Co-Authored-By: Claude Opus 4.7 --- playground/kitchen-sink/routes/api/sandbox.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/kitchen-sink/routes/api/sandbox.ts b/playground/kitchen-sink/routes/api/sandbox.ts index 5b6214f29..1acdd698e 100644 --- a/playground/kitchen-sink/routes/api/sandbox.ts +++ b/playground/kitchen-sink/routes/api/sandbox.ts @@ -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`, From c513cdb1d50821874b9cb46b0f575ec4b4d8ffab Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 17:21:05 +0800 Subject: [PATCH 06/10] fix(sandbox): wait on super.onStart before arming heartbeat; waitUntil on gate release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onStart no longer sets the heartbeat alarm inside Promise.all before super.onStart() — DOs have a single alarm slot and super can clobber it. Set alarm after super, matching the alarm() override pattern. - notifyGateRelease is now wrapped in ctx.waitUntil at the onStop/onActivityExpired call sites so the release POST survives lifecycle teardown. Otherwise the gate's acquire counter can be stuck until the stale-slot sweep, manifesting as false concurrency denials. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/runtime/sandbox.ts | 35 +++++++++++++------ .../void/test/unit/sandbox-runtime.test.ts | 27 +++++++++----- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/packages/void/src/runtime/sandbox.ts b/packages/void/src/runtime/sandbox.ts index 1fef26c89..93be583ac 100644 --- a/packages/void/src/runtime/sandbox.ts +++ b/packages/void/src/runtime/sandbox.ts @@ -137,16 +137,18 @@ function resolveGateIdentity(): { gate: Fetcher; projectId: string; aiToken: str } /** - * Fire-and-forget release notification to the gate so it can drop the active - * lease counter. Failure is silent — the gate maintains its own TTL fallback. + * 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): void { +function notifyGateRelease(ctx: DurableObjectState): Promise { const identity = resolveGateIdentity(); if (!identity) { - return; + return Promise.resolve(); } const sandboxId = getSandboxInstanceId(ctx); - void identity.gate + return identity.gate .fetch('https://gate/sandbox/release', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -156,6 +158,7 @@ function notifyGateRelease(ctx: DurableObjectState): void { sandboxId, }), }) + .then(() => undefined) .catch(() => { // Best-effort — the gate has its own lease-expiry safety net. }); @@ -193,12 +196,18 @@ export class VoidPlatformSandbox extends Sandbox { await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds); } - await Promise.all([ - this.ctx.storage.put({ [STORAGE_KEY_START_TS]: now, [STORAGE_KEY_LAST_FLUSH_TS]: now }), - this.ctx.storage.setAlarm(now + HEARTBEAT_MS), - ]); + await this.ctx.storage.put({ + [STORAGE_KEY_START_TS]: now, + [STORAGE_KEY_LAST_FLUSH_TS]: now, + }); await super.onStart(); + + // Set the heartbeat alarm AFTER super.onStart() because DOs have a single + // alarm slot — upstream Sandbox/Container may arm its own alarm (e.g. for + // sleepAfter idle management) and clobber ours. Same ordering pattern the + // alarm() override uses: defer to super first, then re-arm our heartbeat. + await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS); } override async alarm(alarmProps?: { isRetry: boolean; retryCount: number }): Promise { @@ -225,13 +234,17 @@ export class VoidPlatformSandbox extends Sandbox { // Sandbox narrows Container's `onStop(params)` to zero-arg — exitCode/reason aren't visible here. override async onStop(): Promise { await this.flushFinal(); - notifyGateRelease(this.ctx); + // 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(); - notifyGateRelease(this.ctx); + this.ctx.waitUntil(notifyGateRelease(this.ctx)); await super.onActivityExpired(); } diff --git a/packages/void/test/unit/sandbox-runtime.test.ts b/packages/void/test/unit/sandbox-runtime.test.ts index 5ae098a1a..ef8b5e90d 100644 --- a/packages/void/test/unit/sandbox-runtime.test.ts +++ b/packages/void/test/unit/sandbox-runtime.test.ts @@ -49,6 +49,7 @@ type StorageMap = Map; function createMockCtx(idName?: string) { const storage: StorageMap = new Map(); const alarms: Array = []; + const waitUntilTasks: Array> = []; return { id: { name: idName, @@ -84,8 +85,15 @@ function createMockCtx(idName?: string) { }), 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, }; } @@ -303,6 +311,7 @@ describe('runtime sandbox: getSandbox()', () => { async (..._args: ReadonlyArray) => new Response(null, { status: 200 }), ); + let releaseCtx: ReturnType | undefined; await envContext.run( { SANDBOX: { __ns: true }, @@ -315,19 +324,20 @@ describe('runtime sandbox: getSandbox()', () => { await getSandbox(CALLER_ID); // 2. Release via the DO lifecycle — ctx.id.name is the normalized form. - const ctx = createMockCtx(NORMALIZED_ID); + releaseCtx = createMockCtx(NORMALIZED_ID); const env = { __VOID_PROXY: { fetch: gateFetch }, __PROJECT_ID: 'proj-norm', __VOID_PROXY_TOKEN: 'token-norm', }; - const sandbox = new VoidPlatformSandbox(ctx as never, env as never); + const sandbox = new VoidPlatformSandbox(releaseCtx as never, env as never); await sandbox.onStop(); }, ); - // Flush the microtask queue so the fire-and-forget release POST lands. - await Promise.resolve(); + // 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 { @@ -466,10 +476,11 @@ describe('runtime sandbox: VoidPlatformSandbox', () => { await expect(sandbox.onStop()).resolves.toBeUndefined(); }); - // Flush the microtask queue so the fire-and-forget POST runs. The release - // call is a synchronous `void gate.fetch(...)` — it lands as a microtask, - // not a timer, so `vi.runAllTimersAsync()` would not advance it. - await Promise.resolve(); + // 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'); From f588ff96a15fdb1d3a3d69511981408f405d676b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 17:49:30 +0800 Subject: [PATCH 07/10] fix(sandbox): move start timestamps after super.onStart; map SandboxLimitError to 429 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onStart no longer persists START_TS/LAST_FLUSH_TS before super.onStart(). If super threw, the timestamps survived and the next start's orphan reconciliation branch billed wall-clock time during which no container ever ran. Write timestamps (and re-arm the heartbeat) only after super succeeds, using a fresh Date.now() so they reflect container-ready time. - kitchen-sink playground now returns 429 with the limit reason for SandboxLimitError instead of letting the generic catch surface a 500. Reference implementations get copied — quota denials shouldn't look like server errors. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/runtime/sandbox.ts | 27 ++++++++++++------- playground/kitchen-sink/routes/api/sandbox.ts | 8 +++++- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/void/src/runtime/sandbox.ts b/packages/void/src/runtime/sandbox.ts index 93be583ac..c0936249e 100644 --- a/packages/void/src/runtime/sandbox.ts +++ b/packages/void/src/runtime/sandbox.ts @@ -196,18 +196,25 @@ export class VoidPlatformSandbox extends Sandbox { await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds); } - await this.ctx.storage.put({ - [STORAGE_KEY_START_TS]: now, - [STORAGE_KEY_LAST_FLUSH_TS]: now, - }); - await super.onStart(); - // Set the heartbeat alarm AFTER super.onStart() because DOs have a single - // alarm slot — upstream Sandbox/Container may arm its own alarm (e.g. for - // sleepAfter idle management) and clobber ours. Same ordering pattern the - // alarm() override uses: defer to super first, then re-arm our heartbeat. - await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS); + // 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 { diff --git a/playground/kitchen-sink/routes/api/sandbox.ts b/playground/kitchen-sink/routes/api/sandbox.ts index 1acdd698e..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; @@ -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); } From 13c3cf9bc3bc08a6bbd4ce72427956296b409a63 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 18:01:06 +0800 Subject: [PATCH 08/10] fix(sandbox): clear orphan anchor before super.onStart() to prevent retry re-billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After emitting the orphan-reconciliation runtime event in onStart(), delete the START_TS/LAST_FLUSH_TS keys before calling super.onStart(). Otherwise a failed super (bad image, platform scheduling error) leaves the orphan anchor intact, and the next retry detects the same anchor and emits another reconciliation event for an ever-growing window — double-counting runtime already billed. The round-3 phantom-metering protection is preserved: a failed super on a fresh start (no prior orphan) still writes nothing, so the next retry is a clean fresh start. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/runtime/sandbox.ts | 5 ++ .../void/test/unit/sandbox-runtime.test.ts | 65 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/void/src/runtime/sandbox.ts b/packages/void/src/runtime/sandbox.ts index c0936249e..57023304d 100644 --- a/packages/void/src/runtime/sandbox.ts +++ b/packages/void/src/runtime/sandbox.ts @@ -194,6 +194,11 @@ export class VoidPlatformSandbox extends Sandbox { 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]); } await super.onStart(); diff --git a/packages/void/test/unit/sandbox-runtime.test.ts b/packages/void/test/unit/sandbox-runtime.test.ts index ef8b5e90d..44a8c03c5 100644 --- a/packages/void/test/unit/sandbox-runtime.test.ts +++ b/packages/void/test/unit/sandbox-runtime.test.ts @@ -77,8 +77,17 @@ function createMockCtx(idName?: string) { } } }), - delete: vi.fn(async (key: string) => { - storage.delete(key); + 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); @@ -525,6 +534,58 @@ describe('runtime sandbox: VoidPlatformSandbox', () => { 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'); From 15d3927864e35f451720ad7e1ba6cbe8449b952b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 20:18:23 +0800 Subject: [PATCH 09/10] fix(sandbox): heartbeat slot refresh, release-on-startup-failure, client stub parity - VoidPlatformSandbox.alarm() now fires a fire-and-forget refresh POST to /sandbox/refresh via waitUntil, so the per-account concurrency DO's STALE_MS=1h sweep doesn't reap slots for long-running sessions. The heartbeat already runs every 5 min, well inside the sweep window. - onStart() wraps super.onStart() in try/catch; on throw, releases the acquired gate lease through waitUntil and re-throws. Otherwise a bad image / scheduling failure leaked the lease until the 1h sweep, manifesting as false concurrency denials on immediate retries. - The client-side stub for void/sandbox now matches the real shape: async getSandbox() that throws "server-only" instead of returning a bare {} synchronously, and a SandboxLimitError class so client code that does `instanceof` doesn't crash with a TypeError. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/index.ts | 19 +++- packages/void/src/runtime/sandbox.ts | 56 +++++++++++- .../void/test/unit/sandbox-runtime.test.ts | 91 +++++++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) 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/runtime/sandbox.ts b/packages/void/src/runtime/sandbox.ts index 57023304d..fa576ef1b 100644 --- a/packages/void/src/runtime/sandbox.ts +++ b/packages/void/src/runtime/sandbox.ts @@ -164,6 +164,41 @@ function notifyGateRelease(ctx: DurableObjectState): Promise { }); } +/** + * 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 --- /** @@ -201,7 +236,20 @@ export class VoidPlatformSandbox extends Sandbox { await this.ctx.storage.delete([STORAGE_KEY_START_TS, STORAGE_KEY_LAST_FLUSH_TS]); } - await super.onStart(); + 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), @@ -237,6 +285,12 @@ export class VoidPlatformSandbox extends Sandbox { 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), diff --git a/packages/void/test/unit/sandbox-runtime.test.ts b/packages/void/test/unit/sandbox-runtime.test.ts index 44a8c03c5..ac10b36af 100644 --- a/packages/void/test/unit/sandbox-runtime.test.ts +++ b/packages/void/test/unit/sandbox-runtime.test.ts @@ -609,4 +609,95 @@ describe('runtime sandbox: VoidPlatformSandbox', () => { // 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', + }); + }); }); From 827e9ae131326cf68e182813d7ee91c48f0ba4d7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 19 May 2026 21:06:49 +0800 Subject: [PATCH 10/10] docs(sandbox): await getSandbox in guide examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSandbox is async in this PR — the two code samples in the guide were still showing synchronous usage, which would surface .exec / .writeFile on a Promise. Co-Authored-By: Claude Opus 4.7 --- docs/guide/sandboxes.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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'); ```