Don't cite a released import id after the reference has settled - #267
Kanishkrawatt wants to merge 1 commit into
Conversation
`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 detectedLatest commit: aa6182b The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
commit: |
|
@ask-bonk review this |
There was a problem hiding this comment.
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-promisecase: falls back tohook.get(path)/hook.dup()and thenexportPromise/exportStub.RpcImportHook.get()already routes toentry.resolution, so this works. sendMapcaptures: 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.
| // | ||
| // 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()); |
There was a problem hiding this comment.
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.
| let harness = new TestHarness(new TestTarget()); | |
| await using harness = new TestHarness(new TestTarget()); |
(and drop the trailing 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 () => { |
There was a problem hiding this comment.
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:
| 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)
| await promise; | ||
|
|
||
| // Passing the same *promise object* (not its awaited value) as an argument is what | ||
| // re-serialized the now-dead id. |
There was a problem hiding this comment.
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.
| // 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(); |
There was a problem hiding this comment.
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'.
|
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, I checked every place that calls
Main problem is the tests:
Minor: I said I'm fine keeping the extra check in Other notes:
|
| } | ||
|
|
||
| async awaitResolution(): Promise<RpcPayload> { | ||
| // If the entry has already settled, the import has been released (resolve() calls |
There was a problem hiding this comment.
This hunk can be safely removed as I have confirmed there are no code paths able to actually hit this
Fixes #265.
The problem
ImportTableEntry.resolve()stores the resolution and immediately callssendRelease(), so the import is dead on both sides. The entry keepsimportId— it has to, because the release accounting names the id through it — butgetImport()still returned that id unconditionally.So passing a settled
RpcPromiseback as an argument re-serialized a released id. The peer cannot find it and throws insidereadLoop. SincereadLoopis 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 testdispose(),abort()andonBroken()already apply — they all branch onresolution;getImport()was the one that did not.The guard gates the use of
importIdrather 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 namingnull— and silently leak the peer's exports.awaitResolution()gets the same guard. It is not reachable with a settled entry today:RpcImportHook.pull()returns onentry.resolutionone line earlier, andsendStreamseedsactivePullviapulling = true, so thesendPullbranch never runs. It is defensive only — included so a future caller cannot regress into apullnaming 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 unpatchedmainwithno 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:
vitest run --project=nodevitest run --project=workerdnpm run test:typesI did not run the browser projects or
test:bunlocally (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 aboutreadLoop'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.