Skip to content

fix: dispose unsettled bridge deferreds before tearing down the tag sandbox - #10403

Open
jackkav wants to merge 1 commit into
developfrom
claude/competent-williams-387359
Open

fix: dispose unsettled bridge deferreds before tearing down the tag sandbox#10403
jackkav wants to merge 1 commit into
developfrom
claude/competent-williams-387359

Conversation

@jackkav

@jackkav jackkav commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Problem

installHostBridge in plugin-tag-sandbox.ts creates a ctx.newPromise() per __hostBridge call and returns deferred.handle, but never disposes the deferred.

A QuickJSDeferredPromise owns three JSValues — the promise plus its resolve/reject function handles — and only resolve()/reject() frees the two resolvers (via the private disposeResolvers()). The library's own docs are explicit that returning handle from a VmFunctionImplementation is safe only if you "ensure that either resolve or reject will be called", and that you must call dispose() otherwise.

We didn't. So when a bridge call hadn't settled by the time the run ended, the resolvers were still live and ctx.dispose() aborted the WASM module:

Aborted(Assertion failed: list_empty(&rt->gc_obj_list), at: ../../vendor/quickjs/quickjs.c,2036,JS_FreeRuntime)

This is an abort, not an exception — it kills the runtime instead of surfacing a catchable error, so the caller never sees the timeout it was supposed to get.

Two reachable paths, both with real plugins:

  1. The 10s deadline fires while a slow network.sendRequest or nested util.render is outstanding.
  2. A tag unwinds via __task rejection while a sibling await is still in flight — e.g. Promise.all([slowBridgeCall(), somethingThatThrows()]).

Present on develop in shipped code; not introduced by any open PR.

Fix

installHostBridge now tracks its deferreds and returns a teardown function that runTagInSandbox's finally runs before ctx.dispose().

Teardown frees the resolvers silently rather than settling them, and deliberately does not pump executePendingJobs(). Settling during teardown resumes the sandbox mid-unwind and settles __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() is idempotent and guards each handle, so the abandoned VM promise just stays pending for the microsecond before the runtime goes away.

Two liveness guards cover the late-settle race, so a bridge answering after teardown is a no-op instead of a QuickJSUseAfterFree unhandled rejection:

  • resolveWithString bails on !ctx.alive || !deferred.alive before allocating anything.
  • The deferred.settled job pump is gated on ctx.alive.

Verification

The regression test reproduces the abort against the real runTagInSandbox — it fails with the list_empty(&rt->gc_obj_list) abort before the fix and passes after, with the late bridge response arriving post-teardown to exercise the guards.

  • plugin-tag-sandbox.test.ts: 86/86 pass
  • src/templating + src/scripting: 490/490 pass
  • type-check and ESLint clean

Mechanism was confirmed against the shipped quickjs-emscripten-core source, not just the docs: resolve/reject are the only callers of disposeResolvers(), settled resolves only from onSettled() inside those two, and resolvePromise manages its callback handles in a Scope (so it leaks nothing on the timeout path as long as __task stays unsettled during unwind).

Note

The same bug exists in packages/insomnia/src/scripting/quickjs-script-engine.ts and is what #10392 is stuck on. Not touched here — this PR is scoped to the templating sandbox.

…andbox

`installHostBridge` created a `ctx.newPromise()` per `__hostBridge` call and
returned `deferred.handle` without ever disposing the deferred. Each
`QuickJSDeferredPromise` owns three JSValues — the promise plus its
`resolve`/`reject` function handles — and only `resolve()`/`reject()` frees the
two resolvers.

So when a bridge call had not settled by the time the run ended, those resolvers
were still live and `ctx.dispose()` aborted the whole WASM module:

    Aborted(Assertion failed: list_empty(&rt->gc_obj_list), at: quickjs.c,2036,JS_FreeRuntime)

An abort is unrecoverable — it takes down the runtime rather than surfacing a
catchable error — and it was reachable two ways: the 10s deadline firing while a
slow `network.sendRequest`/nested `util.render` was outstanding, and a tag
unwinding via `__task` rejection while a sibling await was still in flight.

`installHostBridge` now tracks its deferreds and returns a teardown function
that `runTagInSandbox`'s `finally` runs before `ctx.dispose()`.

Teardown frees the resolvers silently instead of settling them. Settling would
resume the sandbox mid-teardown and settle `__task`, at which point
`ctx.resolvePromise`'s reject callback dup()s the error into a host promise
nobody awaits — leaking that handle and aborting the same way, one step removed.
For the same reason teardown must not pump `executePendingJobs()`.

