TanStack AI version
@tanstack/ai@0.40.0 / @tanstack/ai-isolate-quickjs@0.1.45
Framework/Library version
None — plain Node.js v22.21.1 (no framework; the bug reproduces in a standalone Node script using only @tanstack/ai-isolate-quickjs and @tanstack/ai-code-mode)
Describe the bug and the steps to reproduce it
Which project does this relate to?
@tanstack/ai-isolate-quickjs (Code Mode QuickJS isolate driver)
Description
If sandboxed code awaits a host tool binding more than once, the QuickJS WASM module crashes with a refcount assertion:
RuntimeError: Aborted(Assertion failed: p->ref_count == 0, at: ../../vendor/quickjs/quickjs.c,5512,free_zero_refcount)
The error can't be caught in practice, and it breaks the shared asyncify WASM module — so every other context in the process stops working too.
Code Mode is built around programs that chain multiple tool calls, so this makes the driver unusable for anything beyond a single tool invocation.
There's a related problem: if a tool binding returns a promise that never settles, execute() hangs forever. The configured timeout never fires, because it only exists as a QuickJS interrupt handler — there's no host-side timer running while the guest sits suspended in an asyncified call.
The root cause is that tool bindings are injected with vm.newAsyncifiedFunction() on a newAsyncContext() VM. Asyncify pauses and resumes the WASM stack on every host await, and doing that repeatedly corrupts QuickJS refcounts — a known upstream bug: justjake/quickjs-emscripten#258.
Platform
- OS: Windows 11 (also happens on Linux/macOS and in StackBlitz WebContainers — the bug is in the WASM module, not the platform)
- Node.js: v22.21.1
- @tanstack/ai-isolate-quickjs: 0.1.45
- quickjs-emscripten: 0.31.0
Suggested fix
Stop using asyncify. Instead of newAsyncContext() + newAsyncifiedFunction(), use the plain sync WASM build (getQuickJS().newContext()) and turn ea host function that returns a QuickJSpromise (vm.newPromise()). The host resolves that promise when the tool finishes, then calls runtime.executePendingJobs() so the gue. The WASM stack never gets suspended, so the upstream bug can't happen. This also makes two other things possible: the global cross-context execution queue can go away (it only exists becau one suspension at a time), andevalCodeAsync becomes plain evalCode + resolvePromise.
I have a working patch for this (it als the context, since with promise bridging an interrupted guest could otherwise be left waiting forever) and am happy to open a PR. The patch is published as a fork so it's easy to verify: @silviomollov/ai-isolate-quickjs (https://www.npmjs.com/package/@silviomollov/ai-isolate-quickjs) — swap only the import in the repro and it passes.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://stackblitz.com/edit/node-qwj94pnm?file=repro.mjs
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
Yes, I am also opening a PR that solves the problem along side this issue
Terms & Code of Conduct
TanStack AI version
@tanstack/ai@0.40.0 / @tanstack/ai-isolate-quickjs@0.1.45
Framework/Library version
None — plain Node.js v22.21.1 (no framework; the bug reproduces in a standalone Node script using only @tanstack/ai-isolate-quickjs and @tanstack/ai-code-mode)
Describe the bug and the steps to reproduce it
Which project does this relate to?
@tanstack/ai-isolate-quickjs (Code Mode QuickJS isolate driver)
Description
If sandboxed code awaits a host tool binding more than once, the QuickJS WASM module crashes with a refcount assertion:
RuntimeError: Aborted(Assertion failed: p->ref_count == 0, at: ../../vendor/quickjs/quickjs.c,5512,free_zero_refcount)
The error can't be caught in practice, and it breaks the shared asyncify WASM module — so every other context in the process stops working too.
Code Mode is built around programs that chain multiple tool calls, so this makes the driver unusable for anything beyond a single tool invocation.
There's a related problem: if a tool binding returns a promise that never settles, execute() hangs forever. The configured timeout never fires, because it only exists as a QuickJS interrupt handler — there's no host-side timer running while the guest sits suspended in an asyncified call.
The root cause is that tool bindings are injected with vm.newAsyncifiedFunction() on a newAsyncContext() VM. Asyncify pauses and resumes the WASM stack on every host await, and doing that repeatedly corrupts QuickJS refcounts — a known upstream bug: justjake/quickjs-emscripten#258.
Platform
Suggested fix
Stop using asyncify. Instead of newAsyncContext() + newAsyncifiedFunction(), use the plain sync WASM build (getQuickJS().newContext()) and turn ea host function that returns a QuickJSpromise (vm.newPromise()). The host resolves that promise when the tool finishes, then calls runtime.executePendingJobs() so the gue. The WASM stack never gets suspended, so the upstream bug can't happen. This also makes two other things possible: the global cross-context execution queue can go away (it only exists becau one suspension at a time), andevalCodeAsync becomes plain evalCode + resolvePromise.
I have a working patch for this (it als the context, since with promise bridging an interrupted guest could otherwise be left waiting forever) and am happy to open a PR. The patch is published as a fork so it's easy to verify: @silviomollov/ai-isolate-quickjs (https://www.npmjs.com/package/@silviomollov/ai-isolate-quickjs) — swap only the import in the repro and it passes.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://stackblitz.com/edit/node-qwj94pnm?file=repro.mjs
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
Yes, I am also opening a PR that solves the problem along side this issue
Terms & Code of Conduct