diagnostics_channel: snapshot the subscriber list per publish - #33288
diagnostics_channel: snapshot the subscriber list per publish#33288robobun wants to merge 3 commits into
Conversation
publish() re-read this._subscribers on every iteration while unsubscribe() spliced that same array in place, so a subscriber that unsubscribed during dispatch shifted the array under the loop and the next subscriber was skipped. A subscriber added during dispatch was likewise appended to the in-flight publish. Make subscribe()/unsubscribe() copy on write and have publish() capture the array once, so a publish dispatches to exactly the subscribers registered when it started. This keeps publish() allocation-free; only subscribe/unsubscribe copy.
|
Updated 8:21 PM PT - Jul 2nd, 2026
✅ @robobun, your commit 220a66733b33442c0e20f9878980a684c702f94b passed in 🧪 To try this PR locally: bunx bun-pr 33288That installs a local version of the PR into your bun-33288 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Walkthrough
ChangesDiagnostics Channel Subscriber Mutation Safety
Related Issues: None specified. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Confirmed, the overlap is real: #32628 does carry the same copy-on-write change to Two things make me think this is still worth landing separately, though I'm happy either way. The tests don't collide. #32628 doesn't touch
Scope. #32628 is a draft at 58 files, +3305/-185, last commit Jun 23. This is 2 files, and the behavior fix is independent of the rest of that sync. So: happy to land this and let #32628 drop the hunk when it rebases (it would already be applied), or to close this and move the test block over to #32628 if @cirospaciari would rather keep it together. Either works, your call. |
There was a problem hiding this comment.
LGTM — clean copy-on-write fix matching Node's approach, with thorough test coverage.
Extended reasoning...
Overview
This PR fixes node:diagnostics_channel so that mutating the subscriber list during a publish() (subscribing or unsubscribing from within a subscriber callback) does not skip or duplicate deliveries in the in-flight dispatch. The source change is ~15 lines in src/js/node/diagnostics_channel.ts: subscribe() and unsubscribe() now copy-on-write the _subscribers array via a captured ArrayPrototypeSlice, and publish() captures this._subscribers into a local before iterating. The test file gains 8 new tests covering self-unsubscribe, unsubscribing later/all subscribers, subscribing mid-publish, duplicate registrations, channel emptied then re-subscribed mid-publish, and runStores().
Security risks
None. This is pure JS iteration-order semantics for an observability API — no I/O, auth, crypto, parsing of untrusted input, or memory management is touched.
Level of scrutiny
Low-to-moderate. The change is small, mechanical, and directly mirrors the upstream Node.js fix (nodejs/node#55116). It follows the file's existing primordial-safe conventions (captured Array.prototype methods invoked via .$call). I traced the trickiest case (mutate6: channel emptied via maybeMarkInactive swapping the prototype to Channel, then re-subscribed by a still-in-flight subscriber) and it works correctly because the loop iterates the captured local array, and the later subscribe re-activates the channel via Channel.prototype.subscribe → markActive. The retained subscribers?.length guard is now technically unnecessary (the local is always an array on ActiveChannel) but harmless.
Other factors
The bug-hunting system found no issues. There is no CODEOWNERS entry for this path. The PR description is exceptionally detailed with a per-case behavior table verified against Node v26.3.0, and each row maps to a dedicated test asserting exact dispatch order plus post-publish state. A bot flagged possible overlap with PR #32628 (a broader Node 26 sync) — that's a merge-coordination concern for maintainers, not a correctness issue with this change.
|
Thanks. No changes needed, but one correction on the note about the It's still reachable. const ch = dc.channel("guard");
const f = () => {};
ch.subscribe(f);
const pub = ch.publish; // ActiveChannel.prototype.publish
ch.unsubscribe(f); // maybeMarkInactive -> _subscribers = undefined
pub.call(ch, "data"); // without the `?.` this is undefined.lengthnode v26.3.0 and this branch both run that without throwing, and node keeps the same guard upstream for the same reason, so I've left it as is. |
There was a problem hiding this comment.
LGTM — small, well-tested copy-on-write fix that mirrors upstream Node (nodejs/node#55116).
Extended reasoning...
Overview
This PR fixes node:diagnostics_channel so that mutating the subscriber list from inside a subscriber (the common one-shot/trace-once idiom) does not skip or double-invoke other subscribers during the same publish(). The implementation change is ~15 lines in src/js/node/diagnostics_channel.ts: subscribe()/unsubscribe() now copy-on-write the _subscribers array, and publish() captures the array reference once before iterating. This is a direct port of the upstream fix in nodejs/node#55116. The test file gains 8 targeted cases (self-unsubscribe, unsubscribe-later, unsubscribe-all, subscribe-during-publish, duplicate registration, empty-then-resubscribe, and runStores) plus one existing test is extended to match Node's updated upstream test.
Security risks
None. This is pure JS in a Node-compat module with no auth, crypto, filesystem, or network surface. The change uses captured primordials (ArrayPrototypeSlice, ``, $arrayPush) consistent with the rest of the file, so no new tampering surface is introduced.
Level of scrutiny
Low-to-moderate. The fix is mechanical, matches the referenced upstream Node commit exactly in approach, and the affected code path is self-contained (only ActiveChannel.subscribe/unsubscribe/publish). I traced the trickiest new test (mutate6, where the channel is emptied mid-publish via maybeMarkInactive and then re-subscribed by a later snapshot subscriber through the Channel → markActive path) and the control flow is correct. The retained subscribers?.length guard was already justified in the thread. No CODEOWNERS cover this path.
Other factors
The bug-hunting system found nothing. There is an open sequencing note in the thread about overlap with draft PR #32628 (a larger Node 26 sync), but the author has laid out both options and the code here is correct and independently landable either way — that's a maintainer merge-order decision, not a correctness concern. Test coverage is thorough and asserts exact dispatch order plus post-publish state, so regressions would be caught.
Repro
A subscriber that unsubscribes itself (the one-shot / trace-once idiom) silently prevents a later subscriber from seeing the publish it is in the middle of:
Other shapes of the same bug, all confirmed against node v26.3.0:
f1,f2,f3f1,f3f1,f2f1f1,f2f1f1f1,latef1,f2,f1f1,f1f1,f2, thenf3f1, then nothing (channel left with no subscribers)runStoresf1,f2,fnf1,fnCause
ActiveChannel.publish()re-readthis._subscriberson every iteration, whileActiveChannel.unsubscribe()spliced that same array in place:So when a subscriber unsubscribed during dispatch the live array shifted left under the loop index and the next subscriber was skipped, and when it subscribed during dispatch the new function was appended to the in-flight publish. Unsubscribing the last subscriber also set
_subscriberstoundefinedviamaybeMarkInactive, which ended the publish early.The same function subscribed twice is the worst shape: removing the first registration shifts the second one onto the current index, so the one-shot subscriber runs a second time while the subscriber between them is skipped.
Fix
Match node (nodejs/node#55116): make
subscribe()/unsubscribe()copy on write, and havepublish()capture the array once. A publish then dispatches to exactly the subscribers registered when it started, and the mutation takes effect from the next publish on.Copy-on-write rather than copying inside
publish()keeps the hot path allocation-free:publish()is what runs per request (http2.ts,_http_client.ts,dgram.tsonly ever publish), whilesubscribe/unsubscribeare setup-time.publish()is also marginally cheaper now, since the property load is hoisted out of the loop.Verification
test/js/node/diagnostics_channel/diagnostics_channel.test.tsgains 8 tests covering every row of the table above, each asserting the exact dispatch order plus that the mutation did take effect on the following publish.does not throw when unsubscribedis also brought in line with the upstream test, which node extended in the same commit to assert the second subscriber still runs.All 9 differential cases now match node v26.3.0 byte for byte, and node's
test-diagnostics-channel-*ports undertest/js/node/test/parallel/still pass. (test-diagnostics-channel-http2-server-stream-close.jsfails identically before and after this change, for an unrelated http2stream.destroyedlifecycle reason.)