Skip to content

fix(xmtp_api_d14n,xmtp_mls): harden the bidi lifecycle, reconnect backoff, and catch-up ahead of enablement#3887

Merged
tylerhawkes merged 6 commits into
mainfrom
bidi-enable-hardening
Jul 22, 2026
Merged

fix(xmtp_api_d14n,xmtp_mls): harden the bidi lifecycle, reconnect backoff, and catch-up ahead of enablement#3887
tylerhawkes merged 6 commits into
mainfrom
bidi-enable-hardening

Conversation

@tylerhawkes

@tylerhawkes tylerhawkes commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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_streams set the process suspend_requested flag under the registry lock but sent each transport's Cmd::Suspend/Cmd::Resume after 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_UPTIME resets the backoff; one that dies inside that window is flapping and keeps backing off. The explicit resume() reset is unchanged.

Latent hardening

  • Guard the catch-up open against an empty subscription set (#3, low). An empty subs would seed an adds-nothing open frame that never earns a CatchUpComplete, stalling ~90 s → Exhausted. Unreachable today (the welcome topic is always present), but the safety sat far from the open; short-circuit it to Complete.

  • Time-box the catch-up half-close (#4, low). catch_up_attempt awaited conn.finish() with no deadline; a wedged actor could hang it. Wrap the half-close and drain under one budget, matching the transport's own close_gracefully.

  • Conclude a superseded resume waiter on suspend (#5, low). A resume() awaiting catch-up that a suspend() 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

  • Chunking × reconnect and × suspend/resume (#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

  • Both affected crates compile; clippy clean; cargo fmt clean.
  • All 239 transport scripted tests pass (incl. the two new chunking tests and the updated preempt test); catch-up + lifecycle tests pass against the live node; property model passes with the transport changes.

🤖 Generated with Claude Code

Note

Harden bidi transport lifecycle with reconnect backoff, suspend/resume ordering, and catch-up fixes

  • Reconnect backoff in LedgerTask.wire_died now resets to the floor only if the wire stayed up for at least MIN_STABLE_UPTIME (10s); short-lived flaps continue to grow the backoff instead.
  • Transport.enqueue_suspend and enqueue_resume allow callers to enqueue lifecycle commands under the SHARED_WIRES lock and await acknowledgment outside it, closing a race where a resume could be enqueued between a flag flip and its suspend command.
  • Pending 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_live returns 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.
  • Behavioral Change: over-cap leases that were still catching up on wire death are now fully re-issued in fresh chunks on reopen rather than resuming mid-stream.

Macroscope summarized a238169.

tylerhawkes and others added 6 commits July 22, 2026 13:57
…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
@tylerhawkes
tylerhawkes requested a review from a team as a code owner July 22, 2026 20:16
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @tylerhawkes's task —— View job


Code Review Complete

Review 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 Issues

None found. The two high-priority fixes (lifecycle command ordering and reconnect backoff) are solid implementations that address real concurrency issues.


Code Quality & Best Practices

Excellent overall. The code demonstrates deep understanding of:

  • Async Rust concurrency patterns
  • Lock ordering disciplines
  • State machine invariants
  • Defensive programming

Specific observations:

  1. Lock ordering fix (router_callbacks.rs:311-326)
    The synchronized command enqueue under the registry lock is the correct solution. The pattern of splitting enqueue_*() from the await is clean and well-documented.

  2. Flapping wire backoff (bidi_transport.rs:263-267)
    MIN_STABLE_UPTIME is a well-chosen heuristic. The 10s threshold distinguishes transient RSTs from genuine reconnects without being overly conservative.

  3. Empty subscription guard (catch_up.rs:382-388)
    Good defensive programming. The comment at line 382-385 clearly explains why this "impossible" case is worth guarding.

  4. Catch-up timeout (catch_up.rs:511-521)
    Time-boxing the half-close and drain together mirrors the transport's own GRACEFUL_CLOSE_BUDGET pattern. Consistent design.

  5. Resume waiter preemption (router_callbacks.rs:370-404)
    The settle_lifecycle refactoring that latches on Closed ensures suspended-then-resumed wires that fail discovery still route correctly.


Performance Considerations

No concerns. The changes are correctness fixes, not hot paths:

  • Lock hold times remain minimal (synchronous push only)
  • Backoff logic adds negligible overhead
  • Timeout budget is appropriate for the use case

Security Concerns

None. These are internal state management fixes with no API surface changes or data exposure.


Test Coverage

Strong. The PR includes:

  • 2 new deterministic chunking tests (lines 176-247 in bidi_transport.rs)
  • Updated property test defaults (line 718: 16→32 cases)
  • Updated preempt test for the resume waiter fix

Recommendation: The property tests now run 32 cases by default, which is good for CI. Consider documenting the PROPTEST_CASES env var in CLAUDE.md for developers who want faster local runs during iteration.


Minor Observations

  1. Documentation quality (throughout): The commit messages and inline comments are exceptional. The PR description's "two that matter most" framing helps reviewers prioritize.

  2. Atomicity (6 commits): Each commit is self-contained and well-scoped. This aids bisection and makes the review much easier.

  3. Type safety (bidi_transport.rs:432-439): The distinct LeaseId and WaveId types prevent silent mix-ups. Good use of newtype pattern.


Conclusion

This 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:

  • Read and analyze crates/xmtp_api_d14n/src/queries/bidi_transport.rs
  • Read and analyze crates/xmtp_api_d14n/src/queries/bidi_transport_props.rs
  • Read and analyze crates/xmtp_mls/src/subscriptions/catch_up.rs
  • Read and analyze crates/xmtp_mls/src/subscriptions/router_callbacks.rs
  • Provide review feedback

@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

@tylerhawkes
tylerhawkes enabled auto-merge (squash) July 22, 2026 20:44
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.91525% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.74%. Comparing base (622cf37) to head (a238169).

Files with missing lines Patch % Lines
crates/xmtp_api_d14n/src/queries/bidi_transport.rs 96.62% 3 Missing ⚠️
crates/xmtp_mls/src/subscriptions/catch_up.rs 81.81% 2 Missing ⚠️
...tes/xmtp_mls/src/subscriptions/router_callbacks.rs 94.44% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tylerhawkes
tylerhawkes merged commit ebe0e1f into main Jul 22, 2026
62 of 65 checks passed
@tylerhawkes
tylerhawkes deleted the bidi-enable-hardening branch July 22, 2026 20:56
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.

1 participant