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
22 changes: 22 additions & 0 deletions .changeset/quickjs-timeout-host-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@tanstack/ai-isolate-quickjs': patch
---

Enforce `timeout` as a wall-clock deadline that covers host-call suspensions.

Previously the only deadline mechanism was QuickJS's `setInterruptHandler`, which
fires only while the sandbox is stepping bytecode. While the isolate is
asyncify-suspended awaiting a host binding's Promise, no interrupt fires, so the
configured `timeout` was silently ignored: a run with `timeout: 100` and an
800 ms host binding resolved successfully after ~800 ms, and a hung host binding
could hang `execute()` indefinitely.

`execute()` now races the run against a wall-clock timer, so `timeout` bounds the
entire execution including time spent suspended in a host call. On the deadline
it returns a normalized `TimeoutError` immediately. The suspended host Promise
cannot be cancelled and the VM cannot be freed mid-asyncify, so disposal and the
internal execution-queue turn are deferred until the orphaned run unwinds (the
still-armed interrupt handler aborts it the moment the host Promise settles),
which prevents a late host result from resuming a dead execution and avoids
leaking the context. CPU-bound loops are still interrupted as before and leave
the context reusable. The public API is unchanged.
12 changes: 12 additions & 0 deletions packages/ai-isolate-quickjs/src/error-normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NormalizedError } from '@tanstack/ai-code-mode'

const MEMORY_LIMIT_ERROR = 'MemoryLimitError'
const STACK_OVERFLOW_ERROR = 'StackOverflowError'
const TIMEOUT_ERROR = 'TimeoutError'

/**
* Whether this normalized error indicates the QuickJS VM should not be reused
Expand All @@ -21,6 +22,17 @@ export function normalizeError(error: unknown): NormalizedError {
const msg = error.message
const lower = msg.toLowerCase()

// QuickJS raises `InternalError: interrupted` when the interrupt handler
// trips the execution deadline. Surface it as a timeout so a CPU-bound
// deadline and a wall-clock (host-call) deadline report the same error.
if (lower.includes('interrupted')) {
return {
name: TIMEOUT_ERROR,
message: 'Code execution exceeded timeout',
stack: error.stack,
}
}

Comment on lines +25 to +35

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

# Inspect the normalizer and nearby isolate error handling.
printf '\n== error-normalizer.ts ==\n'
cat -n packages/ai-isolate-quickjs/src/error-normalizer.ts

printf '\n== search for normalizeError / TIMEOUT_ERROR / interrupted ==\n'
rg -n "normalizeError|TIMEOUT_ERROR|interrupted|stack overflow|out of memory|memory alloc" packages/ai-isolate-quickjs packages -S

printf '\n== inspect isolate-context usage ==\n'
cat -n packages/ai-isolate-quickjs/src/isolate-context.ts

Repository: TanStack/ai

Length of output: 22246


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the package files to inspect any tests or docs that describe the expected error shape.
fd -a -t f . packages/ai-isolate-quickjs | sed 's#^\./##' | sort

printf '\n== tests mentioning QuickJS errors ==\n'
rg -n "TimeoutError|InternalError: interrupted|interrupted|stack overflow|out of memory|memory alloc" packages/ai-isolate-quickjs -S

Repository: TanStack/ai

Length of output: 2258


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the exact implementation and any tests around timeout normalization.
cat -n packages/ai-isolate-quickjs/src/error-normalizer.ts
printf '\n---\n'
rg -n "TimeoutError|interrupted|InternalError: interrupted|error-normalizer" packages/ai-isolate-quickjs -S

Repository: TanStack/ai

Length of output: 4272


Gate the interrupted match on error.name === 'InternalError'.

As written, any host error whose message contains interrupted is normalized to TimeoutError, which can mask the real failure and drop the original error name/message. Matching the QuickJS InternalError: interrupted shape keeps unrelated interruptions from being misclassified.

🤖 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 25 - 35,
Update the error normalization logic in error-normalizer.ts so the QuickJS
interruption case only maps to TIMEOUT_ERROR when the exception is actually an
InternalError with an interrupted message. In the normalization branch that
checks lower.includes('interrupted'), add a guard on error.name ===
'InternalError' before returning the timeout shape. Keep the existing
TimeoutError mapping and stack preservation, but avoid classifying unrelated
host errors that merely mention interrupted.

if (
lower.includes('out of memory') ||
lower.includes('memory alloc') ||
Expand Down
257 changes: 182 additions & 75 deletions packages/ai-isolate-quickjs/src/isolate-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ import type { ExecutionResult, IsolateContext } from '@tanstack/ai-code-mode'
*/
let globalExecQueue: Promise<void> = Promise.resolve()

/** Sentinel resolved by the wall-clock deadline race. */
const TIMED_OUT = Symbol('quickjs-isolate-timeout')

/**
* Grace window granted, after the wall-clock timer fires, for the in-VM
* interrupt handler to settle a CPU-bound run before it is treated as a host
* call still suspended in asyncify. A CPU-bound loop trips the interrupt at
* (almost) the same instant the timer fires; the grace keeps that path on the
* normal, reusable-context branch instead of orphaning the VM.
*/
const TIMEOUT_INTERRUPT_GRACE_MS = 10

const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))

/**
* IsolateContext implementation using QuickJS WASM
*/
Expand All @@ -19,6 +34,13 @@ export class QuickJSIsolateContext implements IsolateContext {
private readonly timeout: number
private disposed = false
private executing = false
private timedOut = false
/**
* A run that timed out while a host call was suspended in asyncify. The VM
* cannot be disposed until this settles, so disposal and the global-queue
* turn are handed off to a continuation on it. `null` when no run is orphaned.
*/
private orphanedRun: Promise<void> | null = null

constructor(vm: QuickJSAsyncContext, logs: Array<string>, timeout: number) {
this.vm = vm
Expand All @@ -28,21 +50,14 @@ export class QuickJSIsolateContext implements IsolateContext {

async execute<T = unknown>(code: string): Promise<ExecutionResult<T>> {
if (this.disposed) {
return {
success: false,
error: {
name: 'DisposedError',
message: 'Context has been disposed',
},
logs: [],
}
return this.disposedResult<T>()
}

// Serialize through the global queue to prevent concurrent
// WASM asyncify suspensions across contexts.
let resolve!: () => void
let releaseTurn!: () => void
const myTurn = new Promise<void>((r) => {
resolve = r
releaseTurn = r
})
const waitForPrev = globalExecQueue
globalExecQueue = myTurn
Expand All @@ -51,114 +66,206 @@ export class QuickJSIsolateContext implements IsolateContext {

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose() may be called concurrently while awaiting the queue
if (this.disposed) {
resolve()
return {
success: false,
error: {
name: 'DisposedError',
message: 'Context has been disposed',
},
logs: [],
}
releaseTurn()
return this.disposedResult<T>()
}

this.executing = true
this.logs.length = 0

const releaseVmAfterFatalLimit = () => {
if (this.disposed) return
try {
this.vm.runtime.setInterruptHandler(() => false)
} catch {
// ignore if runtime is already torn down
}
this.disposed = true
this.vm.dispose()
}
// Set when a wall-clock timeout leaves a host call suspended in asyncify:
// interrupt-handler reset, VM disposal, and the queue-turn release are then
// owned by the orphaned-run continuation rather than the finally block.
let deferredToOrphan = false

const fail = (error: unknown) => {
const fail = (error: unknown): ExecutionResult<T> => {
const normalized = normalizeError(error)
if (isFatalQuickJSLimitError(normalized)) {
releaseVmAfterFatalLimit()
this.disposeVm()
}
return {
success: false as const,
success: false,
error: normalized,
logs: [...this.logs],
}
}

try {
const wrappedCode = wrapCode(code)

const deadline = Date.now() + this.timeout
this.vm.runtime.setInterruptHandler(() => {
return Date.now() > deadline
})
// Drives the wrapped code to completion and marshals its result. On a
// deadline interrupt `evalCodeAsync` resolves with an error result, which
// `unwrapResult` throws and `fail` normalizes.
const runToSettled = async (): Promise<ExecutionResult<T>> => {
const result = await this.vm.evalCodeAsync(wrapCode(code))

try {
const result = await this.vm.evalCodeAsync(wrappedCode)
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.
const nativePromise = this.vm.resolvePromise(promiseHandle)
promiseHandle.dispose()
this.vm.runtime.executePendingJobs()
const resolvedResult = await nativePromise

const valueHandle = this.vm.unwrapResult(resolvedResult)
const dumpedResult = this.vm.dump(valueHandle)
valueHandle.dispose()

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.
const nativePromise = this.vm.resolvePromise(promiseHandle)
promiseHandle.dispose()
this.vm.runtime.executePendingJobs()
const resolvedResult = await nativePromise

const valueHandle = this.vm.unwrapResult(resolvedResult)
const dumpedResult = this.vm.dump(valueHandle)
valueHandle.dispose()

if (typeof dumpedResult === 'string') {
try {
parsedResult = JSON.parse(dumpedResult) as T
} catch {
parsedResult = dumpedResult as T
}
} else {
if (typeof dumpedResult === 'string') {
try {
parsedResult = JSON.parse(dumpedResult) as T
} catch {
parsedResult = dumpedResult as T
}
} else {
parsedResult = dumpedResult as T
}

return {
success: true,
value: parsedResult,
logs: [...this.logs],
}
} catch (unwrapError) {
return fail(unwrapError)
// The wall-clock deadline may have fired while this run was suspended in
// a host call; the caller already received the timeout result, so a late
// success must not be surfaced as if the execution had finished in time.
if (this.timedOut) {
return this.timeoutResult<T>()
}
} finally {
// 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)

return {
success: true,
value: parsedResult,
logs: [...this.logs],
}
} catch (unwrapError) {
return fail(unwrapError)
}
}

try {
// The interrupt handler only fires while the VM is stepping bytecode, so
// it cannot preempt a host binding whose Promise is awaited via asyncify.
// Keep it for CPU-bound loops, but also race the run against a wall-clock
// timer so `timeout` bounds the whole execution, host suspensions included.
const deadline = Date.now() + this.timeout
this.vm.runtime.setInterruptHandler(() => Date.now() > deadline)

const run = runToSettled()

let timeoutTimer: ReturnType<typeof setTimeout> | undefined
const wallClock = new Promise<typeof TIMED_OUT>((resolve) => {
timeoutTimer = setTimeout(() => resolve(TIMED_OUT), this.timeout)
})

const outcome = await Promise.race([run, wallClock])
if (timeoutTimer !== undefined) {
clearTimeout(timeoutTimer)
}

if (outcome !== TIMED_OUT) {
return outcome
}

// The interrupt fires at (almost) the same instant for a CPU-bound loop;
// give the in-VM interrupt a brief window to settle the run before
// treating this as a host call still suspended in asyncify.
const graced = await Promise.race([
run,
delay(TIMEOUT_INTERRUPT_GRACE_MS).then(
(): typeof TIMED_OUT => TIMED_OUT,
),
])
if (graced !== TIMED_OUT) {
return graced
}

// Still suspended in a host call. The VM cannot be disposed now (freeing
// it mid-asyncify corrupts the stack the suspended call resumes into) and
// the host Promise cannot be cancelled. Leave the interrupt handler armed
// so the VM aborts the instant the host Promise resolves and it resumes
// stepping, then dispose the VM and release the queue turn once the
// orphaned run unwinds. This keeps the single-asyncify-suspension
// invariant that the global queue exists to protect.
this.timedOut = true
deferredToOrphan = true
this.orphanedRun = run
.catch(() => undefined)
.then(() => {
this.disposeVm()
releaseTurn()
})
Comment on lines +188 to +193

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Release the global queue even if orphan cleanup fails.

releaseTurn() currently runs after disposeVm(). If teardown throws, the global exec queue stays blocked and the orphan continuation can reject without a handler.

Proposed fix
       this.orphanedRun = run
         .catch(() => undefined)
         .then(() => {
-          this.disposeVm()
-          releaseTurn()
+          try {
+            this.disposeVm()
+          } finally {
+            releaseTurn()
+          }
         })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.orphanedRun = run
.catch(() => undefined)
.then(() => {
this.disposeVm()
releaseTurn()
})
this.orphanedRun = run
.catch(() => undefined)
.then(() => {
try {
this.disposeVm()
} finally {
releaseTurn()
}
})
🤖 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/isolate-context.ts` around lines 188 - 193,
The orphan cleanup chain in isolate-context should always release the global
queue even if VM disposal fails. Update the `this.orphanedRun` flow so
`releaseTurn()` is guaranteed to run independently of `disposeVm()` in the
orphan handling path, and make sure the `disposeVm`/cleanup logic in
`isolate-context` does not leave the continuation rejected without a handler.


return this.timeoutResult<T>()
} catch (error) {
return fail(error)
} finally {
// On the orphan path the continuation owns interrupt-handler reset, VM
// disposal, and the queue-turn release; the still-armed handler is what
// lets the suspended run abort when it resumes.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- disposed may be set by fail() after a fatal limit error
if (!deferredToOrphan && !this.disposed) {
this.vm.runtime.setInterruptHandler(() => false)
}
this.executing = false
resolve()
if (!deferredToOrphan) {
releaseTurn()
}
}
}

async dispose(): Promise<void> {
if (this.disposed) return

// A timed-out execution may have left a host call suspended in asyncify.
// Its continuation disposes the VM once the call unwinds; wait for that
// rather than freeing the VM out from under the suspended call.
if (this.orphanedRun) {
await this.orphanedRun
return
}

// If an execution is in flight, wait for the global queue to drain
// before disposing the VM. Otherwise the asyncified callback would
// try to access a freed context.
if (this.executing) {
await globalExecQueue
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the orphaned continuation may have disposed while we awaited the queue
if (this.disposed) return

this.disposed = true
this.vm.dispose()
}

/** Reset the interrupt handler and tear the VM down exactly once. */
private disposeVm(): void {
if (this.disposed) return
try {
this.vm.runtime.setInterruptHandler(() => false)
} catch {
// ignore if the runtime is already torn down
}
this.disposed = true
this.vm.dispose()
}

private disposedResult<T>(): ExecutionResult<T> {
return {
success: false,
error: {
name: 'DisposedError',
message: 'Context has been disposed',
},
logs: [],
}
}

private timeoutResult<T>(): ExecutionResult<T> {
return {
success: false,
error: {
name: 'TimeoutError',
message: `Code execution exceeded timeout of ${this.timeout}ms`,
},
logs: [...this.logs],
}
}
}
Loading