From db8375e6cd4150afbdeeb2ca94d3b7ee330b0d85 Mon Sep 17 00:00:00 2001 From: Jay Wu Date: Fri, 23 Oct 2026 20:50:22 +0800 Subject: [PATCH] fix(sandbox): release in-flight bridge calls before disposing the QuickJS context When a bridge call never settled and the sandbox timeout fired, disposing the context hit QuickJS's list_empty(gc_obj_list) assertion, so the caller saw "Aborted(Assertion failed...)" instead of "Template tag sandbox timed out". Track pending bridge promises and dispose them first, and ignore host calls that settle after the context is gone. Adds a test with a bridge call that never settles. Co-Authored-By: Claude Sonnet 5.5 --- .../sandbox/plugin-tag-sandbox.test.ts | 13 +++++++++ .../templating/sandbox/plugin-tag-sandbox.ts | 29 +++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) 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 846ec713eb..0e66bc16ac 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,19 @@ describe('runTagInSandbox — PoC milestone 1', () => { expect(Date.now() - start).toBeLessThan(5000); }); + it('times out cleanly when a bridge call never settles', async () => { + const source = + 'module.exports.templateTags = [{ name: "hang", run: async function (ctx) { return await ctx.app.getPath("home"); } }];'; + let settleLate: () => void = () => {}; + const hanging: HostBridge = () => new Promise(resolve => (settleLate = () => resolve('late'))); + await expect( + runTagInSandbox({ pluginSource: source, tagName: 'hang', envelope: envelope([]), bridge: hanging, timeoutMs: 200 }), + ).rejects.toThrow('Template tag sandbox timed out'); + // The host call finishing after the context is gone must not throw or touch the disposed VM. + settleLate(); + 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 faf476aa13..64d39dae51 100644 --- a/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts +++ b/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts @@ -56,6 +56,7 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise {}; const deadline = Date.now() + timeoutMs; // Single-file convenience: synthesize a one-entry module map from pluginSource when the caller @@ -78,7 +79,7 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise { }; /** Register `__hostBridge(path, bodyJson)` returning a VM promise resolved from async host work. */ -const installHostBridge = (ctx: QuickJSContext, bridge: HostBridge): void => { +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 +133,31 @@ const installHostBridge = (ctx: QuickJSContext, bridge: HostBridge): void => { body = {}; } const deferred = ctx.newPromise(); + pending.add(deferred); + // A call that settles after the context was disposed (timeout) has nothing left to resolve. + const settle = (json: string) => { + if (pending.delete(deferred) && ctx.alive) { + 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()); + deferred.settled.then(() => ctx.alive && ctx.runtime.executePendingJobs()); return deferred.handle; }); setGlobal(ctx, '__hostBridge', fn); + + return () => { + for (const deferred of pending) { + deferred.dispose(); + } + pending.clear(); + }; }; /**