Skip to content

Don't cite a released import id after the reference has settled - #267

Open
Kanishkrawatt wants to merge 1 commit into
cloudflare:mainfrom
Kanishkrawatt:fix/settled-import-id
Open

Kanishkrawatt wants to merge 1 commit into
cloudflare:mainfrom
Kanishkrawatt:fix/settled-import-id

Conversation

@Kanishkrawatt

Copy link
Copy Markdown

Fixes #265.

The problem

ImportTableEntry.resolve() stores the resolution and immediately calls sendRelease(), so the import is dead on both sides. The entry keeps importId — it has to, because the release accounting names the id through it — but getImport() still returned that id unconditionally.

So passing a settled RpcPromise back as an argument re-serialized a released id. The peer cannot find it and throws inside readLoop. Since readLoop is wrapped in a single session-wide .catch(err => this.abort(err)), a call-level fault tears down the whole session, not just the offending call.

We hit this in production on a connection that multiplexes every service in an Electron desktop app: one stale reference took the entire connection down and the user got a "connection lost" screen.

The fix

getImport() declines a settled entry, so the caller exports a fresh stub instead. This is the same test dispose(), abort() and onBroken() already apply — they all branch on resolution; getImport() was the one that did not.

The guard gates the use of importId rather than clearing it, so release accounting is unchanged. A test covers that specifically, because a guard that cleared the id would still emit a release frame — just one naming null — and silently leak the peer's exports.

awaitResolution() gets the same guard. It is not reachable with a settled entry today: RpcImportHook.pull() returns on entry.resolution one line earlier, and sendStream seeds activePull via pulling = true, so the sendPull branch never runs. It is defensive only — included so a future caller cannot regress into a pull naming a released id. Happy to drop it if you would rather not carry an unreachable branch.

Tests

Two tests in __tests__/index.test.ts. The first is load-bearing — verified to fail on unpatched main with no such entry on exports table, and to pass with the fix. The second guards against the clear-the-id mis-fix described above.

Full suite on this branch:

suite result
vitest run --project=node 407 / 407 pass
vitest run --project=workerd 210 / 210 pass
npm run test:types pass

I did not run the browser projects or test:bun locally (Playwright download blocked behind a corporate proxy); neither covers this path, but CI will.

Scope note

There is a second, separate defect where the settled resolution is a non-promise stub — the library then cannot pull it and the call fails with Tried to resolve a non-promise stub. That is unchanged by this PR and is out of scope here; this change only stops that case from being session-fatal rather than call-level. Reported separately in #266, which is about readLoop's missing per-frame error boundary.

Present in 0.10.0 and still in 0.12.0, so upgrading is not a workaround — we are currently carrying this as a local patch against the published bundle, which is what prompted the report.

`ImportTableEntry.resolve()` stores the resolution and immediately calls
`sendRelease()`, so the import is dead on both sides -- but the entry keeps
`importId`, because the release accounting names the id through it.
`getImport()` nevertheless still returned that id, so passing a settled
`RpcPromise` back into a later message re-serialized a released id. The peer
cannot find it and throws inside `readLoop`, and since `readLoop` is wrapped in
a single session-wide `.catch(err => this.abort(err))`, a call-level fault tore
down the entire session.

`getImport()` now declines a settled entry, so the caller exports a fresh stub
instead. This matches `dispose()`, `abort()` and `onBroken()`, which already
branch on `resolution`. The guard gates the *use* of `importId` rather than
clearing it, so release accounting is untouched.

`awaitResolution()` gets the same guard. It is not reachable with a settled
entry today -- `RpcImportHook.pull()` returns on `entry.resolution` one line
earlier, and `sendStream` seeds `activePull` via `pulling = true` -- so it is
defensive only, and prevents a future caller from regressing into a `pull` that
names a released id.

Fixes cloudflare#265
@changeset-bot

changeset-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aa6182b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
capnweb Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@Kanishkrawatt

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@pkg-pr-new

pkg-pr-new Bot commented Sep 24, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@267
npm i https://pkg.pr.new/cloudflare/capnweb/capnweb-validate@267

commit: aa6182b

@ndisidore

Copy link
Copy Markdown
Member

@ask-bonk review this

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The getImport() fix looks right to me. Once resolve() has run sendRelease(), the id is gone from both tables (sendRelease also runs delete this.imports[id]). Checking resolution in getImport() matches what call/get/map/stream/dispose already do in RpcImportHook/ImportTableEntry. I also checked the other callers of getImport():

  • Serializer stub/rpc-promise case: falls back to hook.get(path) / hook.dup() and then exportPromise/exportStub. RpcImportHook.get() already routes to entry.resolution, so this works.
  • sendMap captures: now exports the capture instead of citing a dead id. Correct.
  • ensureResolvingExport: the chained-promise shortcut now applies to settled import-backed promises too. That's fine, and a bit better, since there's no live peer-side promise left to point at.

