Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/quickjs-native-promises.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-isolate-quickjs': patch
---

Bridge asynchronous host tools with native QuickJS promises to prevent repeated awaited tool calls from corrupting the Asyncify runtime.
152 changes: 127 additions & 25 deletions packages/ai-isolate-quickjs/src/isolate-context.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,128 @@
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.
* Preserves the driver's existing execution ordering across contexts.
*/
let globalExecQueue: Promise<void> = Promise.resolve()

function awaitWithDeadline(
promise: Promise<VmCallResult<QuickJSHandle>>,
pendingJobFailure: Promise<never>,
deadline: number,
): Promise<VmCallResult<QuickJSHandle>> {
return new Promise((resolve, reject) => {
let settled = false
const timer = setTimeout(
() => {
settled = true
const error = new Error('Code execution timed out')
error.name = 'TimeoutError'
reject(error)
},
Math.max(0, deadline - Date.now()),
)

promise.then(
(result) => {
if (settled) {
try {
if (result.error) result.error.dispose()
else result.value.dispose()
} catch {
// The context may have been disposed after a timeout.
}
return
}
settled = true
clearTimeout(timer)
resolve(result)
},
(error) => {
if (settled) return
settled = true
clearTimeout(timer)
reject(error)
},
)

void pendingJobFailure.catch((error: unknown) => {
if (settled) return
settled = true
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 rejectPendingJobFailure?: (error: unknown) => void
private pendingHostCalls = 0
private disposeRequested = false
private disposed = false
private executing = false

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

runPendingJobs(): void {
try {
const result = this.vm.runtime.executePendingJobs()
if (!result.error) {
result.dispose()
return
}

let error: unknown
try {
error = result.error.context.dump(result.error)
} finally {
result.dispose()
}
this.rejectPendingJobFailure?.(error)
} catch (error) {
this.rejectPendingJobFailure?.(error)
}
}

beginHostCall(): () => void {
this.pendingHostCalls += 1
let completed = false

return () => {
if (completed) return
completed = true
this.pendingHostCalls -= 1
if (this.disposeRequested && this.pendingHostCalls === 0) {
this.vm.dispose()
}
}
}

private requestVmDispose(): void {
if (this.disposeRequested) return
this.disposeRequested = true
this.disposed = true
if (this.pendingHostCalls === 0) {
this.vm.dispose()
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

async execute<T = unknown>(code: string): Promise<ExecutionResult<T>> {
if (this.disposed) {
return {
Expand All @@ -38,8 +135,7 @@ export class QuickJSIsolateContext implements IsolateContext {
}
}

// Serialize through the global queue to prevent concurrent
// WASM asyncify suspensions across contexts.
// Preserve the driver's existing cross-context execution ordering.
let resolve!: () => void
const myTurn = new Promise<void>((r) => {
resolve = r
Expand All @@ -64,22 +160,27 @@ export class QuickJSIsolateContext implements IsolateContext {

this.executing = true
this.logs.length = 0
const pendingJobFailure = new Promise<never>((_, reject) => {
this.rejectPendingJobFailure = reject
})

const releaseVmAfterFatalLimit = () => {
const releaseVmAfterTerminalError = () => {
if (this.disposed) return
try {
this.vm.runtime.setInterruptHandler(() => false)
} catch {
// ignore if runtime is already torn down
}
this.disposed = true
this.vm.dispose()
this.requestVmDispose()
}

const fail = (error: unknown) => {
const normalized = normalizeError(error)
if (isFatalQuickJSLimitError(normalized)) {
releaseVmAfterFatalLimit()
if (
normalized.name === 'TimeoutError' ||
isFatalQuickJSLimitError(normalized)
) {
releaseVmAfterTerminalError()
}
return {
success: false as const,
Expand All @@ -97,19 +198,21 @@ export class QuickJSIsolateContext implements IsolateContext {
})

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.
// wrapCode returns an async IIFE, so evalCode yields a QuickJS promise.
const nativePromise = this.vm.resolvePromise(promiseHandle)
promiseHandle.dispose()
this.vm.runtime.executePendingJobs()
const resolvedResult = await nativePromise
this.runPendingJobs()
const resolvedResult = await awaitWithDeadline(
nativePromise,
pendingJobFailure,
deadline,
)

const valueHandle = this.vm.unwrapResult(resolvedResult)
const dumpedResult = this.vm.dump(valueHandle)
Expand All @@ -134,7 +237,7 @@ export class QuickJSIsolateContext implements IsolateContext {
return fail(unwrapError)
}
} finally {
// fail() may set disposed when releasing the VM after memory/stack limit errors
// fail() may dispose the VM after a timeout or fatal resource limit.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- disposed set in fail()
if (!this.disposed) {
this.vm.runtime.setInterruptHandler(() => false)
Expand All @@ -143,6 +246,7 @@ export class QuickJSIsolateContext implements IsolateContext {
} catch (error) {
return fail(error)
} finally {
this.rejectPendingJobFailure = undefined
this.executing = false
resolve()
}
Expand All @@ -151,14 +255,12 @@ 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
// try to access a freed context.
// If an execution is in flight, wait for the global queue to drain so
// pending host callbacks cannot access a disposed context.
if (this.executing) {
await globalExecQueue
}

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