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(); + }; }; /**