Skip to content

Commit b4fecce

Browse files
authored
feat(xmtp_api_d14n): bidi silence watchdog + transport suspend/resume (#3829)
Liveness moves into the connection actor, and the transport gains the mobile app-lifecycle touchpoint. ## Commit 1 — bidi actor silence watchdog A half-open wire (silent but never closed) previously left `Connection::next()` pending forever; detection was deferred to callers via `probe()`. The actor now polices this itself — it is the sole inbound reader, so silence detection there cannot deadlock (an inline `probe()` await from the transport's ledger loop would stop draining events, park the actor on a full event channel, and starve the very pong it waits for). - Every inbound frame stamps activity; cadence comes from the server's `Started` (30s XIP-83 fallback until then). - At `(N−1)×keepalive` of silence: one watchdog ping. At `N×` (`PROBE_TIMEOUT_MULTIPLIER` = 3): teardown. - Emitting to a backpressured consumer restarts the window — a slow consumer must never read as wire silence (frames may sit unread behind the parked emit). Teardown surfaces as end-of-events → the transport's existing `Wire(None)` → resume-wave path (previous PR) absorbs it. `probe()` remains for callers needing an answer faster than the watchdog budget (e.g. notification handlers). ## Commit 2 — transport `suspend()` / `resume()` - `suspend()`: graceful half-close; leases and wire positions kept; new leases register without touching the network; nothing reconnects until resume. Idempotent. - `resume()`: re-opens with the same resume wave a wire flap uses and resolves at that wave's `CatchUpComplete` — "catch up, then done", the background-fetch primitive. Waiters ride open-failure backoff and re-home onto the next resume wave if the wire dies mid-catch-up; a second `resume()` joins the in-flight one. A live wire (or nothing leased) resolves immediately — a process thawed *without* suspending is instead caught by the watchdog above. Also converts the wire opener to the `MaybeSend`/`MaybeSync` trait-object pattern (per `watchdog.rs` precedent) — the ledger holds a shared reference across the open await, and the Maybe* form keeps the bound wasm-portable even though this module is native-only today. ## Review-round fixes - Watchdog teardown yields to ready work (`biased` select ordering); suspend/resume transitions log at info; the watchdog nonce is reserved so client probes can never mint it. - Resumes deferred by a failed dial park instead of re-dialing (macroscope finding): an explicit `resume()` deliberately dials immediately, but resumes arriving *mid-dial* rode that attempt and lost with it — replaying each as a fresh dial would stampede the opener past its backoff. They now park (the suspend-preempt rule) and ride the scheduled retry; only a resume arriving between attempts earns a new immediate dial. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 701cc12 commit b4fecce

2 files changed

Lines changed: 1383 additions & 221 deletions

File tree

crates/xmtp_api_d14n/src/queries/bidi.rs

Lines changed: 298 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
//! bidi stream end-to-end and is the **sole writer** of the request half. It
55
//! auto-answers server `Ping`s, correlates client liveness probes, multiplexes
66
//! caller commands onto the wire, and surfaces only real subscription events —
7-
//! keepalive never reaches the consumer.
7+
//! keepalive never reaches the consumer. It also polices liveness on its own:
8+
//! a wire with no inbound frames for the silence budget gets one watchdog ping,
9+
//! and if that too goes unanswered the actor tears down — so a half-open link
10+
//! surfaces as end-of-events instead of a stream that hangs forever.
811
//!
912
//! Everything backend-specific (wire types, frame construction, and turning an
1013
//! inbound frame into consumer events — including d14n's `OriginatorEnvelope`
@@ -43,7 +46,28 @@ pub(crate) const DEFAULT_KEEPALIVE_MS: u32 = 30_000;
4346
/// is this many keepalive intervals — generous enough not to false-positive a
4447
/// slow-but-live link. Latency-sensitive callers (e.g. a notification handler)
4548
/// pass a much smaller bound to [`Connection::probe_within`].
49+
///
50+
/// It is also the actor's total silence budget: after `N - 1` intervals with no
51+
/// inbound frame the actor sends a watchdog ping, and after the full `N` it
52+
/// tears down (see [`watchdog_deadline`]).
4653
pub(crate) const PROBE_TIMEOUT_MULTIPLIER: u32 = 3;
54+
/// Nonce for the actor's own watchdog pings. Client probes mint their nonces
55+
/// via [`next_probe_nonce`], which skips this value, so they can never collide
56+
/// with it — and the watchdog doesn't correlate the pong anyway: *any* inbound
57+
/// frame proves the link and resets the window.
58+
const WATCHDOG_NONCE: u64 = u64::MAX;
59+
60+
/// Mint a client probe nonce: counts up from 1, skipping `0` and the reserved
61+
/// [`WATCHDOG_NONCE`] when the counter wraps. The loop settles within three
62+
/// steps — there are only two reserved values.
63+
fn next_probe_nonce(counter: &AtomicU64) -> u64 {
64+
loop {
65+
let nonce = counter.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
66+
if nonce != WATCHDOG_NONCE && nonce != 0 {
67+
return nonce;
68+
}
69+
}
70+
}
4771
/// Hard cap on the outbound backlog. Past this, the wire has been wedged long
4872
/// enough that buffering more is pointless — the transport isn't draining the
4973
/// request half, so the link is effectively dead — and the actor gives up,
@@ -350,10 +374,7 @@ impl<B: BidiBinding> Connection<B> {
350374
if self.finished.load(Ordering::Acquire) {
351375
return Err(BidiError::Closed);
352376
}
353-
let nonce = self
354-
.probe_nonce
355-
.fetch_add(1, Ordering::Relaxed)
356-
.wrapping_add(1);
377+
let nonce = next_probe_nonce(&self.probe_nonce);
357378
let (ack, ack_rx) = oneshot::channel();
358379
self.commands
359380
.send(Command::Probe { nonce, ack })
@@ -371,12 +392,16 @@ impl<B: BidiBinding> Connection<B> {
371392
}
372393

373394
/// Next event, in wire order. `None` means the connection ended (server
374-
/// close, network death, or reap) — resume from durable cursors on a fresh
375-
/// connection.
395+
/// close, network death, watchdog teardown, or reap) — resume from durable
396+
/// cursors on a fresh connection.
376397
///
377-
/// Only resolves on an event or end-of-stream: a server that goes quiet
378-
/// without closing (a half-open link) leaves this pending. Detect that gap
379-
/// out of band with [`Self::probe`] plus a timeout, never by waiting here.
398+
/// Only resolves on an event or end-of-stream, but a half-open link cannot
399+
/// leave it pending forever: the actor's silence watchdog tears the
400+
/// connection down after [`PROBE_TIMEOUT_MULTIPLIER`] keepalive intervals
401+
/// with no inbound frame (one watchdog ping in between), and that teardown
402+
/// surfaces here as `None`. [`Self::probe`] remains for callers that need
403+
/// an answer *faster* than the watchdog budget — e.g. a notification
404+
/// handler deciding within seconds whether to reuse the connection.
380405
pub async fn next(&mut self) -> Option<Event<B::GroupMessage, B::WelcomeMessage>> {
381406
let event = self.events.recv().await;
382407
// Record the server's cadence as `Started` passes through, so a probe
@@ -402,6 +427,24 @@ impl<B: BidiBinding> Drop for Connection<B> {
402427
}
403428
}
404429

430+
/// When the silence watchdog next fires: after `N - 1` keepalive intervals
431+
/// without an inbound frame it probes, and a probed connection gets the one
432+
/// remaining interval — the full `N ×` budget of [`PROBE_TIMEOUT_MULTIPLIER`] —
433+
/// before teardown. Recomputed every `select!` iteration, so any inbound frame
434+
/// pushes both deadlines out.
435+
fn watchdog_deadline(
436+
last_inbound: tokio::time::Instant,
437+
keepalive: Duration,
438+
probed: bool,
439+
) -> tokio::time::Instant {
440+
let intervals = if probed {
441+
PROBE_TIMEOUT_MULTIPLIER
442+
} else {
443+
PROBE_TIMEOUT_MULTIPLIER - 1
444+
};
445+
last_inbound + keepalive * intervals
446+
}
447+
405448
/// Hand `frame` to the wire if it has room right now, else queue it for the
406449
/// reserve branch to flush. Returns `false` when the actor should give up and tear
407450
/// down: either the wire is already closed (this frame can't be delivered), or the
@@ -458,7 +501,10 @@ fn enqueue<R>(wire_out: &mpsc::Sender<R>, pending: &mut VecDeque<R>, frame: R) -
458501
/// (so `next` ends), and any outstanding probe acks (so pending `probe`s see
459502
/// `Closed`). One place, every teardown. It also gives up the same way if the
460503
/// outbound backlog blows past [`MAX_PENDING_FRAMES`] — a wedged wire that will
461-
/// never drain, so we tear down and let the consumer re-open.
504+
/// never drain, so we tear down and let the consumer re-open — or if the wire
505+
/// goes silent past the watchdog budget (see [`watchdog_deadline`]): a
506+
/// conformant server keeps pinging, so sustained silence, with one watchdog
507+
/// ping of grace, means a half-open link that will never speak again.
462508
async fn run_actor<B, S, E>(
463509
mut inbound: S,
464510
wire_out: mpsc::Sender<B::Request>,
@@ -480,9 +526,23 @@ async fn run_actor<B, S, E>(
480526
// rather than tear everything down. Distinguishes a deliberate half-close
481527
// from the wire/handle-gone breaks, which fall straight through to teardown.
482528
let mut finished = false;
529+
// Silence-watchdog state. A conformant server pings at `keepalive` cadence,
530+
// so a healthy wire always shows *some* inbound frame well inside the
531+
// budget; sustained silence is a half-open link. The cadence starts at the
532+
// XIP-83 fallback and updates when `Started` advertises the real one.
533+
let mut keepalive = Duration::from_millis(DEFAULT_KEEPALIVE_MS.into());
534+
let mut last_inbound = tokio::time::Instant::now();
535+
let mut watchdog_probed = false;
483536

484537
loop {
485538
tokio::select! {
539+
// Polled in order, so teardown is truly last-resort: the watchdog
540+
// arm at the bottom is only reached when no flush, command, or
541+
// inbound frame is ready — a pong landing in the same poll as the
542+
// deadline always wins the tie and restamps the window. (A command
543+
// flood can starve the deadline check, but a silent wire under a
544+
// command flood hits `enqueue`'s give-up cap and tears down there.)
545+
biased;
486546
// Hand one queued frame to the wire the moment it has capacity,
487547
// concurrently with everything else. This is what keeps a busy wire
488548
// from blocking the loop. Gated on a non-empty queue so we only
@@ -576,11 +636,49 @@ async fn run_actor<B, S, E>(
576636
// Consumer-facing frames. The same emit path feeds the live loop
577637
// and the post-`finish` drain, so their event semantics match.
578638
instruction => {
639+
// Adopt the server's advertised cadence for the watchdog as
640+
// `Started` passes through (0 keeps the fallback).
641+
if let Inbound::Emit(Event::Started {
642+
keepalive_interval_ms,
643+
..
644+
}) = &instruction
645+
&& *keepalive_interval_ms > 0
646+
{
647+
keepalive = Duration::from_millis((*keepalive_interval_ms).into());
648+
}
579649
if emit_instruction(&events, instruction).await {
580650
break;
581651
}
582652
}
583653
}
654+
// Stamp *after* the frame is fully handled, not on receipt: the
655+
// emit above can park on consumer backpressure for a long time,
656+
// and that stall is the consumer's, not the wire's — inbound
657+
// frames may be sitting unread behind it. Restarting the window
658+
// when we resume listening keeps a slow consumer from reading
659+
// as wire silence and tearing down a healthy link.
660+
last_inbound = tokio::time::Instant::now();
661+
watchdog_probed = false;
662+
}
663+
// The silence watchdog. Fires only while the arms above are idle
664+
// (enforced by `biased`) — exactly the "nothing inbound" condition
665+
// it exists to detect. One
666+
// unanswered ping stands between silence and teardown, so a quiet
667+
// but live link (a server between keepalives) is never reaped: any
668+
// inbound frame — the pong included — resets the window above.
669+
_ = tokio::time::sleep_until(watchdog_deadline(last_inbound, keepalive, watchdog_probed)) => {
670+
if watchdog_probed {
671+
tracing::warn!(
672+
silent_for_ms = last_inbound.elapsed().as_millis() as u64,
673+
"bidi wire silent past the watchdog budget (probe unanswered); \
674+
tearing down so the consumer re-opens from cursors"
675+
);
676+
break;
677+
}
678+
watchdog_probed = true;
679+
if !enqueue(&wire_out, &mut pending, B::ping_frame(WATCHDOG_NONCE)) {
680+
break;
681+
}
584682
}
585683
}
586684
}
@@ -754,6 +852,15 @@ mod tests {
754852
}
755853
}
756854

855+
/// Probe nonces skip the reserved values across the wrap: the counter
856+
/// steps over [`WATCHDOG_NONCE`] and `0`, then resumes counting from 1.
857+
#[xmtp_common::test]
858+
fn probe_nonces_never_mint_the_watchdog_nonce() {
859+
let counter = AtomicU64::new(u64::MAX - 2);
860+
let minted: Vec<u64> = (0..4).map(|_| next_probe_nonce(&counter)).collect();
861+
assert_eq!(minted, vec![u64::MAX - 1, 1, 2, 3]);
862+
}
863+
757864
/// `finish` is a half-close, not an abort: frames the caller already had
758865
/// accepted under wire backpressure (parked in the actor's `pending` queue,
759866
/// not yet on the wire) must be flushed before the request half closes. Drive
@@ -818,4 +925,184 @@ mod tests {
818925
"the request half must close even when the flush budget expires"
819926
);
820927
}
928+
929+
/// Binding for the watchdog tests. A frame under `100_000` is a `Started`
930+
/// advertising that many milliseconds of keepalive — so each test picks a
931+
/// cadence fast enough to run in real time. [`MSG_FRAME`] delivers one
932+
/// group message (to park the actor on consumer backpressure); anything
933+
/// else is a no-op frame that still counts as inbound activity.
934+
struct WatchdogBinding;
935+
const MSG_FRAME: u64 = 1_000_000;
936+
const ACTIVITY_FRAME: u64 = 2_000_000;
937+
/// Generous ceiling for CI scheduling noise; every wait in these tests
938+
/// resolves in well under a second on a healthy run.
939+
const WAIT: Duration = Duration::from_secs(5);
940+
941+
impl BidiBinding for WatchdogBinding {
942+
type Request = u64;
943+
type Response = u64;
944+
type Mutate = u64;
945+
type GroupMessage = ();
946+
type WelcomeMessage = ();
947+
948+
fn mutate_frame(mutate: u64) -> u64 {
949+
mutate
950+
}
951+
fn ping_frame(nonce: u64) -> u64 {
952+
nonce
953+
}
954+
fn pong_frame(nonce: u64) -> u64 {
955+
nonce
956+
}
957+
fn handle(response: u64) -> Inbound<(), ()> {
958+
match response {
959+
ms if ms < 100_000 => Inbound::Emit(Event::Started {
960+
keepalive_interval_ms: ms as u32,
961+
capabilities: vec![],
962+
}),
963+
MSG_FRAME => Inbound::Messages {
964+
group: vec![()],
965+
welcome: vec![],
966+
},
967+
_ => Inbound::Skip,
968+
}
969+
}
970+
}
971+
972+
struct ActorHarness {
973+
inbound: mpsc::Sender<Result<u64, BidiError>>,
974+
wire: mpsc::Receiver<u64>,
975+
events: mpsc::Receiver<Event<(), ()>>,
976+
_commands: mpsc::Sender<Command<WatchdogBinding>>,
977+
}
978+
979+
/// Spawn `run_actor` over harness-controlled channels, mirroring
980+
/// [`Connection::start`]'s wiring (`poll_fn` stream + `Box::pin`).
981+
fn spawn_actor() -> ActorHarness {
982+
let (inbound_tx, mut inbound_rx) = mpsc::channel::<Result<u64, BidiError>>(2048);
983+
let (wire_out, wire_rx) = mpsc::channel::<u64>(WIRE_BUFFER);
984+
let (commands_tx, commands_rx) = mpsc::channel::<Command<WatchdogBinding>>(COMMAND_BUFFER);
985+
let (events_tx, events_rx) = mpsc::channel::<Event<(), ()>>(EVENT_BUFFER);
986+
let inbound = futures::stream::poll_fn(move |cx| inbound_rx.poll_recv(cx));
987+
xmtp_common::spawn(
988+
None,
989+
run_actor::<WatchdogBinding, _, _>(Box::pin(inbound), wire_out, commands_rx, events_tx),
990+
);
991+
ActorHarness {
992+
inbound: inbound_tx,
993+
wire: wire_rx,
994+
events: events_rx,
995+
_commands: commands_tx,
996+
}
997+
}
998+
999+
/// Total silence earns exactly one watchdog ping, then teardown: the
1000+
/// half-open link surfaces as end-of-events instead of hanging forever.
1001+
#[xmtp_common::test(unwrap_try = true)]
1002+
async fn watchdog_probes_then_tears_down_a_silent_wire() {
1003+
let mut actor = spawn_actor();
1004+
actor.inbound.send(Ok(150)).await?; // Started: 150ms keepalive
1005+
assert!(matches!(
1006+
tokio::time::timeout(WAIT, actor.events.recv()).await?,
1007+
Some(Event::Started { .. })
1008+
));
1009+
1010+
let ping = tokio::time::timeout(WAIT, actor.wire.recv()).await?;
1011+
assert_eq!(
1012+
ping,
1013+
Some(WATCHDOG_NONCE),
1014+
"the watchdog must probe before giving up"
1015+
);
1016+
assert_eq!(
1017+
tokio::time::timeout(WAIT, actor.events.recv()).await?,
1018+
None,
1019+
"an unanswered watchdog ping must tear the connection down"
1020+
);
1021+
}
1022+
1023+
/// A quiet-but-live link is never reaped: any inbound frame — a pong or
1024+
/// otherwise — answers the watchdog ping and resets the window.
1025+
#[xmtp_common::test(unwrap_try = true)]
1026+
async fn an_answered_watchdog_probe_keeps_the_wire_alive() {
1027+
let mut actor = spawn_actor();
1028+
actor.inbound.send(Ok(150)).await?;
1029+
tokio::time::timeout(WAIT, actor.events.recv()).await?;
1030+
1031+
// Ride out three whole silence windows, answering each probe.
1032+
for _ in 0..3 {
1033+
let ping = tokio::time::timeout(WAIT, actor.wire.recv()).await?;
1034+
assert_eq!(ping, Some(WATCHDOG_NONCE));
1035+
actor.inbound.send(Ok(ACTIVITY_FRAME)).await?;
1036+
}
1037+
assert!(
1038+
matches!(
1039+
actor.events.try_recv(),
1040+
Err(mpsc::error::TryRecvError::Empty)
1041+
),
1042+
"an answered probe must keep the connection alive"
1043+
);
1044+
1045+
// Stop answering: the next unanswered ping tears it down.
1046+
assert_eq!(tokio::time::timeout(WAIT, actor.events.recv()).await?, None);
1047+
}
1048+
1049+
/// A steady trickle of frames well inside the probe threshold never
1050+
/// triggers a probe at all.
1051+
#[xmtp_common::test(unwrap_try = true)]
1052+
async fn inbound_activity_resets_the_watchdog() {
1053+
let mut actor = spawn_actor();
1054+
actor.inbound.send(Ok(200)).await?; // probe would fire at 400ms silent
1055+
tokio::time::timeout(WAIT, actor.events.recv()).await?;
1056+
1057+
for _ in 0..20 {
1058+
actor.inbound.send(Ok(ACTIVITY_FRAME)).await?;
1059+
tokio::time::sleep(Duration::from_millis(50)).await;
1060+
}
1061+
assert!(
1062+
matches!(actor.wire.try_recv(), Err(mpsc::error::TryRecvError::Empty)),
1063+
"no watchdog ping may fire while frames keep arriving"
1064+
);
1065+
assert!(matches!(
1066+
actor.events.try_recv(),
1067+
Err(mpsc::error::TryRecvError::Empty)
1068+
));
1069+
}
1070+
1071+
/// Consumer backpressure must not read as wire silence: while the actor is
1072+
/// parked emitting into a full event channel, inbound frames sit unread
1073+
/// behind it — the stall is the consumer's, not the wire's. The silence
1074+
/// window restarts when the actor resumes listening, so a slow consumer
1075+
/// never gets a healthy link torn down.
1076+
#[xmtp_common::test(unwrap_try = true)]
1077+
async fn consumer_backpressure_is_not_wire_silence() {
1078+
let mut actor = spawn_actor();
1079+
actor.inbound.send(Ok(200)).await?; // probe at 400ms, teardown at 600ms
1080+
tokio::time::timeout(WAIT, actor.events.recv()).await?;
1081+
1082+
// Fill the event buffer plus one: the actor parks mid-emit.
1083+
for _ in 0..(EVENT_BUFFER + 1) {
1084+
actor.inbound.send(Ok(MSG_FRAME)).await?;
1085+
}
1086+
// Hold the park well past the full teardown budget, draining nothing.
1087+
tokio::time::sleep(Duration::from_millis(800)).await;
1088+
1089+
// Drain; the actor unparks with a fresh silence window. If the park
1090+
// had counted as silence, both watchdog deadlines are long past and
1091+
// teardown would be immediate.
1092+
let mut delivered = 0;
1093+
while delivered < EVENT_BUFFER + 1 {
1094+
match tokio::time::timeout(WAIT, actor.events.recv()).await? {
1095+
Some(Event::GroupMessages(_)) => delivered += 1,
1096+
other => panic!("unexpected event while draining: {other:?}"),
1097+
}
1098+
}
1099+
tokio::time::sleep(Duration::from_millis(150)).await;
1100+
assert!(
1101+
matches!(
1102+
actor.events.try_recv(),
1103+
Err(mpsc::error::TryRecvError::Empty)
1104+
),
1105+
"a consumer stall must not be read as wire silence"
1106+
);
1107+
}
8211108
}

0 commit comments

Comments
 (0)