From aa6182bac047d4fe8109273f794e7967842b1d9d Mon Sep 17 00:00:00 2001 From: Kanishk Rawat Date: Thu, 10 Sep 2026 00:16:39 +0530 Subject: [PATCH 1/3] Don't cite a released import id after the reference has settled `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 #265 --- .changeset/settled-import-guard.md | 14 ++++++++++ __tests__/index.test.ts | 41 ++++++++++++++++++++++++++++++ src/rpc.ts | 11 +++++++- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 .changeset/settled-import-guard.md diff --git a/.changeset/settled-import-guard.md b/.changeset/settled-import-guard.md new file mode 100644 index 0000000..e61faaa --- /dev/null +++ b/.changeset/settled-import-guard.md @@ -0,0 +1,14 @@ +--- +"capnweb": patch +--- + +Don't cite a released import id after the reference has settled. + +`ImportTableEntry.resolve()` stores the resolution and immediately calls `sendRelease()`, so the +import is dead on both sides — but the entry keeps `importId`, since the release accounting names +the id through it. `getImport()` still returned that id, so passing a settled `RpcPromise` back as +an argument re-serialized a released id. The peer could not find it and threw inside `readLoop`, +which is wrapped in a single session-wide `.catch(err => this.abort(err))` — so a call-level fault +destroyed the whole session. `getImport()` now declines a settled entry and the caller exports a +fresh stub instead, matching `dispose()`, `abort()` and `onBroken()`, which already branch on +`resolution`. diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 01bfd65..c52c011 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -4227,3 +4227,44 @@ describe("deserialization and transport correctness", () => { expect(sentReason).toBe("a".repeat(MAX_CLOSE_REASON_BYTES - 1)); }); }); + +describe("settled import references", () => { + // Regression: ImportTableEntry.resolve() stores `resolution` and immediately calls + // sendRelease(), so the import id is dead on both sides -- but the entry keeps `importId`, + // because the release accounting names the id through it. getImport() then still handed out + // that dead id, so passing the settled promise object into a later message re-serialized a + // released id. The peer could not find it and threw inside readLoop, and because readLoop is + // wrapped in a single session-wide `.catch(err => this.abort(err))`, a call-level fault + // destroyed the entire session. + // + // 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()); + + // Awaiting settles the entry: the resolution is stored and the import is released. + let promise = harness.stub.returnNumber(7); + await promise; + + // Passing the same *promise object* (not its awaited value) as an argument is what + // re-serialized the now-dead id. + expect(await harness.stub.square(promise)).toBe(49); + + // The load-bearing assertion: a failing call would be tolerable, a dead session is not. + expect(await harness.stub.returnNumber(3)).toBe(3); + + harness.stub.dispose(); + }); + + // 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 () => { + let harness = new TestHarness(new TestTarget()); + + await harness.stub.returnNumber(1); + await harness.stub.returnNumber(2); + + expect(await harness.stub.returnNumber(3)).toBe(3); + + harness.stub.dispose(); + }); +}); diff --git a/src/rpc.ts b/src/rpc.ts index 8896fec..8eeb354 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -247,6 +247,11 @@ class ImportTableEntry { } async awaitResolution(): Promise { + // 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(); + if (!this.activePull) { this.session.sendPull(this.importId); this.activePull = Promise.withResolvers(); @@ -662,7 +667,11 @@ class RpcSessionImpl implements Importer, Exporter { } getImport(hook: StubHook): ImportId | undefined { - if (hook instanceof RpcImportHook && hook.entry && hook.entry.session === this) { + // A settled entry has already released its import (resolve() calls sendRelease()), so its + // importId no longer names anything on the peer. Fall through to exporting the resolution, + // the way dispose(), abort() and onBroken() all branch on `resolution`. + if (hook instanceof RpcImportHook && hook.entry && hook.entry.session === this && + !hook.entry.resolution) { return hook.entry.importId; } else { return undefined; From b97c1f1dbd8edd35160e057aef1d9a4152ec1beb Mon Sep 17 00:00:00 2001 From: Kanishk Rawat Date: Mon, 5 Oct 2026 13:58:07 +0530 Subject: [PATCH 2/3] Address review: drop unreachable guard, tighten settled-import tests - Remove the awaitResolution() guard. RpcImportHook.pull() returns on entry.resolution first, so no code path reaches it with a settled entry. - Use `await using harness` in the settled-import tests, so the harness checks import/export counts on dispose and catches a leak from the new re-export path. - Make "still releases settled imports by id" assert getStats() on both sides. It now fails if resolve() clears importId before the release. - Add a test that passes a property of a settled promise, which goes through hook.get(path) on the settled entry. Co-Authored-By: Claude Opus 5.5 --- __tests__/index.test.ts | 23 +++++++++++++++++------ src/rpc.ts | 5 ----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index c52c011..64a6b6c 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -4239,7 +4239,7 @@ describe("settled import references", () => { // // 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()); + await using harness = new TestHarness(new TestTarget()); // Awaiting settles the entry: the resolution is stored and the import is released. let promise = harness.stub.returnNumber(7); @@ -4251,20 +4251,31 @@ describe("settled import references", () => { // The load-bearing assertion: a failing call would be tolerable, a dead session is not. expect(await harness.stub.returnNumber(3)).toBe(3); + }); + + // Same as above, but through a property path on the settled promise, which the serializer + // resolves via hook.get(path) on the settled entry rather than hook.dup(). + it("does not cite a released import id via a property of a settled reference", async () => { + await using harness = new TestHarness(new TestTarget()); + + let promise = harness.stub.callSquare(harness.stub, 7); + await promise; - harness.stub.dispose(); + expect(await harness.stub.square(promise.result)).toBe(2401); + expect(await harness.stub.returnNumber(3)).toBe(3); }); // 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 () => { - let harness = new TestHarness(new TestTarget()); + await using harness = new TestHarness(new TestTarget()); await harness.stub.returnNumber(1); await harness.stub.returnNumber(2); + await pumpMicrotasks(); - expect(await harness.stub.returnNumber(3)).toBe(3); - - harness.stub.dispose(); + // 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}); }); }); diff --git a/src/rpc.ts b/src/rpc.ts index 8eeb354..d3fc826 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -247,11 +247,6 @@ class ImportTableEntry { } async awaitResolution(): Promise { - // 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(); - if (!this.activePull) { this.session.sendPull(this.importId); this.activePull = Promise.withResolvers(); From 0e3272f9ec0ff1cbc196127d14c190667bbd3956 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Mon, 5 Oct 2026 09:45:31 -0500 Subject: [PATCH 3/3] Update .changeset/settled-import-guard.md Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com> --- .changeset/settled-import-guard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/settled-import-guard.md b/.changeset/settled-import-guard.md index e61faaa..8aebf12 100644 --- a/.changeset/settled-import-guard.md +++ b/.changeset/settled-import-guard.md @@ -2,7 +2,7 @@ "capnweb": patch --- -Don't cite a released import id after the reference has settled. +Fix a whole-session abort when a settled `RpcPromise` (or a property of one) is passed back as an argument to a later call. Previously this re-sent an import id that had already been released. `ImportTableEntry.resolve()` stores the resolution and immediately calls `sendRelease()`, so the import is dead on both sides — but the entry keeps `importId`, since the release accounting names