diff --git a/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.test.ts b/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.test.ts index 846ec713ebf..13d6f5a037e 100644 --- a/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.test.ts +++ b/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.test.ts @@ -276,6 +276,36 @@ describe('runTagInSandbox — PoC milestone 1', () => { expect(Date.now() - start).toBeLessThan(5000); }); + // Each `__hostBridge` call allocates a VM deferred that owns three JSValues — the promise plus its + // resolve/reject function handles — and only settling frees the two resolvers. When the deadline + // fires with a bridge call still outstanding (a slow endpoint, a nested render), those resolvers + // are still live, and freeing the runtime under them aborted the whole WASM module + // (`Assertion failed: list_empty(&rt->gc_obj_list)`) instead of surfacing the timeout. + it('times out cleanly while a host bridge call is still in flight', async () => { + const source = + 'module.exports.templateTags = [{ name: "slow", run: async function (ctx) { return await ctx.util.render("x"); } }];'; + let settleBridge: (value: string) => void = () => {}; + const stalling: HostBridge = () => + new Promise(resolve => { + settleBridge = resolve; + }); + + await expect( + runTagInSandbox({ + pluginSource: source, + tagName: 'slow', + envelope: envelope([]), + bridge: stalling, + timeoutMs: 150, + }), + ).rejects.toThrow(/timed out/); + + // The bridge answering after teardown must be a no-op: the context it would resolve into is gone, + // so an unguarded late settle is a QuickJSUseAfterFree surfacing as an unhandled rejection. + settleBridge('done'); + await new Promise(resolve => setTimeout(resolve, 10)); + }); + it('rejects unbounded allocation instead of exhausting host memory', async () => { const source = `module.exports.templateTags = [{ name: "hog", run: function () { var chunks = []; diff --git a/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts b/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts index faf476aa138..3746d743cd4 100644 --- a/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts +++ b/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts @@ -6,6 +6,9 @@ import { type ContextEnvelope, encodeBridgeFailure, encodeBridgeSuccess } from ' import { buildModuleRegistrySource } from './module-registry'; import { SANDBOX_GLOBALS_SOURCE } from './sandbox-globals'; +/** A VM promise the host controls, as handed back by `ctx.newPromise()`. */ +type VmDeferredPromise = ReturnType; + /** * Synchronous crypto operations backed by the host's real crypto (node:crypto in main). Exposed as * sync QuickJS host functions so the in-sandbox `require('crypto')` shim works without the @@ -68,6 +71,8 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise void = () => {}; try { // Polled during synchronous execution so a tight sync loop in plugin code can't bypass the timeout. ctx.runtime.setInterruptHandler(() => Date.now() > deadline); @@ -78,7 +83,7 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise { throw new Error(`Host bridge is unavailable during discovery (attempted call to "${path}")`); }; -/** Register `__hostBridge(path, bodyJson)` returning a VM promise resolved from async host work. */ -const installHostBridge = (ctx: QuickJSContext, bridge: HostBridge): void => { +/** + * Register `__hostBridge(path, bodyJson)` returning a VM promise resolved from async host work. + * + * Returns a teardown function that must run before the context is disposed. Every `newPromise()` + * owns three JSValues — the promise plus its `resolve`/`reject` function handles — and only + * `resolve()`/`reject()` frees the two resolvers. A bridge call that hasn't settled when the run ends + * (the deadline fired mid-`network.sendRequest`, or `__task` rejected while a sibling await was still + * outstanding) therefore leaves live handles, and `JS_FreeRuntime` aborts the WASM module on + * `Assertion failed: list_empty(&rt->gc_obj_list)`. + */ +const installHostBridge = (ctx: QuickJSContext, bridge: HostBridge): (() => void) => { + const pending = new Set(); const fn = ctx.newFunction('__hostBridge', (pathHandle, bodyHandle) => { const path = ctx.getString(pathHandle); let body: unknown; @@ -128,17 +147,43 @@ const installHostBridge = (ctx: QuickJSContext, bridge: HostBridge): void => { body = {}; } const deferred = ctx.newPromise(); + pending.add(deferred); + const settle = (json: string) => { + // Dropped from `pending` either way: once settled the resolvers are freed, and if the context is + // already gone there is nothing left to free. + pending.delete(deferred); + resolveWithString(ctx, deferred, json); + }; Promise.resolve() .then(() => bridge(path, body)) .then( - value => resolveWithString(ctx, deferred, encodeBridgeSuccess(value)), - err => resolveWithString(ctx, deferred, encodeBridgeFailure(err)), + value => settle(encodeBridgeSuccess(value)), + err => settle(encodeBridgeFailure(err)), ); - // The settled VM promise schedules a job; pump it so the awaiting sandbox code resumes. - deferred.settled.then(() => ctx.runtime.executePendingJobs()); + // The settled VM promise schedules a job; pump it so the awaiting sandbox code resumes. Guarded + // because this microtask can land after teardown, when there is no runtime left to pump. + deferred.settled.then(() => { + if (ctx.alive) { + ctx.runtime.executePendingJobs(); + } + }); return deferred.handle; }); setGlobal(ctx, '__hostBridge', fn); + return () => { + // Freed silently rather than settled. Settling here would resume the sandbox mid-teardown, and the + // resulting rejection would settle `__task` — at which point `ctx.resolvePromise`'s reject callback + // dup()s the error into a host promise nobody is listening to, leaking that handle and aborting in + // exactly the same way, one step removed. `dispose()` frees the promise and both resolvers, and is + // idempotent, so the abandoned VM promise simply stays pending for the microsecond before the + // runtime goes away. + for (const deferred of pending) { + if (deferred.alive) { + deferred.dispose(); + } + } + pending.clear(); + }; }; /** @@ -204,6 +249,8 @@ const drivePromiseToString = async (ctx: QuickJSContext, timeoutMs: number): Pro break; } if (Date.now() > deadline) { + // Bridge calls still in flight are freed by the teardown in runTagInSandbox's finally, not here: + // settling them would resume the sandbox while we're unwinding. throw new Error('Template tag sandbox timed out'); } // Yield to the host loop so bridge promises settle and microtasks (incl. resultPromise) drain. @@ -220,11 +267,13 @@ const drivePromiseToString = async (ctx: QuickJSContext, timeoutMs: number): Pro return result; }; -const resolveWithString = ( - ctx: QuickJSContext, - deferred: ReturnType, - json: string, -): void => { +const resolveWithString = (ctx: QuickJSContext, deferred: VmDeferredPromise, json: string): void => { + // A bridge call can answer after the run already ended (timed out, or unwound on an error). By then + // the context and the deferred's resolvers are freed, so the late arrival is a no-op instead of a + // QuickJSUseAfterFree surfacing as an unhandled rejection. + if (!ctx.alive || !deferred.alive) { + return; + } const handle = ctx.newString(json); deferred.resolve(handle); handle.dispose();