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`]).
4653pub ( 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.
462508async 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