Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/quickjs-promise-bridge.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 29 additions & 3 deletions packages/ai-isolate-quickjs/src/error-normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
}
}

Comment on lines +46 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files packages/ai-isolate-quickjs/src | sed -n '1,200p'
echo '--- error-normalizer.ts ---'
wc -l packages/ai-isolate-quickjs/src/error-normalizer.ts
sed -n '1,220p' packages/ai-isolate-quickjs/src/error-normalizer.ts
echo '--- isolate-context.ts ---'
wc -l packages/ai-isolate-quickjs/src/isolate-context.ts
sed -n '1,280p' packages/ai-isolate-quickjs/src/isolate-context.ts
echo '--- normalizeError usages ---'
rg -n "normalizeError|interrupted|InternalError|TIMEOUT_ERROR|jobs.error|executePendingJobs|pendingJobs" packages/ai-isolate-quickjs/src

Repository: TanStack/ai

Length of output: 14755


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
files = [
    Path('packages/ai-isolate-quickjs/src/error-normalizer.ts'),
    Path('packages/ai-isolate-quickjs/src/isolate-context.ts'),
]
for p in files:
    print(f"===== {p} =====")
    text = p.read_text()
    for needle in ['interrupted', 'InternalError', 'TIMEOUT_ERROR', 'normalizeError', 'jobs.error', 'executePendingJobs']:
        if needle in text:
            print(f"-- contains {needle}")
    print()
PY

Repository: TanStack/ai

Length of output: 512


Handle QuickJS interrupts in the dumped-object path too

normalizeError() only maps "interrupted" to TimeoutError for Error instances. The jobs.error path in packages/ai-isolate-quickjs/src/isolate-context.ts dumps the QuickJS error into a plain object first, so interrupts there still normalize to InternalError and skip releaseAfterTimeout(). Add the same InternalError/interrupted check to the object branch, and keep the Error branch scoped to InternalError to avoid misclassifying unrelated errors.

📍 Affects 2 files
  • packages/ai-isolate-quickjs/src/error-normalizer.ts#L46-L54 (this comment)
  • packages/ai-isolate-quickjs/src/isolate-context.ts#L161-L198
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-isolate-quickjs/src/error-normalizer.ts` around lines 46 - 54,
Update normalizeError in packages/ai-isolate-quickjs/src/error-normalizer.ts to
detect interrupted QuickJS errors in the dumped plain-object branch, requiring
name InternalError and an interrupted message before returning TimeoutError.
Keep the existing Error-instance check similarly scoped to InternalError, and
update the jobs.error handling in
packages/ai-isolate-quickjs/src/isolate-context.ts only as needed to preserve
releaseAfterTimeout() for these normalized interrupts.

if (error.name === 'RuntimeError' && lower.includes('unreachable')) {
return {
name: 'WasmRuntimeError',
Expand All @@ -65,9 +76,24 @@ export function normalizeError(error: unknown): NormalizedError {

if (typeof error === 'object' && error !== null) {
const errObj = error as Record<string, unknown>
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']),
}),
Expand Down
227 changes: 195 additions & 32 deletions packages/ai-isolate-quickjs/src/isolate-context.ts
Original file line number Diff line number Diff line change
@@ -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<void> = 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<VmCallResult<QuickJSHandle>>,
deadline: number,
): Promise<VmCallResult<QuickJSHandle>> {
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<string>
private readonly timeout: number
private readonly execState: ExecState
/** Serializes execute() calls so evaluations on this VM never interleave. */
private execQueue: Promise<void> = Promise.resolve()
private disposed = false
private executing = false

constructor(vm: QuickJSAsyncContext, logs: Array<string>, timeout: number) {
constructor(
vm: QuickJSContext,
logs: Array<string>,
timeout: number,
execState: ExecState,
) {
this.vm = vm
this.logs = logs
this.timeout = timeout
this.execState = execState
}

async execute<T = unknown>(code: string): Promise<ExecutionResult<T>> {
Expand All @@ -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<void>((r) => {
resolve = r
})
const waitForPrev = globalExecQueue
globalExecQueue = myTurn
const waitForPrev = this.execQueue
this.execQueue = myTurn

await waitForPrev

Expand All @@ -65,21 +143,67 @@ 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)
} catch {
// 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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical — FreeRuntime hole on fatal limits

releaseAfterTimeout and dispose() both cancel pendingCancels before vm.dispose() because unsettled QuickJS deferreds abort the shared WASM module (list_empty(&rt->gc_obj_list)). This path does not.

Repro shape:

const p = hangTool({})              // registers deferred + pendingCancel
return 'x'.repeat(8 * 1024 * 1024)  // MemoryLimitError while deferred still live

Existing OOM tests never leave a tool in flight, so this is unguarded.

Suggested fix: best-effort cancel + grace tick (same as timeout/dispose) before free; if the sick heap cannot cancel, catch and prefer intentional leak over aborting the shared module. See also #946’s requestVmDispose(), which already cancels on both timeout and fatal limits.

Test to add: start a never-resolving tool, then OOM (or stack overflow); assert a new context from the same driver still executes successfully.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing this out! Fatal limit cleanup now settles pending tool deferred before disposing the VM. I also added an OOM regression test that verifies a new context still works afterward, using your repro suggestion.

}

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,
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -151,14 +291,37 @@ export class QuickJSIsolateContext implements IsolateContext {
async dispose(): Promise<void> {
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()
}
}
Loading