fix(quickjs): free bridge promise resolvers before disposing the VM

`vm.newPromise()` allocates three JSValues — the promise plus its `resolve`
and `reject` functions — and quickjs-emscripten frees the two functions only
from inside `resolve()`/`reject()`. `installSendRequestBridge` returned the
promise handle but could skip both (the `disposedRef` guard did exactly that),
so a `sendRequest()` still in flight at teardown left two live function objects
in the runtime and `JS_FreeRuntime` aborted on
`assert(list_empty(&rt->gc_obj_list))` — a native Emscripten abort that takes
down the Worker and the WASM module shared with the template-tag sandbox.

Track every unsettled deferred and dispose it before `vm.dispose()`, which is
the cleanup the library documents for this case. Guard the late-response path
on `vm.alive`/`deferred.alive` instead of a hand-rolled flag.

Also plumb the templating-db auth token from `run-script-quickjs.ts` through
the worker into the engine — it was accepted but never passed, so every bridge
fetch was rejected 401 by the protocol's auth gate.

The crash reproduces in vitest after all: the mocked fetch in the existing
tests settles in the same microtask checkpoint as the call, so the deferred was
always settled before teardown. Settling it on a later macrotask reproduces the
exact production abort, so the smoke test no longer needs `test.fail()`.
This commit is contained in:
jackkav committed 2026-08-14 14:27:31 +02:00
1 parent a2930ba59e
commit 5ad0278188
6 files changed
+231 -69

No files matched your search

