Skip to content

fix: release onRpcBroken callbacks when a remote stub is disposed - #286

Open
ndisidore wants to merge 5 commits into
mainfrom
fix/on-rpc-broken-dispose
Open

ndisidore wants to merge 5 commits into
mainfrom
fix/on-rpc-broken-dispose

Conversation

@ndisidore

@ndisidore ndisidore commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Closes #210

fixes a leak on long-lived connections. A server or client that keeps making and dropping capabilities, and watches each one with onRpcBroken(), used to keep every callback it ever registered. When the connection finally dropped, all of them ran, including callbacks for objects the app had cleaned up already. Disposing a remote stub other than the session's main stub, along with all its duplicates, now unregisters the onRpcBroken() callbacks registered on it. (Disposing the main stub still ends the session, which calls them.)

Deleting the slots on disposal, as the issue sketches, only covers callbacks registered directly on a stub. A callback registered on a promise that resolves to a stub on the same session gets moved by resolve(), which keeps the original slot so callbacks still fire in registration order, while the new owner records the index of a slot that was just deleted. Registrations are now {callback, index} records shared by the session and the owning import, so resolve() can hand the original slot to the new owner along with its index.

#234 asks for a way to unregister a single callback. This PR doesn't add one: tracking stays per import, so callbacks go away only once the stub and every duplicate are disposed, and disposing just one dup unregisters nothing. #234 may reshape the records and the resolve() handoff, but the behavior and tests here hold under either design proposed there. Draft #284 targets the same issue; it also changes registration during abort and resolve() reentrancy, which are separate pre-existing behaviors I left alone.

I went with unregistering on disposal, rather than calling the callbacks at that point, for a few reasons:

This PR also detaches the session's callback list at the start of an abort, before any app code runs. Without that, a broken-connection handler that disposes child stubs would unregister the children's callbacks before they fire. As a side effect, the session no longer holds any callbacks after it ends.

One change users may notice: a stub received as a call parameter is disposed when the call returns, so an onRpcBroken() callback on it no longer fires at disconnect unless the stub was dup()ed. The changeset and the disposal and stubs docs say so, and a test covers it. The docs also advise calling onRpcBroken() on the stub you keep, because a dup() of a promise that hasn't been awaited is a separate import and does not keep the promise's callbacks. The sessions guide no longer says onRpcBroken "cannot be unregistered".

Final disposal of an unresolved import now deletes its registrations from
the session's callback list, so they are no longer retained for the
session's lifetime and called at teardown.

Registrations become {callback, index} records shared by the session and
the owning import, so a callback moved by a same-session resolve() keeps
its original slot and stays removable by its new owner. Session abort
detaches the list before running app code, so a callback that disposes
another stub cannot unregister callbacks that have yet to fire.

Closes #210
@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3655f16

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

@pkg-pr-new

pkg-pr-new Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 3655f16

@ask-bonk

ask-bonk Bot commented Oct 23, 2026

Copy link
Copy Markdown

LGTM

github run

@clanky-the-clanker

clanky-the-clanker Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

This pull request's reviews are posted as pull request reviews: see the review of 25731264.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://fix-on-rpc-broken-dispose.pr.capnweb.com (commit 3655f16)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://94bbce3c.pr.capnweb.com 3655f16 2026-11-09T15:27:08.352Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://50345b00.pr.capnweb.com 2573126 2026-11-02T18:30:39.063Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://ace874b2.pr.capnweb.com 624e13a 2026-10-23T16:31:25.895Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://3fcf9d94.pr.capnweb.com 1185d55 2026-10-23T16:23:12.089Z Visit the dashboard ↗

@srikrsna srikrsna left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the following behavior is worth documenting with a test:

it("callbacks are retained by dups", async () => {
  let harness = new TestHarness(new TestTarget());
  let counter = await harness.stub.makeCounter(0);
  let calls = 0;
  counter.onRpcBroken(() => { ++calls; });
  let dup = counter.dup();
  counter[Symbol.dispose]();

  harness.clientTransport.forceReceiveError(new Error("test disconnect"));
  await pumpMicrotasks();
  expect(calls).toBe(1);
});

Comment thread src/rpc.ts
Comment on lines +236 to +240
// here. So, let's move the newly-added registration into the original's slot, updating
// its index so that its new owner can still unregister it.
// TODO: This is quite hacky, think about whether this is really the right answer.
moved.index = index;
this.session.onBrokenCallbacks[index] = moved;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just a note, nothing to change but I think this has become even more hacky now as it depends on the newly created registration (the index update) in a different import hook along side the session.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, it now writes into the new owner's record on top of the existing endIndex check. I left it since it's the same TODO, and this handoff is what #234 has to revisit anyway: a handle created before a promise resolves needs to keep working after its callback moves. That seems like the right place to decide what this should look like.