Two liveness guards cover the late-settle race: `resolveWithString` no-ops on a
dead context or deferred, and the `deferred.settled` job pump is gated on
`ctx.alive`, so a bridge answering after teardown does not raise
`QuickJSUseAfterFree` as an unhandled rejection.
@jackkav
jackkav requested a review from kwburns-kong August 14, 2026 15:06
@github-actions

Copy link
Copy Markdown

✅ Circular References Report

Generated at: 2026-08-14T15:12:50.835Z
Status: ✅ NO CHANGE

Summary

Metric Base (develop) PR Change
Total Circular References 10 10 0 (0.00%)
Click to view all circular references in PR (10)
insomnia-inso/src/db/models/types.ts -> insomnia-inso/src/db/types.ts
insomnia/src/main/prompt-bridge.ts -> insomnia/src/main/window-utils.ts -> insomnia/src/main/plugin-window.ts
insomnia/src/main/window-utils.ts -> insomnia/src/main/plugin-window.ts
insomnia/src/network/network.ts -> insomnia-scripting-environment/src/objects/index.ts -> insomnia-scripting-environment/src/objects/collection.ts -> insomnia-scripting-environment/src/objects/response.ts
insomnia/src/network/network.ts -> insomnia/src/common/render.ts
insomnia/src/ui/components/settings/import-export.tsx -> insomnia/src/ui/components/modals/export-requests-modal.tsx
insomnia/src/ui/components/tabs/request-delete-fallback.ts -> insomnia/src/ui/components/tabs/tab.tsx -> insomnia/src/ui/components/tabs/tab-list.tsx
insomnia/src/ui/components/tabs/tab.tsx -> insomnia/src/ui/components/tabs/tab-list.tsx
insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx
insomnia/src/ui/components/viewers/response-viewer.tsx -> insomnia/src/ui/components/viewers/response-multipart-viewer.tsx
Click to view all circular references in base branch (10)
insomnia-inso/src/db/models/types.ts -> insomnia-inso/src/db/types.ts
insomnia/src/main/prompt-bridge.ts -> insomnia/src/main/window-utils.ts -> insomnia/src/main/plugin-window.ts
insomnia/src/main/window-utils.ts -> insomnia/src/main/plugin-window.ts
insomnia/src/network/network.ts -> insomnia-scripting-environment/src/objects/index.ts -> insomnia-scripting-environment/src/objects/collection.ts -> insomnia-scripting-environment/src/objects/response.ts
insomnia/src/network/network.ts -> insomnia/src/common/render.ts
insomnia/src/ui/components/settings/import-export.tsx -> insomnia/src/ui/components/modals/export-requests-modal.tsx
insomnia/src/ui/components/tabs/request-delete-fallback.ts -> insomnia/src/ui/components/tabs/tab.tsx -> insomnia/src/ui/components/tabs/tab-list.tsx
insomnia/src/ui/components/tabs/tab.tsx -> insomnia/src/ui/components/tabs/tab-list.tsx
insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx
insomnia/src/ui/components/viewers/response-viewer.tsx -> insomnia/src/ui/components/viewers/response-multipart-viewer.tsx

Analysis

No Change: This PR does not introduce or remove any circular references.


This report was generated automatically by comparing against the develop branch.

@jackkav
jackkav marked this pull request as ready for review August 14, 2026 15:50
Copilot AI lite review requested due to automatic review settings August 14, 2026 15:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a QuickJS runtime abort in the template-tag sandbox caused by unsettled ctx.newPromise() deferreds created by __hostBridge calls that never resolve/reject before teardown. The change ensures pending bridge deferreds are disposed before ctx.dispose() so QuickJS doesn’t abort on live GC objects at runtime free.

Changes:

  • Track all outstanding __hostBridge deferreds and return a teardown function that disposes any still-pending deferreds prior to context disposal.
  • Add liveness guards so late-arriving bridge resolutions don’t attempt to allocate/resolve into a torn-down QuickJS context.
  • Add a regression test that reproduces the timeout-with-in-flight-bridge scenario and verifies it times out cleanly without aborting.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts Track and dispose pending host-bridge deferred promises before ctx.dispose(), and guard late-settle/job-pumping on context/deferred liveness.
packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.test.ts Adds a regression test ensuring timeouts with an in-flight host bridge call do not abort the QuickJS runtime and late settle is a no-op.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants