Repository navigation
fix(net): retire epochless handles only once their Restart is deliverable - #5443
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able A WebSocket-to-QUIC upgrade re-announces every epochless route as a new instance. RequestValidity retired the handles resolved before it at once, while their Restart waited out the origin's update hold, so a request in that window failed Unroutable before any Restart could be followed. One Release per table change now ends both: announce cursors hold the pending Restart on it, and a selection replaced at the same prefix by an epochless route keeps serving until it fires. Everything else still retires at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quest outcome
Open decisions:
(Written by Claude Opus 5.5) |
# Conflicts: # quest/m0/README.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughOrigin route synchronization now shares a clock-based hold between epochless restart delivery and selection retirement. Request validity tracks the selected route period and returns Priority: ➖ Normal Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
# Conflicts: # quest/m0/README.md
Merge summaryChanges since the last outcome comment:
Decisions:
Checks: CI green on (Written by Claude Opus 5.5) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: eea0f99 against 4e3e23b, including the earlier full review at 75b6fa9 and subsequent base merges.
Two P2 races remain: an anchoring wake can be mistaken for retirement, and a synthesized broad-prefix Restart can use a different release from the retiring selection. Both can still expose Unroutable before Restart is available.
Sharing a release is a sound direction and avoids another independent timeout policy. The ordinary new-period/no-revival logic is sensible; these two paths need the same actual release boundary. The explicitly deferred consumer Restart-following work is not a new finding here.
Verification: GitHub-only full diff and origin/broadcast/time/subscriber/test inspection, plus current-head comparison. No builds or tests run; the concurrent interleavings have not been executed.
| let until = state.until(now); | ||
| state.deadline.set(Some(until)); | ||
| state.deadline.poll(waiter) |
There was a problem hiding this comment.
[P2] Recheck the release after the anchoring wake
With Held::Anchoring(S), until(S) returns S + 1ns without starting update_hold. The independently running origin driver can advance Clock after try_now() but before Deadline::poll(); the latter rereads the clock and can return Ready for that temporary anchoring deadline. Period::check then reports Unroutable before the real hold starts. An announce scan instead starts the hold at the fresh reading and remains Pending, and the same Period can subsequently appear valid again. Re-read/recompute after deadline readiness and return Ready only when the actual hold has elapsed. Add an interleaving test advancing the clock between these reads.
| let held = best.is_some_and(|entry| entry.epoch.is_none() && entry.prefix == self.prefix); | ||
| match (held, &self.retiring) { | ||
| (true, Some(_)) => None, | ||
| (true, None) => Some(hold.release().map_or(Change::End, Change::Retire)), |
There was a problem hiding this comment.
[P2] Reuse retirement's release for a synthesized prefix Restart
A broad prefix's announcement winner can stay unchanged while a path beneath it switches between two instances from the same peer. The first sync_route creates Release1 for this selection but queues no Restart. run_front later detects the moved path and calls renew (2762–2770), which calls sync_route again (4042–4052) and creates Release2 for the cursor's Restart. This branch retains Release1. Their separate lazy anchors can expire at different times, so the old request becomes Unroutable while the Restart is still held. Propagate/reuse the selection's release during the synthesized renewal, or coordinate both synchronously. Extend a_moved_path_restarts_a_scoped_cursor with nonzero hold and a same-via replacement; its current zero-hold helper misses this case.
Drop the repro note for the now-completed upgrade-unroutable quest (#5443). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Implements step 1 and the regression of quest/m0/upgrade-unroutable.md. The consumer half is split out to quest/m1/restart-followers.md, moved to m1 because the WebSocket-to-QUIC upgrade is being removed (maintainer, 2026-10-13).
Problem
When WebSocket wins the connect race, #4180's upgrade re-announces the relay's routes over QUIC, and every epochless route comes back as a new instance. Since #5294,
RequestValidityretired the handles resolved before the upgrade at once. TheirRestartwas still held by the origin'supdate_hold(300 ms), so a request in that window failed withUnroutablebefore anyRestartcould reach the holder. That is Interop'scatalog consumer: unroutable/container consumer: unroutable.Fix
Retirement and its
Restartnow share oneReleaseinrs/moq-net/src/model/origin.rs:Release, anchored like the existing hold (on the first clock reading after the change, thenupdate_holdlater).Restarton theReleaseinstead of their own per-consumer anchor.Selectionwhose peer replaced its instance at the same prefix with an epochless route keeps its period open until the sameRelease.RequestValidity::startnow captures the period with its selection (Period), socheckrefuses once either the period closes or its release fires.The old session is still draining during the hold, so it serves those requests. Subscriptions already open stay sticky on it, as before. This also covers a relay hop when a publisher upgrades, because the lite and IETF subscribers both build their validity through
Dynamic::request_validity.Tests
rs/moq-net/tests/upgrade.rssimulates an upgrade with two sessions from the same peer into one origin, under simulated time, on lite-06 and lite-07:Unroutablebefore this change. Past the hold, the old handle refuses withUnroutablewhile theRestartis already deliverable (try_next, with no wait). Following it, a freshrequest_broadcastserves a new track.Restart, and the old handle keeps serving past the hold.The race can't be forced in
websocket_upgrades_to_quic(QUIC wins locally, on the wall clock), so this sibling carries the proof.Public API / wire
Restartis released, not when the replacement is announced.Config::update_holddocuments this.Not in this PR
First-party consumers following
Restart(moq export/subscribe, moq-ffi and moq-c consumers, interop clients) need API and behavior decisions, such as what an fmp4 export does on an epochless restart. Those moved to Restart followers. Without them, a consumer that requests within the hold, as every interop subscriber does right after connecting, now works. A long-lived consumer's sticky subscriptions still end when the old session closes.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code