@ndisidore
ndisidore requested a review from srikrsna November 2, 2026 18:30

@clanky-the-clanker clanky-the-clanker 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.

Superseded by the review of 3655f163.

This review, of 25731264

💬 Approved with comments — The fix is correct and tested, and the new test covers dup retention as requested in the discussion. One INFO note remains about stale registration indexes during abort. It can't make any callback fire wrongly.

Findings: 1 info

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


📋 Code Quality: 💬 1 info

The fix is correct. The new dup test covers the retention behaviour requested in the discussion. One informational note about the abort window remains.

Recommendation: 💬 Optional Nits

  • [INFO] During abort, imports keep registration indexes that point into the detached callback array, while dispose() and resolve() write to the fresh one. A stale index could delete an onRpcBroken registered during the abort window. No callback fires wrongly, because the fresh array is never iterated. (src/rpc.ts:272)
View full review

Stale indexes during abort (INFO, unchanged since the last review)

  • src/rpc.ts:940-941: abort() swaps onBrokenCallbacks for a fresh [] before any app code runs.
  • Imports keep their onBrokenRegistrations until the final abort loop, so their index values still point into the detached array.
  • If a stub is disposed in that window, dispose() at src/rpc.ts:272 deletes an entry from the fresh array. If app code registered a new onRpcBroken in the same window, that delete could remove the unrelated registration. resolve() at src/rpc.ts:240-244 can make the same kind of write.
  • The fresh array is never iterated after an abort, so no callback ever fires wrongly.
  • The description says registration during abort was left alone on purpose.
  • A cheap guard, if you want one: skip the delete when session.abortReason !== undefined.

Tests

The new test callbacks are retained by dups (__tests__/index.test.ts:2434) pins down the retention behaviour suggested in the discussion. Unregistering on full disposal is already covered by does not call back for capabilities disposed before the connection is lost.

resolve() handoff

The discussion on the resolve() handoff (src/rpc.ts:240) is noted. Writing into the new owner's record there is an existing TODO, and #234 is the natural place to rework it.


✅ All other checks passed

🚀 Release: ✅ LGTM

The changeset is present and appropriate for a bug fix. Risk: low.


🔒 Security: ✅ LGTM

No security concerns in this change.


📖 AGENTS.md: ✅ LGTM

No agent instruction files are affected.


⚡ Performance: ✅ LGTM

No performance concerns. Disposal and abort do O(1) work per registration, and disposed stubs no longer keep their callbacks alive for the lifetime of the session.


📝 Documentation: ✅ LGTM

The disposal and sessions guides accurately describe the new behaviour: callbacks are unregistered when a stub is disposed.


Reviewed commit: 25731264

@clanky-the-clanker clanky-the-clanker 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.

✅ Approved — The fix is correct and well tested. The new commits add tests for dup retention and for parameter stubs, plus accurate docs, and they introduce no issues. One earlier informational note about stale indexes during abort remains open as the author's choice.

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


✅ All checks passed

🚀 Release: ✅ LGTM

The changeset is present and appropriate for a bug fix. Risk: low.


🔒 Security: ✅ LGTM

No security concerns in this change.


📖 AGENTS.md: ✅ LGTM

No agent instruction files are affected.


📋 Code Quality: ✅ LGTM

Since the last review, this pull request adds tests and updates the docs. I found no problems in them.

  • __tests__/index.test.ts covers four behaviours, and all four match the code in src/rpc.ts:
    • callbacks are unregistered once the stub is fully disposed, including a callback that resolve() moved from a promise to a stub;
    • callbacks are kept while a dup is alive;
    • a callback still fires when an earlier callback has already disposed its stub, because abort() detaches the callback list first;
    • a callback on a parameter stub fires only if a dup outlives the call.
  • disposal.mdx, stubs.mdx and sessions.mdx describe what the code does.

One earlier note, about stale registration indexes during abort, is still open. Its code has not changed and nobody has replied to it, so it is left to the author.


⚡ Performance: ✅ LGTM

No performance concerns.

  • Disposal, resolve() and abort each do O(1) work per registration.
  • Disposed stubs no longer keep their callbacks alive for the life of the session.
  • Deleting entries leaves holes in the sparse callback array. Those holes cost far less memory than the callbacks that used to be retained.

📝 Documentation: ✅ LGTM

The changeset and the disposal, stubs and sessions docs accurately describe the new behaviour. This includes the note about parameter stubs and the advice to register onRpcBroken() on the stub you keep.


Reviewed commit: 3655f163

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.

onRpcBroken callbacks are retained for the session's lifetime after their import is disposed (and fire at teardown)

2 participants