From 7e799241a3b516ac3af370804605ceaa5fb76cb0 Mon Sep 17 00:00:00 2001 From: Silvio Date: Wed, 15 Jul 2026 12:32:49 +0300 Subject: [PATCH 1/9] fix(ai-isolate-quickjs): replace asyncify host functions with promise bridging --- .changeset/quickjs-promise-bridge.md | 5 + .../ai-isolate-quickjs/src/isolate-context.ts | 111 ++++++++++--- .../ai-isolate-quickjs/src/isolate-driver.ts | 150 +++++++++++------- .../tests/isolate-driver.test.ts | 66 ++++++++ 4 files changed, 256 insertions(+), 76 deletions(-) create mode 100644 .changeset/quickjs-promise-bridge.md diff --git a/.changeset/quickjs-promise-bridge.md b/.changeset/quickjs-promise-bridge.md new file mode 100644 index 000000000..7570a382b --- /dev/null +++ b/.changeset/quickjs-promise-bridge.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-isolate-quickjs': patch +--- + +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. diff --git a/packages/ai-isolate-quickjs/src/isolate-context.ts b/packages/ai-isolate-quickjs/src/isolate-context.ts index e23bcb76a..f215ffc61 100644 --- a/packages/ai-isolate-quickjs/src/isolate-context.ts +++ b/packages/ai-isolate-quickjs/src/isolate-context.ts @@ -1,29 +1,93 @@ import { wrapCode } from '@tanstack/ai-code-mode' import { isFatalQuickJSLimitError, normalizeError } from './error-normalizer' -import type { QuickJSAsyncContext } from 'quickjs-emscripten' +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. + * Interrupt deadline shared with the tool-binding job pumps created in + * the driver. `deadline === 0` means no execution is active, so any guest + * job that runs outside execute() is interrupted immediately. */ -let globalExecQueue: Promise = Promise.resolve() +export interface ExecState { + deadline: number +} + +/** + * 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. 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 = 'TimeoutError' + 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 +102,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 @@ -92,24 +156,25 @@ 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 + const resolvedResult = await awaitWithDeadline( + nativePromise, + deadline, + ) const valueHandle = this.vm.unwrapResult(resolvedResult) const dumpedResult = this.vm.dump(valueHandle) @@ -137,7 +202,7 @@ export class QuickJSIsolateContext implements IsolateContext { // fail() may set disposed when releasing the VM after memory/stack limit errors // 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) { @@ -151,11 +216,11 @@ 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 } this.disposed = true diff --git a/packages/ai-isolate-quickjs/src/isolate-driver.ts b/packages/ai-isolate-quickjs/src/isolate-driver.ts index 971e0bb96..e6f7d7c9b 100644 --- a/packages/ai-isolate-quickjs/src/isolate-driver.ts +++ b/packages/ai-isolate-quickjs/src/isolate-driver.ts @@ -1,9 +1,11 @@ -import { newAsyncContext } from 'quickjs-emscripten' +import { getQuickJS } from 'quickjs-emscripten' import { QuickJSIsolateContext } 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 +36,87 @@ 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, +): void { + const toolFn = vm.newFunction(name, (argsHandle) => { + const argsJson = vm.getString(argsHandle) + const promise = vm.newPromise() + + void invokeBinding(binding, argsJson).then((payloadJson) => { + // The context may have been disposed while the tool was in flight. + if (!vm.alive || !promise.alive) return + const payloadHandle = vm.newString(payloadJson) + promise.resolve(payloadHandle) + payloadHandle.dispose() + }) + + // Resume guest code waiting on the promise. Outside an active execute() + // the interrupt deadline is 0, so jobs from an abandoned (timed-out) + // execution are interrupted instead of running unbounded. + void promise.settled.then(() => { + if (vm.runtime.alive) { + vm.runtime.executePendingJobs() + } + }) + + 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 +165,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. @@ -128,58 +214,16 @@ export function createQuickJSIsolateDriver( // 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) } - return new QuickJSIsolateContext(vm, logs, timeout) + // Deadline shared between execute() and the pending-job pumps in the + // tool bindings. 0 means "no execution active": any guest job that + // tries to run outside execute() is interrupted immediately. + const execState = { deadline: 0 } + 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..6db5afaf8 100644 --- a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts +++ b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts @@ -113,6 +113,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') From 6d7c9a11a4db3c3f4b9c4ea7c5d832e79e19cb0f Mon Sep 17 00:00:00 2001 From: Silvio Date: Wed, 15 Jul 2026 12:32:49 +0300 Subject: [PATCH 2/9] fix(ai-isolate-quickjs): make timeouts terminal and surface pending-job errors --- .changeset/quickjs-promise-bridge.md | 7 +- .../src/error-normalizer.ts | 13 ++- .../ai-isolate-quickjs/src/isolate-context.ts | 90 +++++++++++++++---- .../ai-isolate-quickjs/src/isolate-driver.ts | 53 ++++++++--- .../tests/isolate-driver.test.ts | 22 ++++- 5 files changed, 154 insertions(+), 31 deletions(-) diff --git a/.changeset/quickjs-promise-bridge.md b/.changeset/quickjs-promise-bridge.md index 7570a382b..ac6932665 100644 --- a/.changeset/quickjs-promise-bridge.md +++ b/.changeset/quickjs-promise-bridge.md @@ -1,5 +1,10 @@ --- -'@tanstack/ai-isolate-quickjs': patch +'@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..8cf52c635 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 (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', diff --git a/packages/ai-isolate-quickjs/src/isolate-context.ts b/packages/ai-isolate-quickjs/src/isolate-context.ts index f215ffc61..436e86482 100644 --- a/packages/ai-isolate-quickjs/src/isolate-context.ts +++ b/packages/ai-isolate-quickjs/src/isolate-context.ts @@ -1,5 +1,9 @@ import { wrapCode } from '@tanstack/ai-code-mode' -import { isFatalQuickJSLimitError, normalizeError } from './error-normalizer' +import { + TIMEOUT_ERROR, + isFatalQuickJSLimitError, + normalizeError, +} from './error-normalizer' import type { QuickJSContext, QuickJSHandle, @@ -8,20 +12,30 @@ import type { import type { ExecutionResult, IsolateContext } from '@tanstack/ai-code-mode' /** - * Interrupt deadline shared with the tool-binding job pumps created in - * the driver. `deadline === 0` means no execution is active, so any guest - * job that runs outside execute() is interrupted immediately. + * 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. */ 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. If the guest settles after the deadline, its result handle is - * disposed to avoid leaking it into the context's lifetime. + * 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>, @@ -33,7 +47,7 @@ function awaitWithDeadline( () => { timedOut = true const timeoutError = new Error('Code execution timed out') - timeoutError.name = 'TimeoutError' + timeoutError.name = TIMEOUT_ERROR reject(timeoutError) }, Math.max(0, deadline - Date.now()), @@ -129,7 +143,11 @@ export class QuickJSIsolateContext implements IsolateContext { this.executing = true this.logs.length = 0 - const releaseVmAfterFatalLimit = () => { + // True until a program promise is in flight; sync failure paths (parse + // errors, interrupted straight-line code) leave no unsettled guest state. + let guestSettled = true + + const releaseVmAfterFatalError = () => { if (this.disposed) return try { this.vm.runtime.setInterruptHandler(() => false) @@ -140,10 +158,37 @@ export class QuickJSIsolateContext implements IsolateContext { this.vm.dispose() } - const fail = (error: unknown) => { + // A timed-out program's interrupted jobs stay queued in the VM, where + // they would run inside the next execution's deadline — so a timeout is + // terminal for the context. Disposal needs care: freeing a runtime that + // still holds an unsettled program promise aborts the shared WASM + // module. Cancel every outstanding tool call (settling the guest + // program), then dispose once the guest has settled; if it cannot + // settle (e.g. an interrupted infinite loop), leak the VM instead. + const releaseAfterTimeout = 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) => { const normalized = normalizeError(error) - if (isFatalQuickJSLimitError(normalized)) { - releaseVmAfterFatalLimit() + if (normalized.name === TIMEOUT_ERROR) { + await releaseAfterTimeout() + } else if (isFatalQuickJSLimitError(normalized)) { + // Memory/stack limits leave the heap in an unknown state. + releaseVmAfterFatalError() } return { success: false as const, @@ -170,7 +215,21 @@ export class QuickJSIsolateContext implements IsolateContext { // tool bindings' promise-settled pumps call executePendingJobs. const nativePromise = this.vm.resolvePromise(promiseHandle) promiseHandle.dispose() - this.vm.runtime.executePendingJobs() + 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) + } const resolvedResult = await awaitWithDeadline( nativePromise, deadline, @@ -196,17 +255,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.execState.deadline = 0 } } } catch (error) { - return fail(error) + return await fail(error) } finally { this.executing = false resolve() diff --git a/packages/ai-isolate-quickjs/src/isolate-driver.ts b/packages/ai-isolate-quickjs/src/isolate-driver.ts index e6f7d7c9b..cdc2d06b7 100644 --- a/packages/ai-isolate-quickjs/src/isolate-driver.ts +++ b/packages/ai-isolate-quickjs/src/isolate-driver.ts @@ -1,5 +1,6 @@ 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, @@ -67,25 +68,46 @@ 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() - void invokeBinding(binding, argsJson).then((payloadJson) => { - // The context may have been disposed while the tool was in flight. + // 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. Outside an active execute() - // the interrupt deadline is 0, so jobs from an abandoned (timed-out) - // execution are interrupted instead of running unbounded. + // 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(() => { - if (vm.runtime.alive) { - vm.runtime.executePendingJobs() + if (!vm.runtime.alive) return + 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() } }) @@ -212,15 +234,20 @@ 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)) { - injectBinding(vm, name, binding) + injectBinding(vm, name, binding, logs, execState) } - // Deadline shared between execute() and the pending-job pumps in the - // tool bindings. 0 means "no execution active": any guest job that - // tries to run outside execute() is interrupted immediately. - const execState = { deadline: 0 } 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 6db5afaf8..4b917decd 100644 --- a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts +++ b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts @@ -214,7 +214,27 @@ 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 () => { + const never = makeBinding('never', () => new Promise(() => {})) + + 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() }) }) From 854ca49757152e06396d782f8ed49da08606b210 Mon Sep 17 00:00:00 2001 From: Silvio Date: Thu, 16 Jul 2026 09:02:27 +0300 Subject: [PATCH 3/9] fix(ai-isolate-quickjs): address promise bridge review feedback --- .../src/error-normalizer.ts | 21 ++++++-- .../ai-isolate-quickjs/src/isolate-context.ts | 4 ++ .../ai-isolate-quickjs/src/isolate-driver.ts | 22 ++++---- .../tests/isolate-driver.test.ts | 52 +++++++++++++++++++ 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/packages/ai-isolate-quickjs/src/error-normalizer.ts b/packages/ai-isolate-quickjs/src/error-normalizer.ts index 8cf52c635..18523ed2d 100644 --- a/packages/ai-isolate-quickjs/src/error-normalizer.ts +++ b/packages/ai-isolate-quickjs/src/error-normalizer.ts @@ -44,7 +44,7 @@ export function normalizeError(error: unknown): NormalizedError { } // QuickJS reports a fired interrupt handler as "InternalError: interrupted". - if (lower.includes('interrupted')) { + if (error.name === 'InternalError' && lower.includes('interrupted')) { return { name: TIMEOUT_ERROR, message: 'Code execution timed out', @@ -76,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 436e86482..50123e5ea 100644 --- a/packages/ai-isolate-quickjs/src/isolate-context.ts +++ b/packages/ai-isolate-quickjs/src/isolate-context.ts @@ -281,6 +281,10 @@ export class QuickJSIsolateContext implements IsolateContext { // try to access a freed context. if (this.executing) { 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 diff --git a/packages/ai-isolate-quickjs/src/isolate-driver.ts b/packages/ai-isolate-quickjs/src/isolate-driver.ts index cdc2d06b7..d66aaa776 100644 --- a/packages/ai-isolate-quickjs/src/isolate-driver.ts +++ b/packages/ai-isolate-quickjs/src/isolate-driver.ts @@ -99,15 +99,19 @@ function injectBinding( // abandoned execution is interrupted instead of running unbounded. void promise.settled.then(() => { if (!vm.runtime.alive) return - 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() + try { + 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() } }) diff --git a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts index 4b917decd..8f2744c72 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 () => { @@ -236,6 +262,32 @@ describe('createQuickJSIsolateDriver', () => { await expect(context.dispose()).resolves.toBeUndefined() }) + + 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') + }) }) describe('execute - error handling', () => { From 3ddf160e761f0485c96b8ef6fa3dddaaa852eec2 Mon Sep 17 00:00:00 2001 From: Silvio Date: Thu, 16 Jul 2026 15:29:22 +0300 Subject: [PATCH 4/9] fix(ai-isolate-quickjs): settle pending tool-call deferreds on dispose Co-Authored-By: Claude Fable 5 --- .../ai-isolate-quickjs/src/isolate-context.ts | 19 +++++++++ .../tests/isolate-driver.test.ts | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/ai-isolate-quickjs/src/isolate-context.ts b/packages/ai-isolate-quickjs/src/isolate-context.ts index 50123e5ea..2c235f947 100644 --- a/packages/ai-isolate-quickjs/src/isolate-context.ts +++ b/packages/ai-isolate-quickjs/src/isolate-context.ts @@ -288,6 +288,25 @@ export class QuickJSIsolateContext implements IsolateContext { } 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/tests/isolate-driver.test.ts b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts index 8f2744c72..d13cff1be 100644 --- a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts +++ b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts @@ -355,6 +355,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', () => { From b24dbf166a3ed9c9b3a06ca0236ee4929200b997 Mon Sep 17 00:00:00 2001 From: Silvio Date: Fri, 17 Jul 2026 09:43:49 +0300 Subject: [PATCH 5/9] fix(ai-isolate-quickjs): ensure safe VM disposal after fatal errors --- .../ai-isolate-quickjs/src/isolate-context.ts | 14 +++++++++-- .../tests/isolate-driver.test.ts | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/ai-isolate-quickjs/src/isolate-context.ts b/packages/ai-isolate-quickjs/src/isolate-context.ts index 2c235f947..b9b6795a4 100644 --- a/packages/ai-isolate-quickjs/src/isolate-context.ts +++ b/packages/ai-isolate-quickjs/src/isolate-context.ts @@ -147,7 +147,7 @@ export class QuickJSIsolateContext implements IsolateContext { // errors, interrupted straight-line code) leave no unsettled guest state. let guestSettled = true - const releaseVmAfterFatalError = () => { + const releaseVmAfterFatalError = async () => { if (this.disposed) return try { this.vm.runtime.setInterruptHandler(() => false) @@ -155,6 +155,16 @@ 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() } @@ -188,7 +198,7 @@ export class QuickJSIsolateContext implements IsolateContext { await releaseAfterTimeout() } else if (isFatalQuickJSLimitError(normalized)) { // Memory/stack limits leave the heap in an unknown state. - releaseVmAfterFatalError() + await releaseVmAfterFatalError() } return { success: false as const, diff --git a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts index d13cff1be..06181bce4 100644 --- a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts +++ b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts @@ -446,6 +446,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({ From 81a71e9b70679606e2a51ff8038c46c4d56f5497 Mon Sep 17 00:00:00 2001 From: Silvio Date: Fri, 17 Jul 2026 09:51:07 +0300 Subject: [PATCH 6/9] fix(ai-isolate-quickjs): ensure error handling only occurs if VM is alive in injectBinding --- .../ai-isolate-quickjs/src/isolate-driver.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/ai-isolate-quickjs/src/isolate-driver.ts b/packages/ai-isolate-quickjs/src/isolate-driver.ts index d66aaa776..6c97c7fc6 100644 --- a/packages/ai-isolate-quickjs/src/isolate-driver.ts +++ b/packages/ai-isolate-quickjs/src/isolate-driver.ts @@ -98,17 +98,18 @@ function injectBinding( // 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(() => { - if (!vm.runtime.alive) return try { - 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() + 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() From d257ec1dce56db48a9a1f6727f4fce1c24c55b31 Mon Sep 17 00:00:00 2001 From: Silvio Date: Fri, 17 Jul 2026 10:04:29 +0300 Subject: [PATCH 7/9] fix(ai-isolate-quickjs): improve handling of unsettled executions and timeouts --- .../ai-isolate-quickjs/src/isolate-context.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/ai-isolate-quickjs/src/isolate-context.ts b/packages/ai-isolate-quickjs/src/isolate-context.ts index b9b6795a4..b9e56814f 100644 --- a/packages/ai-isolate-quickjs/src/isolate-context.ts +++ b/packages/ai-isolate-quickjs/src/isolate-context.ts @@ -168,14 +168,12 @@ export class QuickJSIsolateContext implements IsolateContext { this.vm.dispose() } - // A timed-out program's interrupted jobs stay queued in the VM, where - // they would run inside the next execution's deadline — so a timeout is - // terminal for the context. Disposal needs care: freeing a runtime that - // still holds an unsettled program promise aborts the shared WASM - // module. Cancel every outstanding tool call (settling the guest - // program), then dispose once the guest has settled; if it cannot - // settle (e.g. an interrupted infinite loop), leak the VM instead. - const releaseAfterTimeout = async () => { + // 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 @@ -192,13 +190,18 @@ export class QuickJSIsolateContext implements IsolateContext { } } - const fail = async (error: unknown) => { + const fail = async ( + error: unknown, + cleanup: 'reusable' | 'terminal' = 'reusable', + ) => { const normalized = normalizeError(error) if (normalized.name === TIMEOUT_ERROR) { - await releaseAfterTimeout() + 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, @@ -238,7 +241,7 @@ export class QuickJSIsolateContext implements IsolateContext { if (jobs.error) { const dumped: unknown = this.vm.dump(jobs.error) jobs.error.dispose() - return await fail(dumped) + return await fail(dumped, 'terminal') } const resolvedResult = await awaitWithDeadline( nativePromise, From 33b1e693f2d0726f5d356636dc10b743768d2a3e Mon Sep 17 00:00:00 2001 From: Silvio Date: Fri, 17 Jul 2026 10:12:01 +0300 Subject: [PATCH 8/9] fix(ai-isolate-quickjs): clarify guestSettled state management --- packages/ai-isolate-quickjs/src/isolate-context.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ai-isolate-quickjs/src/isolate-context.ts b/packages/ai-isolate-quickjs/src/isolate-context.ts index b9e56814f..bde49ca20 100644 --- a/packages/ai-isolate-quickjs/src/isolate-context.ts +++ b/packages/ai-isolate-quickjs/src/isolate-context.ts @@ -143,8 +143,10 @@ export class QuickJSIsolateContext implements IsolateContext { this.executing = true this.logs.length = 0 - // True until a program promise is in flight; sync failure paths (parse - // errors, interrupted straight-line code) leave no unsettled guest state. + // 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 () => { From c1e3829e01909782f9d8ed1951c775b94d3578d9 Mon Sep 17 00:00:00 2001 From: Silvio Date: Fri, 17 Jul 2026 10:38:28 +0300 Subject: [PATCH 9/9] fix(ai-isolate-quickjs): ensure context disposal after timeout prevents stale job leakage --- .../tests/isolate-driver.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts index 06181bce4..23c6a4c34 100644 --- a/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts +++ b/packages/ai-isolate-quickjs/tests/isolate-driver.test.ts @@ -244,7 +244,11 @@ describe('createQuickJSIsolateDriver', () => { }) it('disposes the context after a timeout so stale jobs cannot leak into later executions', async () => { - const never = makeBinding('never', () => new Promise(() => {})) + 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 } }) @@ -261,6 +265,16 @@ describe('createQuickJSIsolateDriver', () => { 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 () => {