The rest of my comments are about the tests. They're the main thing I'd change before merging.

One note on the scope: when the settled resolution is itself a stub pointing back at the peer, we now proxy the peer's own object back to it through a local export instead of short-circuiting. That's the #266 behavior, and the PR already says so. I'm mentioning it so maintainers know it's a deliberate tradeoff: correct but not optimal.

Comment thread __tests__/index.test.ts
//
// dispose(), abort() and onBroken() all already branch on `resolution`; getImport() did not.
it("does not cite a released import id after the reference has settled", async () => {
let harness = new TestHarness(new TestTarget());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nearly every other test in this file uses await using harness = .... On async dispose, TestHarness pumps microtasks and runs checkAllDisposed(), which asserts that both sides are back to {imports: 1, exports: 1}. Creating the harness with plain let and calling harness.stub.dispose() by hand skips that check. That check is exactly the one that would catch a leak from the new export path, where the settled promise is re-exported as a fresh promise and the peer has to release it after resolve.

Suggested change
let harness = new TestHarness(new TestTarget());
await using harness = new TestHarness(new TestTarget());

(and drop the trailing harness.stub.dispose();)

Comment thread __tests__/index.test.ts

// A guard that cleared `importId` instead of gating its use would stop the release
// accounting naming the id, leaking the peer's exports.
it("still releases settled imports by id", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

As written, this test only checks that the session is still alive after two settled calls. It never checks that the releases actually happened, so it doesn't really guard against the leak it describes. It passes on main too. Switching to await using would make the harness verify import/export counts on dispose. Or you could assert the counts directly:

Suggested change
it("still releases settled imports by id", async () => {
it("still releases settled imports by id", async () => {
await using harness = new TestHarness(new TestTarget());
await harness.stub.returnNumber(1);
await harness.stub.returnNumber(2);
await pumpMicrotasks();
// Both settled imports must have been released on the wire, not just dropped locally.
expect(harness.client.getStats()).toStrictEqual({imports: 1, exports: 1});
expect(harness.server.getStats()).toStrictEqual({imports: 1, exports: 1});

(then drop the returnNumber(3) / dispose() lines below)

Comment thread __tests__/index.test.ts
await promise;

// Passing the same *promise object* (not its awaited value) as an argument is what
// re-serialized the now-dead id.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: I'd also cover the non-empty pathIfPromise case, e.g. passing settledPromise.someProp as an argument. That goes through hook.get(pathIfPromise) on the settled entry, which is the other branch the serializer takes now that getImport() returns undefined.

Comment thread src/rpc.ts
// If the entry has already settled, the import has been released (resolve() calls
// sendRelease()), so there is nothing left on the wire to pull. Read the stored
// resolution instead of sending a "pull" naming a released id.
if (this.resolution) return this.resolution.pull();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I agree with your analysis that this can't be reached today: RpcImportHook.pull() returns early on entry.resolution. I'm fine keeping it as a defensive check. If you keep it, it could be a one-line early return with no extra explanation, since the comment in getImport() already explains why. Your call, or the maintainers'.

@ask-bonk

ask-bonk Bot commented Sep 24, 2026

Copy link
Copy Markdown

I posted a review on PR #267 with comments only (no approve or reject): #267 (review)

The fix itself looks right. When a promise settles, resolve() calls sendRelease(), which also removes the id from the local import table. So once resolution is set, the id no longer exists on either side. Checking resolution inside getImport() matches how the other methods on the entry already behave.

I checked every place that calls getImport():

  • Passing a settled promise as an argument: the code falls back to exporting a fresh stub or promise, and that correctly uses the stored resolution.
  • Captures in sendMap: these now get exported instead of pointing at the released id.
  • ensureResolvingExport: its shortcut for chained promises now also applies to settled promises. That's harmless.

Main problem is the tests:

  • Both new tests create the harness with plain let and call harness.stub.dispose() by hand. Nearly every other test in the file uses await using harness, which checks on dispose that nothing leaked. By skipping it, the tests miss exactly the leak the new export path could cause.
  • The second test (meant to catch a fix that clears the id) only checks that the session is still alive, never that the releases were sent. It would pass on main too, so it doesn't guard against the leak it describes. I suggested either using await using or asserting getStats() directly.
  • Optional: add a test that passes a property of a settled promise, which is the other route the code now takes.

Minor: I said I'm fine keeping the extra check in awaitResolution(), which can't currently be reached, as a defensive guard.

Other notes:

github run

Comment thread src/rpc.ts
}

async awaitResolution(): Promise<RpcPayload> {
// If the entry has already settled, the import has been released (resolve() calls

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This hunk can be safely removed as I have confirmed there are no code paths able to actually hit this

This branch has not been deployed

No deployments
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.

getImport() returns the id of an already-settled import, sending a released id to the peer

2 participants