@@ -6,15 +6,13 @@ import { test } from '../../playwright/test';
// PR 2 of the QuickJS script-sandbox rollout plan —
// https://gist.github.com/jackkav/3ebf8768bf84be024a3a138874919354
//
// KNOWN BUG (unresolved): insomnia.sendRequest() crashes under the real Electron/Worker/QuickJS
// environment with `Aborted(Assertion failed: list_empty(&rt->gc_obj_list), at:
// .../quickjs.c,2036,JS_FreeRuntime)` on the first real network round trip, even after three
// targeted fixes (see quickjs-script-engine.ts's module doc comment and installSendRequestBridge).
// This never reproduces in vitest (mocked fetch, or a plain Node http server) — only real Electron
// fetch() timing inside the dedicated Worker reproduces it. `test.fail()` marks this as an expected
// failure: CI stays green while the bug is open, and flips red (telling us to remove the
// annotation) the moment someone actually fixes it.
test.describe('QuickJS sendRequest bridge (known bug)', () => {
// End-to-end cover for the `insomnia.sendRequest()` bridge: the real Electron protocol handler, the
// real auth token (forwarded by run-script-quickjs.ts), and real curl. The teardown hazard this used
// to crash on is covered far more cheaply by
// `packages/insomnia/src/scripting/quickjs-script-engine.test.ts`'s "sendRequest bridge teardown"
// block, which reproduces the `gc_obj_list` abort in vitest by settling the mocked fetch on a later
// macrotask.
test.describe('QuickJS sendRequest bridge', () => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
test.beforeEach(async ({ app, page }) => {
@@ -48,8 +46,6 @@ test.describe('QuickJS sendRequest bridge (known bug)', () => {
};
test('runs a real sendRequest() round trip through the QuickJS engine', async ({ page, insomnia }) => {
test.fail(true, 'insomnia.sendRequest() crashes under real Electron/Worker timing — see file header comment');
await enableQuickJsSandbox(page);
// "testQueryParams" has no body and no folder-inherited after-response script — see
@@ -200,12 +200,6 @@ describe('runScriptInQuickJs', () => {
});
});
// KNOWN BUG (unresolved): a real sendRequest() round trip crashes under the real Electron/Worker
// environment (see quickjs-script-engine.ts's module doc comment and
// packages/insomnia-smoke-test/tests/smoke/quickjs-sendrequest-bridge.test.ts, which reproduces it
// end to end). These unit tests mock `fetch` entirely, so they only cover the bridge's own request
// normalization / response reconstruction / error propagation logic — they pass even though the
// real-environment crash is still open, which is exactly the gap this WIP branch exists to flag.
describe('runScriptInQuickJs sendRequest bridge', () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -362,3 +356,130 @@ describe('runScriptInQuickJs sendRequest bridge', () => {
expect((result.environment.data as Record<string, unknown>).callbackError).toBe('DNS resolution failed');
});
});
/**
* `vm.newPromise()` allocates three JSValues — the promise plus its `resolve`/`reject` functions —
* and quickjs-emscripten frees the latter two only from inside `resolve()`/`reject()`. Disposing the
* runtime while a bridge promise is still pending therefore leaks two live function objects and
* trips QuickJS's `assert(list_empty(&rt->gc_obj_list))` in `JS_FreeRuntime`. That is a native
* Emscripten `abort()`, not a normal exception, so it takes down the whole script worker rather than
* surfacing as a script error.
*
* The earlier bridge tests can't catch it because a `mockResolvedValue` fetch settles inside the
* same microtask checkpoint as the call, so the deferred is always settled before teardown. These
* tests deliberately settle the fetch on a *later macrotask* — the only thing real Electron `fetch()`
* timing adds — and each one aborts the module without the teardown fix in `runScriptInQuickJs`.
*/
describe('runScriptInQuickJs sendRequest bridge teardown', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
const stubSlowFetch = (delayMs: number) => {
const fetchMock = vi.fn().mockImplementation(
() =>
new Promise(resolve =>
setTimeout(
() =>
resolve({
ok: true,
status: 200,
text: () =>
Promise.resolve(
JSON.stringify({ code: 200, status: 'OK', headers: [], body: 'late', responseTime: delayMs }),
),
}),
delayMs,
),
),
);
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
};
it('tears down cleanly when the script never awaits its sendRequest', async () => {
stubSlowFetch(120);
const context = baseContext();
// Postman-style fire-and-forget: the script returns while the request is still in flight.
const result = await runScriptInQuickJs({
script: `
insomnia.sendRequest('https://example.com', () => {});
insomnia.environment.set('finished', true);
`,
context,
});
expect((result.environment.data as Record<string, unknown>).finished).toBe(true);
// The response lands after teardown; it must be a silent no-op rather than an abort or an
// unhandled QuickJSUseAfterFree rejection out of the settle callback.
await new Promise(resolve => setTimeout(resolve, 250));
// A later run proving the shared WASM module survived — after an Emscripten abort this throws.
const laterResult = await runScriptInQuickJs({
script: 'insomnia.environment.set("ranCleanly", true);',
context: baseContext(),
});
expect((laterResult.environment.data as Record<string, unknown>).ranCleanly).toBe(true);
});
it('tears down cleanly when the deadline fires while a sendRequest is in flight', async () => {
stubSlowFetch(2000);
const context = baseContext();
context.settings = { timeout: 100 } as any;
await expect(
runScriptInQuickJs({ script: `await insomnia.sendRequest('https://example.com');`, context }),
).rejects.toThrow(/Executing script timeout: 100/);
const laterResult = await runScriptInQuickJs({
script: 'insomnia.environment.set("ranCleanly", true);',
context: baseContext(),
});
expect((laterResult.environment.data as Record<string, unknown>).ranCleanly).toBe(true);
});
it('tears down cleanly when only some of several sendRequests have settled', async () => {
let call = 0;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(
() =>
new Promise(resolve =>
// First call answers immediately, second is still outstanding at teardown.
setTimeout(
() =>
resolve({
ok: true,
status: 200,
text: () =>
Promise.resolve(
JSON.stringify({ code: 200, status: 'OK', headers: [], body: 'x', responseTime: 1 }),
),
}),
call++ === 0 ? 0 : 2000,
),
),
),
);
const context = baseContext();
const result = await runScriptInQuickJs({
script: `
const first = await insomnia.sendRequest('https://example.com/one');
insomnia.sendRequest('https://example.com/two', () => {});
insomnia.environment.set('firstBody', first.body);
`,
context,
});
expect((result.environment.data as Record<string, unknown>).firstBody).toBe('x');
const laterResult = await runScriptInQuickJs({
script: 'insomnia.environment.set("ranCleanly", true);',
context: baseContext(),
});
expect((laterResult.environment.data as Record<string, unknown>).ranCleanly).toBe(true);
});
});
@@ -1,4 +1,4 @@
import type { QuickJSContext, QuickJSHandle } from 'quickjs-emscripten';
import type { QuickJSContext, QuickJSDeferredPromise, QuickJSHandle } from 'quickjs-emscripten';
import { Console } from '../../../insomnia-scripting-environment/src/objects/console';
import type { RequestContext } from '../../../insomnia-scripting-environment/src/objects/interfaces';
@@ -22,13 +22,6 @@ import { getQuickJSModule } from '../templating/sandbox/quickjs-runtime';
* only `code`/`status`/`headers`/`body`/`responseTime`/`json()`/`text()` (no chai assertions, no
* `originalRequest`). Full parity with the hidden-window `Response` class is deferred.
*
* KNOWN BUG (unresolved): a real sendRequest() round trip crashes under the real Electron/Worker
* environment with `Aborted(Assertion failed: list_empty(&rt->gc_obj_list), at:
* .../quickjs.c,2036,JS_FreeRuntime)` — see installSendRequestBridge's comment and
* packages/insomnia-smoke-test/tests/smoke/quickjs-sendrequest-bridge.test.ts, which reproduces it.
* Never reproduces in vitest (mocked fetch, or a real Node http server) — only real Electron fetch()
* timing inside the dedicated Worker.
*
* Not supported (throws inside the script if called): insomnia.test()/pm.test(), collectionVariables,
* vault, cookies, client certificates, and mutating the request.
*/
@@ -53,13 +46,14 @@ export const runScriptInQuickJs = async ({
const environmentData: Record<string, unknown> = { ...context.environment.data };
const variablesData: Record<string, unknown> = { ...context.transientVariables?.data };
// Set right after `vm.dispose()` below. The sendRequest bridge is a real async host round trip
// (a `fetch()` to the Electron main process) whose resolution isn't driven by QuickJS's own job
// queue the way in-VM promises are — checking this flag before ever touching `vm`/a promise
// handle from that callback is what makes a late-arriving response after this function has
// already returned (deadline/interrupt fired, or the caller otherwise moved on) a safe no-op
// instead of a `QuickJSUseAfterFree` (or a native WASM abort disposing an in-use runtime).
const disposedRef = { current: false };
// Every VM promise handed to the script by `installSendRequestBridge` that hasn't settled yet.
// Each one owns three JSValues — the promise plus its `resolve`/`reject` functions — and
// quickjs-emscripten only frees the two functions from inside `resolve()`/`reject()`. A VM
// promise still pending when the runtime is freed therefore leaks two live function objects,
// which trips QuickJS's `assert(list_empty(&rt->gc_obj_list))` in `JS_FreeRuntime`: a native
// Emscripten abort rather than a catchable error, so it takes the script worker down instead of
// surfacing as a script failure. `finally` below settles this set before disposing.
const pendingDeferreds = new Set<QuickJSDeferredPromise>();
try {
// Polled during synchronous execution so a tight sync loop in the script can't bypass the timeout.
@@ -69,7 +63,7 @@ export const runScriptInQuickJs = async ({
installConsole(vm, scriptConsole);
installKeyValueBridge(vm, '__envGet', '__envSet', environmentData);
installKeyValueBridge(vm, '__varGet', '__varSet', variablesData);
installSendRequestBridge(vm, authToken, disposedRef);
installSendRequestBridge(vm, pendingDeferreds, authToken);
setGlobalString(vm, '__requestJSON', JSON.stringify(context.request ?? {}));
evalOrThrow(vm, BOOTSTRAP, '<quickjs-script-bootstrap>');
@@ -77,7 +71,12 @@ export const runScriptInQuickJs = async ({
await driveTaskToCompletion(vm, deadline, timeoutMs);
} finally {
disposedRef.current = true;
// Ordering matters: free the resolve/reject functions of anything still in flight (a script
// that never awaited its sendRequest, or a run that hit the deadline mid-request) *before*
// the runtime, or `JS_FreeRuntime` aborts. `dispose()` is documented as idempotent, so
// already-settled deferreds still in the set are harmless.
pendingDeferreds.forEach(deferred => deferred.dispose());
pendingDeferreds.clear();
vm.dispose();
}
@@ -203,44 +202,37 @@ const sendRequestViaFetch = async (requestBodyJson: string, authToken?: string):
};
/** Registers the async `__sendRequest(bodyJson)` bridge backing `insomnia.sendRequest()` in BOOTSTRAP. */
const installSendRequestBridge = (vm: QuickJSContext, authToken?: string, disposedRef?: { current: boolean }): void => {
const installSendRequestBridge = (
vm: QuickJSContext,
pendingDeferreds: Set<QuickJSDeferredPromise>,
authToken?: string,
): void => {
const fn = vm.newFunction('__sendRequest', bodyHandle => {
const bodyJson = vm.getString(bodyHandle);
const deferred = vm.newPromise();
// Registered so the run's `finally` can free this deferred's resolve/reject functions if the
// fetch is still outstanding when the VM is torn down — see `pendingDeferreds`' comment.
pendingDeferreds.add(deferred);
// `Promise.resolve().then(...)` defers the real fetch() call by one microtask tick so it never
// runs while still nested inside this C-to-JS callback frame — matching host-bridge.ts's
// installHostBridge (the proven-working equivalent for the template-tag sandbox). Calling the
// async bridge function directly here instead (no extra tick) let its `await fetch(...)`
// resolve while still on that native callback's call stack, which corrupted the WASM heap under
// real Electron fetch() timing (reproduced as a `QuickJSUseAfterFree` / `gc_obj_list` abort on
// `vm.newString` inside the resolution handler) — a reentrancy window neither a mocked fetch in
// unit tests nor a plain Node http client ever hit, since both settle within the same tick.
// NOTE: this deferral alone did NOT resolve the crash (see the module doc comment above) — kept
// because it's still a real fix for the reentrancy hazard it targets, just not a sufficient one.
// runs while still nested inside this C-to-JS callback frame — matching plugin-tag-sandbox.ts's
// installHostBridge, the proven-working equivalent for the template-tag sandbox.
Promise.resolve()
.then(() => sendRequestViaFetch(bodyJson, authToken))
.then(
value => {
if (!disposedRef?.current) {
resolveDeferredWithString(vm, deferred, JSON.stringify({ ok: true, value }));
}
},
err => {
if (!disposedRef?.current) {
resolveDeferredWithString(
vm,
deferred,
JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }),
);
}
},
value => settle(vm, deferred, pendingDeferreds, JSON.stringify({ ok: true, value })),
err =>
settle(
vm,
deferred,
pendingDeferreds,
JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }),
),
);
// The settled VM promise schedules a job; pump it so the awaiting script resumes.
deferred.settled.then(() => {
if (!disposedRef?.current) {
if (vm.alive) {
// executePendingJobs() returns a DisposableResult — on failure its `.error` is a live
// QuickJSHandle. Discarding the result outright (as this used to) leaks that handle,
// which is exactly what trips "gc_obj_list not empty" on a later vm.dispose().
// QuickJSHandle that leaks if the result is discarded rather than disposed.
vm.runtime.executePendingJobs().dispose();
}
});
@@ -249,14 +241,28 @@ const installSendRequestBridge = (vm: QuickJSContext, authToken?: string, dispos
setGlobal(vm, '__sendRequest', fn);
};
const resolveDeferredWithString = (
/**
* Resolve a bridge deferred with a JSON payload, if the VM is still around to receive it.
*
* A `fetch()` can land after the run has finished (the script never awaited it, or the deadline
* fired first), by which point the VM is gone: `vm.newString` on a disposed context throws
* `QuickJSUseAfterFree` synchronously inside this `.then`, i.e. an unhandled rejection in the
* worker. `deferred.alive` covers the narrower case of a deferred already force-disposed by the
* run's `finally` while the context itself is somehow still up.
*/
const settle = (
vm: QuickJSContext,
deferred: ReturnType<QuickJSContext['newPromise']>,
deferred: QuickJSDeferredPromise,
pendingDeferreds: Set<QuickJSDeferredPromise>,
value: string,
): void => {
if (!vm.alive || !deferred.alive) {
return;
}
const handle = vm.newString(value);
deferred.resolve(handle);
handle.dispose();
pendingDeferreds.delete(deferred);
};
/** Registers `<getName>(key)`/`<setName>(key, jsonValue)` host functions backed by a plain object. */
@@ -12,6 +12,8 @@ interface WorkerRequest {
id: string;
script: string;
context: RequestContext;
/** Forwarded by `run-script-quickjs.ts`; a Worker can't reach `window.main` to fetch it itself. */
authToken?: string;
}
interface WorkerErrorResponse {
@@ -27,9 +29,9 @@ interface WorkerSuccessResponse {
export type WorkerResponse = WorkerSuccessResponse | WorkerErrorResponse;
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const { id, script, context } = event.data;
const { id, script, context, authToken } = event.data;
try {
const result = await runScriptInQuickJs({ script, context });
const result = await runScriptInQuickJs({ script, context, authToken });
self.postMessage({ id, result } satisfies WorkerSuccessResponse);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
@@ -56,12 +56,23 @@ class FakeWorker {
describe('runScriptInQuickJs (worker client)', () => {
let fakeWorker: FakeWorker;
/**
* The client fetches the templating-db auth token over IPC before posting, so the postMessage
* lands a microtask later than the call. Awaiting this lets a test assert on `postedMessages`.
*/
const flushTokenFetch = () => new Promise(resolve => setTimeout(resolve, 0));
beforeEach(async () => {
fakeWorker = new FakeWorker();
vi.stubGlobal(
'Worker',
vi.fn(() => fakeWorker),
);
// A Worker can't reach `window.main`, so the client fetches the bridge auth token here and
// forwards it in every message — see `getTemplatingDbAuthToken`.
vi.stubGlobal('window', {
main: { templatingDb: { getAuthToken: vi.fn().mockResolvedValue('test-auth-token') } },
});
// The module keeps a lazily-created singleton worker at module scope — reset it between tests
// by re-importing fresh so each test gets its own FakeWorker instance.
vi.resetModules();
@@ -76,9 +87,13 @@ describe('runScriptInQuickJs (worker client)', () => {
const context = baseContext();
const promise = runScriptInQuickJs({ script: 'console.log(1)', context });
await flushTokenFetch();
expect(fakeWorker.postedMessages).toHaveLength(1);
const { id } = fakeWorker.postedMessages[0];
const { id, authToken } = fakeWorker.postedMessages[0];
expect(typeof id).toBe('string');
// Forwarded so the engine's sendRequest bridge can authenticate against the templating-db
// protocol; without it every bridge fetch comes back 401.
expect(authToken).toBe('test-auth-token');
const result = { ...context, logs: ['log: 1\n'] };
fakeWorker.emitMessage({ id, result });
@@ -91,6 +106,7 @@ describe('runScriptInQuickJs (worker client)', () => {
const context = baseContext();
const promise = runScriptInQuickJs({ script: 'throw new Error("boom")', context });
await flushTokenFetch();
const { id } = fakeWorker.postedMessages[0];
fakeWorker.emitMessage({ id, error: { message: 'boom', name: 'Error' } });
@@ -102,6 +118,7 @@ describe('runScriptInQuickJs (worker client)', () => {
const context = baseContext();
const promise = runScriptInQuickJs({ script: 'console.log(1)', context });
await flushTokenFetch();
fakeWorker.emitMessage({ id: 'not-a-real-id', result: {} });
const { id } = fakeWorker.postedMessages[0];
@@ -136,6 +153,7 @@ describe('runScriptInQuickJs (worker client)', () => {
);
const secondCall = runScriptInQuickJs({ script: 'console.log(1)', context });
await flushTokenFetch();
expect(secondWorker.postedMessages).toHaveLength(1);
const { id } = secondWorker.postedMessages[0];
const result = { ...context, logs: [] };
@@ -57,16 +57,35 @@ const getWorker = (): Worker => {
return instance;
};
export const runScriptInQuickJs = ({
// A Worker has no `window.main`, so — exactly as `ui/worker/templating-handler.ts` does for the
// templating worker — fetch the `insomnia-templating-worker-database://` auth token here once and
// forward it on every postMessage. Without it the engine's `insomnia.sendRequest()` bridge fetch is
// rejected by the protocol's auth gate with a 401.
let authTokenPromise: Promise<string> | null = null;
const getTemplatingDbAuthToken = (): Promise<string> => {
if (!authTokenPromise) {
authTokenPromise = window.main.templatingDb.getAuthToken();
}
return authTokenPromise;
};
export const runScriptInQuickJs = async ({
script,
context,
}: {
script: string;
context: RequestContext;
}): Promise<RequestContext> => {
// The worker and the pending entry are set up before awaiting the token, so a crash during the
// token round trip still rejects this call through the `error` listener rather than hanging.
const instance = getWorker();
const id = `quickjs-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return new Promise<RequestContext>((resolve, reject) => {
const result = new Promise<RequestContext>((resolve, reject) => {
pending.set(id, { resolve, reject });
getWorker().postMessage({ id, script, context });
});
const authToken = await getTemplatingDbAuthToken();
instance.postMessage({ id, script, context, authToken });
return result;
};