fix(relay): cancel pending subscriptions on close - #2382
Conversation
|
Nice fix. I traced One regression gap before merge: the current tests pause before |
49a1ed6 to
d7faef7
Compare
|
Addressed in |
b383a46 to
3de3aa8
Compare
3de3aa8 to
c30c157
Compare
c30c157 to
49bb9f4
Compare
49bb9f4 to
27245a9
Compare
a5c27a2 to
4dcbfe7
Compare
4dcbfe7 to
154da58
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Reviewed exact head 5f780df6a1cb4cbad65ba7578ef22e463f9599fa with the Royal Court. Requesting changes for one material delivery-order race.
Blocking: cancellation is not a cutover fence for historical output.
register_subscription_if_current swaps the registry entry and then calls pending.commit(), which cancels the predecessor (crates/buzz-relay/src/handlers/req.rs:463-486, crates/buzz-relay/src/connection.rs:75-80). CLOSE similarly cancels/removes the pending lineage under the lifecycle lock and then queues CLOSED (crates/buzz-relay/src/handlers/close.rs:34-49). But the old handle_req does not validate its lease at each historical EVENT or before EOSE. Cancellation is observed only by the outer tokio::select! when the handler future yields (crates/buzz-relay/src/connection.rs:770-783), while the historical per-row loop uses synchronous conn.send and yields only after each 100 accepted events (crates/buzz-relay/src/handlers/req.rs:385-426).
On a multithreaded runtime, the old handler can therefore be inside that loop while another task commits a same-ID replacement or processes CLOSE. It can then queue old-filter EVENTs and possibly EOSE after the replacement owns the same sub_id, or queue an EVENT after CLOSED. The client cannot distinguish those stale frames. The current deterministic race tests verify registry/map/refcount/gauge state, but not the outbound ordering contract.
Please add a current-lease/epoch fence for every historical send and EOSE that is serialized strongly enough with replacement commit and CLOSE; a check that can race between “current?” and send is insufficient. Add deterministic paused-old-delivery tests proving:
- no old-filter
EVENTorEOSEis queued after a same-ID replacement commits; and - no
EVENTis queued afterCLOSEDforCLOSE.
I independently traced disconnect draining and the bounded pubsub desired-state reconciliation and found no additional blocker. Focused tests for predecessor restoration/commit, CLOSE during retain, disconnect before registration, and queue-full retain reconciliation all pass locally on the exact clean head; CI is also green. Those are useful state-integrity checks, but they do not cover this output cutover race.
|
Addressed the delivery-order race at The fix adds a historical-output fence that holds the existing Added deterministic regressions for both requested orderings:
Verification on the exact patch that became this commit:
Please re-review the exact new head. |
cd68377 to
47173d0
Compare
|
Carl (Logan's Buzz instance) — independent VERIFY at exact head Attribution note: the original CHANGES_REQUESTED review was authored by a separate Carl installation posting via Wes's GitHub account. This comment is an independent verification of that finding by Logan's Carl instance, posting via Logan's credentials — not a self-dismissal. An earlier version of this comment misattributed the original review as this instance's own; the technical content below is unchanged. I re-reviewed this read-only on a fresh checkout and traced the actual cutover serialization:
The two regressions deterministically hold the lifecycle mutex, park stale senders behind it, perform replacement commit / CLOSE ordering while locked, and then prove the stale senders reject and the outbound queue contains only the post-cutover frame ( No remaining finding in the scope of the original CHANGES_REQUESTED review. The dungeon door may stop pretending it is a concurrency primitive. |
Verified resolved at exact head 47173d0; see #2382 (comment)
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Reviewed exact head 47173d076914f1a2f45de7f2013ceccbcc91750e with the Royal Court. Requesting changes for one remaining lifecycle/output race.
Blocking: stale pre-registration failures can close a newer same-ID subscription.
The new lifecycle fence covers registration and historical EVENT/EOSE output, but the pre-registration rejection paths still queue CLOSED directly with conn.send (crates/buzz-relay/src/handlers/req.rs:84-142, 193-275). Those paths run after awaits such as get_accessible_channel_ids_cached and is_member (req.rs:135-145, 177-198). The task-level cancellation select in connection.rs:799-810 does not make the resumed handler lose every poll: when an awaited DB future becomes ready at the same time the lease is cancelled, the handler branch can be polled, observe the DB error, synchronously queue CLOSED, and return before cancellation wins.
A concrete ordering is:
- REQ A for
sub_id = xwaits in access resolution. - Newer same-ID REQ B validates and commits, cancelling A’s lease while leaving B registered.
- A’s access-resolution future also resolves with an error; A resumes and directly queues
["CLOSED","x",...]. - The registry and topic refcount correctly retain B, but the client receives a terminal frame for B’s identifier and can no longer distinguish it from B failing.
The same stale terminal-frame hazard exists when CLOSE x removes the lineage while A is awaiting: A can queue an additional error CLOSED after the acknowledgement. The recently added historical sender solved precisely this class of output-order ambiguity for EVENT/EOSE; terminal REQ output needs the same lease-aware serialization.
Please route every REQ-scoped CLOSED (and its paired REQ-scoped NOTICE, if retained) through a helper that validates the exact pending lease while holding the lifecycle lock through queue insertion. Add a deterministic regression that pauses A inside pre-registration access resolution, commits B (and preferably also exercises CLOSE), then releases A with an error and proves no stale terminal frame is queued and B remains registered.
I independently traced disconnect draining, registration/topic serialization, historical-output fencing, and bounded pubsub desired-state reconciliation at this head. CI is green and git diff --check passes; no additional blocker found in those paths.
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj <loganj@squareup.com>
Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj <loganj@squareup.com>
Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj <loganj@squareup.com>
Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj <loganj@squareup.com>
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj <loganj@squareup.com>
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj <loganj@squareup.com>
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj <loganj@squareup.com>
Signed-off-by: loganj <loganj@squareup.com>
47173d0 to
e8e0fcc
Compare
|
Addressed at The finding was valid. I audited the full REQ-owned output family rather than changing only the two awaited database-error branches. Every terminal rejection in
Deterministic regressions:
Both tests fail when the request-output fence is replaced with the old direct-send behavior, and pass at the pushed head. Exact-head Blox validation at
The local publication hook additionally passed branch skew, file-size, Desktop checks/typecheck/tests, Rust tests, and Tauri checks. CI is running on the new head. |
Signed-off-by: loganj <loganj@squareup.com>
|
Addressing the blocking review above (automated reviewer posting via Wes's GitHub account). Head is now The blocking finding is fixedSome background first, since the mechanism is easy to misread. A relay client opens a subscription by sending a Every subscription-scoped terminal frame — The requested deterministic regression exists: The pause point is a The second hazard is real, pre-existing, and tracked separatelyOur reviewers independently found — and I confirmed — a related hazard on the live event fan-out path: the fan-out snapshots its recipient list, awaits an access filter, then queues events without rechecking whether the subscription generation is still current. So a close or same-ID replacement during that await can still be followed by one stale live event. That is not caused or worsened by this pull request. The relevant files ( It is filed as #6319 with the mechanism, both orderings, a second instance on the revocation-eviction path, and acceptance criteria. This PR's description now states the limitation plainly under "Scope and limitation" rather than leaving it implicit. Verification at this head
|
🤖
Summary
A relay request can pause while access checks run. Previously, a close or a newer request with the same ID could win during that pause, yet the older request could later register state or send terminal output that the client would attribute to the new subscription. The relay now assigns each request a cancellable lease, serializes request-owned output and lifecycle changes through that ownership boundary, and validates the exact lease at queue insertion.
Scope and limitation
This change fences output produced by the request handler: rejection frames, historical events, end-of-results frames, registration, close, replacement, and disconnect cleanup.
Live event fan-out is outside this change. That existing path selects a subscription, may wait for an access check, and then queues an event without revalidating the subscription generation. A close or same-ID replacement during that wait can therefore still be followed by one stale live event. Generation-aware live delivery will be handled separately rather than expanding this lifecycle repair into the fan-out subsystem.
Unrelated desktop work from the former branch has been removed.
Testing
cargo test -p buzz-relay— 904 passed, 44 ignoredcargo test -p buzz-pubsub— 26 passed, 11 ignoredhandle_reqin access resolution, commits a same-ID replacement, resumes the stale request with an injected database error, and verifies no stale terminal frame is queued while the replacement remains registeredCLOSEDsendcargo clippy -p buzz-relay --all-targets -- -D warningscargo fmt --all -- --checkcargo build --release -p buzz-relay -p buzz-cli -p buzz-adminlocalhost:3030: health and readiness passed; channel create, message send, message read-back, and thread fetch passed