fix(xmtp_api_d14n,xmtp_mls): harden the bidi lifecycle, reconnect backoff, and catch-up ahead of enablement#3887
Conversation
…ription set `plan_catch_up_waves` seeds the open frame from the first chunk of `subs`; an empty `subs` would yield an adds-nothing `history_only` Mutate that never earns a `CatchUpComplete`, so the run would stall until the silence watchdog tore the wire down (~90s) and burn every attempt into `Exhausted` instead of completing instantly. `subs` always carries the welcome topic today, so this is unreachable — but the safety sat far from the open frame, and the transport's own `lease()` treats an empty add-set as a hard `Empty` error. Short-circuit an empty set to `Complete` (nothing owed) right at the plan site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AVuJ7VAgen2fFhtJQ6zid
…'t hang the run `catch_up_attempt` awaited `conn.finish()` with no deadline and time-boxed only the subsequent drain. `finish()` is a blocking send on the connection's command channel; if the actor is parked emitting into a full event channel (the AB-BA the module docs warn about), that send hangs unbounded. The transport's own `close_gracefully` wraps `finish()` in a budget for exactly this reason. Catch-up is safe today only because it finishes after `outstanding` is empty (=> the command channel is drained), an invariant proven three call sites away. Wrap the half-close and the drain under one `POST_FINISH_DRAIN` budget: the courtesy close can never hang the run, since every owed frame is already processed by this point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AVuJ7VAgen2fFhtJQ6zid
The backoff reset to the 100ms floor the instant a wire opened, before a single frame arrived. A server that accepts the stream then closes it immediately — an overloaded node RSTing right after accept — therefore pinned every client at the reconnect floor forever, with no exponential growth, amplifying load against the very backend that was struggling. (The fleet jitter de-syncs the herd but does nothing about the per-client rate.) Reset the backoff only when a wire proves stable: it stayed up at least MIN_STABLE_UPTIME before dying. A wire that opens and dies inside that window is flapping, so wire_died grows the backoff instead. A bare accept no longer earns a reset. The explicit resume() reset (a foregrounded app wants a prompt reconnect) is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AVuJ7VAgen2fFhtJQ6zid
…d preempts it A resume() awaiting its catch-up parks in resume_notify until a live wire has no wave left. If a suspend() arrives first — the app re-backgrounded before the resume caught up — the waiter was left parked until some later resume, stranding the caller (and, at the FFI, the task awaiting it) for the whole background sojourn. A suspend supersedes any pending resume: the wire is going off the network, so the resume can never reach its live edge on this transition. Conclude the parked waiters at the suspend instead (both the direct suspend() and the dial-preempting suspend_preempted()), leaving the failed-dial park_deferred_resumes path — which legitimately rides the retry — untouched. The next resume parks a fresh waiter as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AVuJ7VAgen2fFhtJQ6zid
…pend flag under one lock suspend_bidi_streams/resume_bidi_streams set the process suspend_requested flag under the registry lock but sent each transport's Cmd::Suspend/Cmd::Resume after dropping it (the command rode the async suspend()/resume() future, polled by join_all outside the lock). So the flag was ordered by the mutex but the command push was not: on a multi-threaded runtime a concurrent suspend and resume could race between lock-release and their sends, delivering the commands to a transport in the opposite order of the flag — leaving one wire live while backgrounded (battery) or parked while foregrounded (no delivery), disagreeing with the flag until the next lifecycle transition. Split the synchronous command push (enqueue_suspend/enqueue_resume) from the reply await, and do the push inside the same locked section that flips the flag. The unbounded-channel send is synchronous, so flag and command push happen atomically under the lock; only the reply await — which can block on catch-up — runs outside. The command order now matches the flag order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AVuJ7VAgen2fFhtJQ6zid
…ume paths The multi-wave-per-lease chunking behind the Mutate frame bound was exercised across a wire death or a suspend/resume only by the property test, which defaults to 16 cases; every scripted lifecycle test used the production cap, so none actually chunked. Add two deterministic tests at a shrunk cap: an over-cap lease still catching up when the wire dies is re-issued whole and re-chunked on reopen, reporting caught-up only once its LAST reopened chunk completes; and the same lease carried across a suspend/resume, where resume() resolves only once every resumed chunk is caught up. Also raise the property test's default case count (env-overridable) so the delicate chunked-completion accounting gets more random interleavings by default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AVuJ7VAgen2fFhtJQ6zid
|
Claude finished @tylerhawkes's task —— View job Code Review CompleteReview Date: July 22, 2026 20:17 UTC I've reviewed the 4 changed files across 6 commits focused on hardening the XIP-83 bidi lifecycle before enablement. The changes address critical race conditions and improve resilience. Here's my feedback: Critical IssuesNone found. The two high-priority fixes (lifecycle command ordering and reconnect backoff) are solid implementations that address real concurrency issues. Code Quality & Best PracticesExcellent overall. The code demonstrates deep understanding of:
Specific observations:
Performance ConsiderationsNo concerns. The changes are correctness fixes, not hot paths:
Security ConcernsNone. These are internal state management fixes with no API surface changes or data exposure. Test CoverageStrong. The PR includes:
Recommendation: The property tests now run 32 cases by default, which is good for CI. Consider documenting the Minor Observations
ConclusionThis is production-ready hardening code. The fixes address real concurrency issues that could manifest under mobile app lifecycle transitions and network flapping. The test coverage gives confidence, and the bidi-default-OFF deployment strategy means any latent issues won't block release. Recommend: Approve and merge. Todo List:
|
ApprovabilityVerdict: Approved Hardening changes to bidi transport reconnect backoff and lifecycle management. All modified files are owned by the PR author (admin). The changes add defensive checks, improve race condition handling, and include comprehensive tests. You can customize Macroscope's approvability policy. Learn more. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3887 +/- ##
==========================================
+ Coverage 85.64% 85.74% +0.09%
==========================================
Files 426 426
Lines 68005 68102 +97
==========================================
+ Hits 58245 58394 +149
+ Misses 9760 9708 -52 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Pre-enablement hardening for the XIP-83 bidi surface, from a fresh adversarial re-review of the now-merged transport + catch-up + lifecycle code (the interaction bugs that per-PR reviews miss). Bidi still ships default-OFF (
XMTP_BIDI_STREAMS_ENABLED), so none of these block a release — they harden the fleet behavior that matters the moment the flag is flipped. Six atomic commits, one per finding.The two that matter most for enablement
Order the lifecycle command with the suspend flag under one lock (
#1, medium).suspend_bidi_streams/resume_bidi_streamsset the processsuspend_requestedflag under the registry lock but sent each transport'sCmd::Suspend/Cmd::Resumeafter dropping it. On a multi-threaded runtime a concurrent suspend+resume could race between lock-release and their sends, delivering the commands to a transport opposite to the flag — a wire left live while backgrounded (battery) or parked while foregrounded (no delivery). Now the synchronous command push (enqueue_suspend/enqueue_resume) happens inside the same locked section as the flag; only the reply await runs outside.Grow the reconnect backoff on a flapping wire (
#2, medium, pre-existing). The backoff reset to the 100 ms floor the instant a wire opened, before any frame. A server that accepts then immediately closes (an overloaded node RSTing after accept) pinned every client at the floor forever, amplifying load against the struggling backend. Now only a wire that stays up ≥MIN_STABLE_UPTIMEresets the backoff; one that dies inside that window is flapping and keeps backing off. The explicitresume()reset is unchanged.Latent hardening
Guard the catch-up open against an empty subscription set (
#3, low). An emptysubswould seed an adds-nothing open frame that never earns aCatchUpComplete, stalling ~90 s →Exhausted. Unreachable today (the welcome topic is always present), but the safety sat far from the open; short-circuit it toComplete.Time-box the catch-up half-close (
#4, low).catch_up_attemptawaitedconn.finish()with no deadline; a wedged actor could hang it. Wrap the half-close and drain under one budget, matching the transport's ownclose_gracefully.Conclude a superseded resume waiter on suspend (
#5, low). Aresume()awaiting catch-up that asuspend()preempts was left parked until some later resume, stranding the caller (and its task) for the whole background sojourn. A suspend supersedes pending resumes; conclude them at the suspend instead.Coverage
#6, test). The multi-wave-per-lease chunking across a wire death or suspend/resume was exercised only by the property test (16-case default); every scripted lifecycle test used the production cap, so none chunked. Adds two deterministic tests at a shrunk cap (over-cap lease survives a wire death; survives a suspend/resume), and raises the property test's default case count.Verification
clippyclean;cargo fmtclean.🤖 Generated with Claude Code
Note
Harden bidi transport lifecycle with reconnect backoff, suspend/resume ordering, and catch-up fixes
LedgerTask.wire_diednow resets to the floor only if the wire stayed up for at leastMIN_STABLE_UPTIME(10s); short-lived flaps continue to grow the backoff instead.Transport.enqueue_suspendandenqueue_resumeallow callers to enqueue lifecycle commands under theSHARED_WIRESlock and await acknowledgment outside it, closing a race where a resume could be enqueued between a flag flip and its suspend command.resume()waiters are now resolved immediately when a suspend is processed (both in the normal handler and when a suspend preempts an in-flight dial).CatchUp::catch_up_to_livereturns early when the subscription set is empty (avoiding a stalled frame) and enforces a timeout on the post-finish drain to prevent indefinite blocking on a wedged connection.Macroscope summarized a238169.