mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-05 20:43:52 -05:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
eecc5b004e
commit
db8375e6cd
2 files changed
+37
-5
No files matched your search
@@ -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 = [];
|
||||
|
||||
@@ -56,6 +56,7 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise<str
|
||||
const { getQuickJSModule } = await import('./quickjs-runtime');
|
||||
const QuickJS = await getQuickJSModule();
|
||||
const ctx = QuickJS.newContext();
|
||||
let disposeBridge = () => {};
|
||||
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<str
|
||||
// sandbox global, see SANDBOX_INTERNAL_GLOBALS) and wire up a context of its own. Swapping in a
|
||||
// rejecting bridge here means any such attempt fails inside the sandbox instead of reaching the
|
||||
// real host, no matter how it's invoked.
|
||||
installHostBridge(ctx, discover ? rejectingBridge : bridge);
|
||||
disposeBridge = installHostBridge(ctx, discover ? rejectingBridge : bridge);
|
||||
installHostConsole(ctx, onConsole);
|
||||
installHostCrypto(ctx, opts.hostCrypto);
|
||||
|
||||
@@ -108,6 +109,9 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise<str
|
||||
|
||||
return await drivePromiseToString(ctx, timeoutMs);
|
||||
} finally {
|
||||
// Bridge calls still in flight (e.g. one that never settles before the timeout) hold VM handles
|
||||
// that must be released first, or disposing the context trips QuickJS's gc_obj_list assertion.
|
||||
disposeBridge();
|
||||
ctx.dispose();
|
||||
}
|
||||
};
|
||||
@@ -118,7 +122,8 @@ const rejectingBridge: HostBridge = async path => {
|
||||
};
|
||||
|
||||
/** 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<ReturnType<QuickJSContext['newPromise']>>();
|
||||
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();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user