diff --git a/.changeset/quickjs-promise-bridge.md b/.changeset/quickjs-promise-bridge.md new file mode 100644 index 000000000..ac6932665 --- /dev/null +++ b/.changeset/quickjs-promise-bridge.md @@ -0,0 +1,10 @@ +--- +'@tanstack/ai-isolate-quickjs': minor +--- + +Replace asyncify-suspended host functions with promise-based bridging in the QuickJS isolate driver. Sequential awaits of the same async tool binding previously corrupted QuickJS refcounts and could abort the WASM module (https://github.com/justjake/quickjs-emscripten/issues/258) — the passing tests relied on an incidental heap-layout effect of the injected `console` global. Tool bindings now return QuickJS promises resolved from the host, which never suspend the WASM stack, and the driver uses the plain (non-asyncify) QuickJS build. Concurrent tool calls via `Promise.all` in sandboxed code are now supported, and execution timeouts are enforced with a host-side deadline in addition to the QuickJS interrupt handler. + +**Migration notes:** + +- **WASM variant changed.** The driver now loads the sync QuickJS build. If you bundle or self-host the wasm binary, ship `@jitl/quickjs-wasmfile-release-sync/wasm` instead of `@jitl/quickjs-wasmfile-release-asyncify/wasm` — the sync glue cannot instantiate the asyncify binary. +- **Timeouts are now terminal for the context.** After a `TimeoutError` (previously surfaced as `InternalError: interrupted`), outstanding tool calls are cancelled with a timeout error inside the sandbox, the context is disposed, and subsequent `execute()` calls return `DisposedError` — the timed-out program's interrupted jobs stay queued in the VM and must not run inside a later execution. Create a new context after a timeout; `@tanstack/ai-code-mode` already creates a context per execution, so typical Code Mode usage is unaffected. diff --git a/packages/ai-isolate-quickjs/src/error-normalizer.ts b/packages/ai-isolate-quickjs/src/error-normalizer.ts index 2e025d3ba..18523ed2d 100644 --- a/packages/ai-isolate-quickjs/src/error-normalizer.ts +++ b/packages/ai-isolate-quickjs/src/error-normalizer.ts @@ -2,10 +2,12 @@ import type { NormalizedError } from '@tanstack/ai-code-mode' const MEMORY_LIMIT_ERROR = 'MemoryLimitError' const STACK_OVERFLOW_ERROR = 'StackOverflowError' +export const TIMEOUT_ERROR = 'TimeoutError' /** * Whether this normalized error indicates the QuickJS VM should not be reused - * (memory or stack limit exceeded). + * (memory or stack limit exceeded). Timeouts are also terminal but take a + * separate release path (see `releaseAfterTimeout` in isolate-context). */ export function isFatalQuickJSLimitError(error: NormalizedError): boolean { return ( @@ -41,6 +43,15 @@ export function normalizeError(error: unknown): NormalizedError { } } + // QuickJS reports a fired interrupt handler as "InternalError: interrupted". + if (error.name === 'InternalError' && lower.includes('interrupted')) { + return { + name: TIMEOUT_ERROR, + message: 'Code execution timed out', + stack: error.stack, + } + } + if (error.name === 'RuntimeError' && lower.includes('unreachable')) { return { name: 'WasmRuntimeError', @@ -65,9 +76,24 @@ export function normalizeError(error: unknown): NormalizedError { if (typeof error === 'object' && error !== null) { const errObj = error as Record + const name = String(errObj.name || 'Error') + const message = String(errObj.message || 'Unknown error') + if ( + name === 'InternalError' && + message.toLowerCase().includes('interrupted') + ) { + return { + name: TIMEOUT_ERROR, + message: 'Code execution timed out', + ...(errObj['stack'] !== undefined && { + stack: String(errObj['stack']), + }), + } + } + return { - name: String(errObj.name || 'Error'), - message: String(errObj.message || 'Unknown error'), + name, + message, ...(errObj['stack'] !== undefined && { stack: String(errObj['stack']), }), diff --git a/packages/ai-isolate-quickjs/src/isolate-context.ts b/packages/ai-isolate-quickjs/src/isolate-context.ts index e23bcb76a..bde49ca20 100644 --- a/packages/ai-isolate-quickjs/src/isolate-context.ts +++ b/packages/ai-isolate-quickjs/src/isolate-context.ts @@ -1,29 +1,107 @@ import { wrapCode } from '@tanstack/ai-code-mode' -import { isFatalQuickJSLimitError, normalizeError } from './error-normalizer' -import type { QuickJSAsyncContext } from 'quickjs-emscripten' +import { + TIMEOUT_ERROR, + isFatalQuickJSLimitError, + normalizeError, +} from './error-normalizer' +import type { + QuickJSContext, + QuickJSHandle, + VmCallResult, +} from 'quickjs-emscripten' import type { ExecutionResult, IsolateContext } from '@tanstack/ai-code-mode' /** - * Serializes all QuickJS evalCodeAsync calls across contexts. - * Required because newAsyncContext() reuses a singleton WASM module - * whose asyncify stack can only handle one suspension at a time. + * Execution state shared with the tool bindings created in the driver. + * `deadline === 0` means no execution is active, so any guest job that runs + * outside execute() is interrupted immediately. `pendingCancels` holds one + * cancel callback per tool call still awaiting its host promise; a timed-out + * execution invokes them to settle the guest program so the VM can be + * disposed safely. */ -let globalExecQueue: Promise = Promise.resolve() +export interface ExecState { + deadline: number + pendingCancels: Set<() => void> +} + +/** Grace window for cancellation continuations after a timeout. */ +const CANCEL_GRACE_MS = 100 + +/** + * Await the guest program's promise, but give up at `deadline`. Host tool + * calls are bridged as QuickJS promises, so a guest program that is stuck + * waiting (e.g. its continuation was interrupted) would otherwise never + * settle. A timeout is terminal for the context (see `fail()` in execute): + * the timed-out program's interrupted jobs stay queued in the VM and must + * never run inside a later execution. If the guest settles after the + * deadline, its result handle is disposed to avoid leaking it into the + * context's lifetime. + */ +function awaitWithDeadline( + promise: Promise>, + deadline: number, +): Promise> { + return new Promise((resolve, reject) => { + let timedOut = false + const timer = setTimeout( + () => { + timedOut = true + const timeoutError = new Error('Code execution timed out') + timeoutError.name = TIMEOUT_ERROR + reject(timeoutError) + }, + Math.max(0, deadline - Date.now()), + ) + + promise.then( + (result) => { + if (timedOut) { + try { + if ('error' in result && result.error) { + result.error.dispose() + } else { + result.value.dispose() + } + } catch { + // context may already be disposed + } + return + } + clearTimeout(timer) + resolve(result) + }, + (error) => { + if (timedOut) return + clearTimeout(timer) + reject(error) + }, + ) + }) +} /** * IsolateContext implementation using QuickJS WASM */ export class QuickJSIsolateContext implements IsolateContext { - private readonly vm: QuickJSAsyncContext + private readonly vm: QuickJSContext private readonly logs: Array private readonly timeout: number + private readonly execState: ExecState + /** Serializes execute() calls so evaluations on this VM never interleave. */ + private execQueue: Promise = Promise.resolve() private disposed = false private executing = false - constructor(vm: QuickJSAsyncContext, logs: Array, timeout: number) { + constructor( + vm: QuickJSContext, + logs: Array, + timeout: number, + execState: ExecState, + ) { this.vm = vm this.logs = logs this.timeout = timeout + this.execState = execState } async execute(code: string): Promise> { @@ -38,14 +116,14 @@ export class QuickJSIsolateContext implements IsolateContext { } } - // Serialize through the global queue to prevent concurrent - // WASM asyncify suspensions across contexts. + // Serialize through the queue so concurrent execute() calls on this + // context never interleave their pending jobs. let resolve!: () => void const myTurn = new Promise((r) => { resolve = r }) - const waitForPrev = globalExecQueue - globalExecQueue = myTurn + const waitForPrev = this.execQueue + this.execQueue = myTurn await waitForPrev @@ -65,7 +143,13 @@ export class QuickJSIsolateContext implements IsolateContext { this.executing = true this.logs.length = 0 - const releaseVmAfterFatalLimit = () => { + // Starts true. + // Becomes false once the guest program promise is being awaited, and returns to true when it settles. + // Stays false if the program promise never settles (e.g. interrupt during a spin). + // Only pre-arm failures leave it true so timeout release can dispose. + let guestSettled = true + + const releaseVmAfterFatalError = async () => { if (this.disposed) return try { this.vm.runtime.setInterruptHandler(() => false) @@ -73,13 +157,53 @@ export class QuickJSIsolateContext implements IsolateContext { // ignore if runtime is already torn down } this.disposed = true + + // Fatal limits can occur while host tools still own unsettled QuickJS + // deferreds. Settle them before freeing the runtime so their native + // handles cannot abort the shared WASM module during JS_FreeRuntime. + for (const cancel of [...this.execState.pendingCancels]) { + cancel() + } + this.execState.pendingCancels.clear() + await new Promise((r) => setTimeout(r, 0)) + this.vm.dispose() } - const fail = (error: unknown) => { + // A timed-out program or failed initial pending-job pump can leave jobs + // queued in the VM, where they would run inside the next execution's + // deadline. Both are terminal for the context. Cancel every outstanding + // tool call, then dispose once the guest has settled; if it cannot settle, + // leak the VM instead of aborting the shared WASM module. + const releaseAfterUnsettledExecution = async () => { + if (this.disposed) return + this.disposed = true + this.execState.deadline = Date.now() + CANCEL_GRACE_MS + for (const cancel of [...this.execState.pendingCancels]) { + cancel() + } + this.execState.pendingCancels.clear() + // Cancellation continuations run as microtasks; one macrotask tick + // lets the guest program settle and its result handles be reclaimed. + await new Promise((r) => setTimeout(r, 0)) + this.execState.deadline = 0 + if (guestSettled) { + this.vm.dispose() + } + } + + const fail = async ( + error: unknown, + cleanup: 'reusable' | 'terminal' = 'reusable', + ) => { const normalized = normalizeError(error) - if (isFatalQuickJSLimitError(normalized)) { - releaseVmAfterFatalLimit() + if (normalized.name === TIMEOUT_ERROR) { + await releaseAfterUnsettledExecution() + } else if (isFatalQuickJSLimitError(normalized)) { + // Memory/stack limits leave the heap in an unknown state. + await releaseVmAfterFatalError() + } else if (cleanup === 'terminal') { + await releaseAfterUnsettledExecution() } return { success: false as const, @@ -92,24 +216,39 @@ export class QuickJSIsolateContext implements IsolateContext { const wrappedCode = wrapCode(code) const deadline = Date.now() + this.timeout - this.vm.runtime.setInterruptHandler(() => { - return Date.now() > deadline - }) + this.execState.deadline = deadline try { - const result = await this.vm.evalCodeAsync(wrappedCode) + const result = this.vm.evalCode(wrappedCode) let parsedResult: T try { const promiseHandle = this.vm.unwrapResult(result) - // evalCodeAsync returns a Promise handle (our wrapper is an async IIFE). - // Use resolvePromise + executePendingJobs to properly await the - // QuickJS promise without re-entering the WASM asyncify state. + // evalCode returns a Promise handle (our wrapper is an async IIFE). + // Await it via resolvePromise; guest continuations run when the + // tool bindings' promise-settled pumps call executePendingJobs. const nativePromise = this.vm.resolvePromise(promiseHandle) promiseHandle.dispose() - this.vm.runtime.executePendingJobs() - const resolvedResult = await nativePromise + guestSettled = false + void nativePromise.then( + () => { + guestSettled = true + }, + () => { + guestSettled = true + }, + ) + const jobs = this.vm.runtime.executePendingJobs() + if (jobs.error) { + const dumped: unknown = this.vm.dump(jobs.error) + jobs.error.dispose() + return await fail(dumped, 'terminal') + } + const resolvedResult = await awaitWithDeadline( + nativePromise, + deadline, + ) const valueHandle = this.vm.unwrapResult(resolvedResult) const dumpedResult = this.vm.dump(valueHandle) @@ -131,17 +270,18 @@ export class QuickJSIsolateContext implements IsolateContext { logs: [...this.logs], } } catch (unwrapError) { - return fail(unwrapError) + return await fail(unwrapError) } } finally { - // fail() may set disposed when releasing the VM after memory/stack limit errors + // fail() may set disposed when releasing the VM after memory/stack + // limit errors or timeouts // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- disposed set in fail() if (!this.disposed) { - this.vm.runtime.setInterruptHandler(() => false) + this.execState.deadline = 0 } } } catch (error) { - return fail(error) + return await fail(error) } finally { this.executing = false resolve() @@ -151,14 +291,37 @@ export class QuickJSIsolateContext implements IsolateContext { async dispose(): Promise { if (this.disposed) return - // If an execution is in flight, wait for the global queue to drain - // before disposing the VM. Otherwise the asyncified callback would + // If an execution is in flight, wait for the queue to drain before + // disposing the VM. Otherwise a pending tool-binding callback would // try to access a freed context. if (this.executing) { - await globalExecQueue + await this.execQueue + // The execution may have disposed the VM, or intentionally retained an + // unsettled VM, while dispose() was waiting for the queue. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- execution cleanup may set disposed while awaiting + if (this.disposed) return } this.disposed = true + + // A completed execution can leave tool calls still awaiting their host + // promise (e.g. a Promise.race loser abandoned by the guest program). + // Each holds an unsettled QuickJS deferred, and freeing a runtime with + // live deferred handles aborts the shared WASM module + // (`Assertion failed: list_empty(&rt->gc_obj_list)` in JS_FreeRuntime), + // poisoning every later context in this process. Settle them exactly like + // the timeout path does, then give the settled pumps one macrotask tick + // to dispose their deferreds before the VM goes away. + if (this.execState.pendingCancels.size > 0) { + this.execState.deadline = Date.now() + CANCEL_GRACE_MS + for (const cancel of [...this.execState.pendingCancels]) { + cancel() + } + this.execState.pendingCancels.clear() + await new Promise((r) => setTimeout(r, 0)) + this.execState.deadline = 0 + } + this.vm.dispose() } } diff --git a/packages/ai-isolate-quickjs/src/isolate-driver.ts b/packages/ai-isolate-quickjs/src/isolate-driver.ts index 971e0bb96..6c97c7fc6 100644 --- a/packages/ai-isolate-quickjs/src/isolate-driver.ts +++ b/packages/ai-isolate-quickjs/src/isolate-driver.ts @@ -1,9 +1,12 @@ -import { newAsyncContext } from 'quickjs-emscripten' +import { getQuickJS } from 'quickjs-emscripten' import { QuickJSIsolateContext } from './isolate-context' +import type { ExecState } from './isolate-context' +import type { QuickJSContext } from 'quickjs-emscripten' import type { IsolateConfig, IsolateContext, IsolateDriver, + ToolBinding, } from '@tanstack/ai-code-mode' /** Default memory limit in MB (matches Node isolate driver default). */ @@ -34,6 +37,113 @@ export interface QuickJSIsolateDriverConfig { maxStackSize?: number } +/** + * Run a tool binding against JSON-encoded args. Never rejects: errors are + * encoded into the JSON envelope so the guest-side wrapper can rethrow them. + */ +async function invokeBinding( + binding: ToolBinding, + argsJson: string, +): Promise { + try { + const args = JSON.parse(argsJson) + const result = await binding.execute(args) + return JSON.stringify({ success: true, value: result }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + return JSON.stringify({ success: false, error: errorMessage }) + } +} + +/** + * Inject a tool binding as a host function that returns a QuickJS promise + * resolved from the host side. + * + * Deliberately avoids `newAsyncifiedFunction`: repeated asyncify suspensions + * corrupt QuickJS refcounts and abort the WASM module + * (https://github.com/justjake/quickjs-emscripten/issues/258). The promise + * bridge never suspends the WASM stack, so that bug cannot trigger. + */ +function injectBinding( + vm: QuickJSContext, + name: string, + binding: ToolBinding, + logs: Array, + execState: ExecState, +): void { + const toolFn = vm.newFunction(name, (argsHandle) => { + const argsJson = vm.getString(argsHandle) + const promise = vm.newPromise() + + // A timed-out execution cancels every outstanding tool call by settling + // its deferred with a timeout envelope, so the guest program itself can + // settle and the VM can be disposed (freeing a runtime that still holds + // an unsettled program promise aborts the shared WASM module). + const resolveWithPayload = (payloadJson: string) => { + execState.pendingCancels.delete(cancel) + if (!vm.alive || !promise.alive) return + const payloadHandle = vm.newString(payloadJson) + promise.resolve(payloadHandle) + payloadHandle.dispose() + } + const cancel = () => + resolveWithPayload( + JSON.stringify({ success: false, error: 'Execution timed out' }), + ) + execState.pendingCancels.add(cancel) + + void invokeBinding(binding, argsJson).then(resolveWithPayload) + + // Resume guest code waiting on the promise. Defense in depth: outside an + // active execute() the interrupt deadline is 0, so a stray job from an + // abandoned execution is interrupted instead of running unbounded. + void promise.settled.then(() => { + try { + if (vm.runtime.alive) { + const jobs = vm.runtime.executePendingJobs() + if (jobs.error) { + // Errors thrown inside guest async code reject the observed program + // promise instead of surfacing here; anything that does land here + // would otherwise be silently swallowed and leak its handle. + logs.push( + `ERROR: uncaught error in sandboxed code: ${JSON.stringify(vm.dump(jobs.error))}`, + ) + jobs.error.dispose() + } + } + } finally { + promise.dispose() + } + }) + + return promise.handle + }) + + // Set on global - the VM keeps its own reference + vm.setProp(vm.global, `__${name}_impl`, toolFn) + toolFn.dispose() + + // Create wrapper that parses input and output + // Function names match the binding keys (e.g., external_fetchWeather) + const wrapperCode = ` + async function ${name}(input) { + const resultJson = await __${name}_impl(JSON.stringify(input)); + const result = JSON.parse(resultJson); + if (!result.success) { + throw new Error(result.error); + } + return result.value; + } + ` + const wrapperResult = vm.evalCode(wrapperCode) + if (wrapperResult.error) { + const errorStr = vm.dump(wrapperResult.error) + wrapperResult.error.dispose() + throw new Error(`Failed to create wrapper for ${name}: ${errorStr}`) + } + wrapperResult.value.dispose() +} + /** * Create a QuickJS WASM isolate driver * @@ -82,8 +192,11 @@ export function createQuickJSIsolateDriver( const memoryLimitMb = isolateConfig.memoryLimit ?? defaultMemoryLimit const maxStackSizeBytes = defaultMaxStackSize - // Create async QuickJS context (supports async host functions) - const vm = await newAsyncContext() + // Create a plain (non-asyncify) QuickJS context. Host async functions + // are bridged with QuickJS promises instead of asyncify suspensions, + // so the sync WASM build is sufficient and sidesteps asyncify bugs. + const QuickJS = await getQuickJS() + const vm = QuickJS.newContext() // Enforce heap and stack limits so OOM/stack overflow surface as JS errors // instead of growing WASM memory until the host process OOMs. @@ -126,60 +239,23 @@ export function createQuickJSIsolateDriver( infoFn.dispose() consoleObj.dispose() + // Shared between execute() and the tool bindings: the interrupt + // deadline (0 means "no execution active" — any guest job that tries + // to run outside execute() is interrupted immediately) and the cancel + // callbacks for tool calls still awaiting their host promise. + const execState: ExecState = { + deadline: 0, + pendingCancels: new Set<() => void>(), + } + // Inject each tool binding as an async function for (const [name, binding] of Object.entries(isolateConfig.bindings)) { - // Create async function that calls back to host - // newAsyncifiedFunction receives QuickJS handles as arguments - const toolFn = vm.newAsyncifiedFunction(name, async (argsHandle) => { - try { - // Get the input argument - argsHandle is a QuickJS handle - const argsJson = vm.getString(argsHandle) - const args = JSON.parse(argsJson) - - // Execute the tool on the host - const result = await binding.execute(args) - - // Return result as JSON string handle - const returnHandle = vm.newString( - JSON.stringify({ success: true, value: result }), - ) - return returnHandle - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - const returnHandle = vm.newString( - JSON.stringify({ success: false, error: errorMessage }), - ) - return returnHandle - } - }) - - // Set on global - the VM keeps its own reference - vm.setProp(vm.global, `__${name}_impl`, toolFn) - toolFn.dispose() - - // Create wrapper that parses input and output - // Function names match the binding keys (e.g., external_fetchWeather) - const wrapperCode = ` - async function ${name}(input) { - const resultJson = await __${name}_impl(JSON.stringify(input)); - const result = JSON.parse(resultJson); - if (!result.success) { - throw new Error(result.error); - } - return result.value; - } - ` - const wrapperResult = vm.evalCode(wrapperCode) - if (wrapperResult.error) { - const errorStr = vm.dump(wrapperResult.error) - wrapperResult.error.dispose() - throw new Error(`Failed to create wrapper for ${name}: ${errorStr}`) - } - wrapperResult.value.dispose() + injectBinding(vm, name, binding, logs, execState) } - return new QuickJSIsolateContext(vm, logs, timeout) + vm.runtime.setInterruptHandler(() => Date.now() > execState.deadline) + + return new QuickJSIsolateContext(vm, logs, timeout, execState) }, } } diff --git a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts index eb1506afb..23c6a4c34 100644 --- a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts +++ b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createQuickJSIsolateDriver } from '../src/isolate-driver' +import { normalizeError } from '../src/error-normalizer' import type { ToolBinding } from '@tanstack/ai-code-mode' function makeBinding( @@ -14,6 +15,31 @@ function makeBinding( } } +describe('normalizeError', () => { + it('normalizes dumped QuickJS interrupt errors as timeouts', () => { + expect( + normalizeError({ + name: 'InternalError', + message: 'interrupted', + stack: 'guest stack', + }), + ).toEqual({ + name: 'TimeoutError', + message: 'Code execution timed out', + stack: 'guest stack', + }) + }) + + it('does not treat unrelated interrupted errors as timeouts', () => { + const error = new Error('operation interrupted') + + expect(normalizeError(error)).toMatchObject({ + name: 'Error', + message: 'operation interrupted', + }) + }) +}) + describe('createQuickJSIsolateDriver', () => { describe('createContext', () => { it('returns a context with execute and dispose', async () => { @@ -113,6 +139,72 @@ describe('createQuickJSIsolateDriver', () => { expect(result.value).toBe('AB') }) + it('supports sequential awaits of the same asyncified host function', async () => { + // Regression test for https://github.com/justjake/quickjs-emscripten/issues/258 + const calls: Array = [] + const get = makeBinding('get', async (args) => { + if ( + typeof args !== 'object' || + args === null || + !('path' in args) || + typeof args.path !== 'string' + ) { + throw new Error('Expected a path argument') + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + calls.push(args.path) + return { path: args.path } + }) + + const driver = createQuickJSIsolateDriver() + const context = await driver.createContext({ bindings: { get } }) + + try { + const result = await context.execute(` + const a = await get({ path: "/a" }); + const b = await get({ path: "/b" }); + return [a, b]; + `) + + expect(result.success).toBe(true) + expect(result.value).toEqual([{ path: '/a' }, { path: '/b' }]) + expect(calls).toEqual(['/a', '/b']) + } finally { + await context.dispose() + } + }) + + it('supports concurrent tool calls via Promise.all', async () => { + const get = makeBinding('get', async (args) => { + await new Promise((resolve) => setTimeout(resolve, 0)) + return args + }) + + const driver = createQuickJSIsolateDriver() + const context = await driver.createContext({ bindings: { get } }) + + try { + const result = await context.execute(` + const [a, b, c] = await Promise.all([ + get({ path: "/a" }), + get({ path: "/b" }), + get({ path: "/c" }), + ]); + return [a, b, c]; + `) + + expect(result.success).toBe(true) + expect(result.value).toEqual([ + { path: '/a' }, + { path: '/b' }, + { path: '/c' }, + ]) + } finally { + await context.dispose() + } + }) + it('surfaces tool execution errors', async () => { const failTool = makeBinding('failTool', async () => { throw new Error('Tool failed') @@ -148,7 +240,67 @@ describe('createQuickJSIsolateDriver', () => { `) expect(result.success).toBe(false) - expect(result.error?.name).toBeDefined() + expect(result.error?.name).toBe('TimeoutError') + }) + + it('disposes the context after a timeout so stale jobs cannot leak into later executions', async () => { + let resolveNever!: (value: unknown) => void + const neverResult = new Promise((resolve) => { + resolveNever = resolve + }) + const never = makeBinding('never', () => neverResult) + + const driver = createQuickJSIsolateDriver({ timeout: 100 }) + const context = await driver.createContext({ bindings: { never } }) + + const first = await context.execute(` + await never({}); + return 1; + `) + expect(first.success).toBe(false) + expect(first.error?.name).toBe('TimeoutError') + + const second = await context.execute('return 42') + expect(second.success).toBe(false) + expect(second.error?.name).toBe('DisposedError') + + await expect(context.dispose()).resolves.toBeUndefined() + + resolveNever('late') + await new Promise((resolve) => setTimeout(resolve, 0)) + + const next = await driver.createContext({ bindings: {} }) + const after = await next.execute('return 42') + + expect(after.success).toBe(true) + expect(after.value).toBe(42) + await next.dispose() + }) + + it('safely disposes while an in-flight execution times out', async () => { + let markToolStarted!: () => void + const toolStarted = new Promise((resolve) => { + markToolStarted = resolve + }) + const never = makeBinding('never', () => { + markToolStarted() + return new Promise(() => {}) + }) + + const driver = createQuickJSIsolateDriver({ timeout: 100 }) + const context = await driver.createContext({ bindings: { never } }) + + const execution = context.execute(` + await never({}); + return 1; + `) + await toolStarted + + const disposal = context.dispose() + const [result] = await Promise.all([execution, disposal]) + + expect(result.success).toBe(false) + expect(result.error?.name).toBe('TimeoutError') }) }) @@ -217,6 +369,47 @@ describe('createQuickJSIsolateDriver', () => { expect(result.error?.name).toBe('DisposedError') expect(result.error?.message).toContain('disposed') }) + + it('disposes cleanly while an abandoned tool call is still in flight', async () => { + // A guest program can settle while a tool call is still awaiting its + // host promise (e.g. a Promise.race loser). Disposing the VM with that + // unsettled deferred used to abort the shared WASM module in + // JS_FreeRuntime (`list_empty(&rt->gc_obj_list)`), breaking every + // later context in the process. + const driver = createQuickJSIsolateDriver() + let resolveSlow!: (value: unknown) => void + const slow = new Promise((resolve) => { + resolveSlow = resolve + }) + const context = await driver.createContext({ + bindings: { + fast: makeBinding('fast', async () => 'fast'), + slow: makeBinding('slow', () => slow), + }, + }) + + const result = await context.execute( + 'return await Promise.race([slow({}), fast({})])', + ) + + expect(result.success).toBe(true) + expect(result.value).toBe('fast') + + await expect(context.dispose()).resolves.toBeUndefined() + + // The shared WASM module must still be healthy for later contexts. + const next = await driver.createContext({ + bindings: { fast: makeBinding('fast', async () => 'ok') }, + }) + const after = await next.execute('return await fast({})') + + expect(after.success).toBe(true) + expect(after.value).toBe('ok') + + await next.dispose() + // The host promise settling after disposal must be a no-op. + resolveSlow('late') + }) }) describe('memory isolation', () => { @@ -267,6 +460,31 @@ describe('createQuickJSIsolateDriver', () => { expect(result.error?.message).toContain('memory limit') }) + it('keeps shared WASM healthy after OOM with an in-flight tool', async () => { + const driver = createQuickJSIsolateDriver({ memoryLimit: 1 }) + const context = await driver.createContext({ + bindings: { + hang: makeBinding('hang', () => new Promise(() => {})), + }, + memoryLimit: 1, + }) + + const result = await context.execute(` + hang({}); + return "x".repeat(8 * 1024 * 1024); + `) + + expect(result.success).toBe(false) + expect(result.error?.name).toBe('MemoryLimitError') + + const next = await driver.createContext({ bindings: {} }) + const after = await next.execute('return 42') + + expect(after.success).toBe(true) + expect(after.value).toBe(42) + await next.dispose() + }) + it('dispose is safe after memory limit error', async () => { const driver = createQuickJSIsolateDriver({ memoryLimit: 1 }) const context = await driver.createContext({