From a8ba9439712ca19454dc440f3ad54f166421b144 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Mon, 5 Oct 2026 22:32:16 +0500 Subject: [PATCH] fix(desktop): detect stale control independently of live video --- crates/rds-cli/src/desktop.rs | 40 ++- crates/rds-cli/src/desktop/control.rs | 150 ++++++++++- crates/rds-cli/src/desktop/liveness.rs | 246 ++++++++++++++++++ docs/architecture.md | 6 + docs/native-viewer.md | 20 ++ docs/reports/rds-control-liveness-20261005.md | 23 ++ docs/research.md | 32 +++ 7 files changed, 506 insertions(+), 11 deletions(-) create mode 100644 crates/rds-cli/src/desktop/liveness.rs create mode 100644 docs/reports/rds-control-liveness-20261005.md diff --git a/crates/rds-cli/src/desktop.rs b/crates/rds-cli/src/desktop.rs index 9e2f4db..808eb5f 100644 --- a/crates/rds-cli/src/desktop.rs +++ b/crates/rds-cli/src/desktop.rs @@ -93,6 +93,9 @@ pub async fn read_grant( #[cfg(feature = "desktop")] mod control; +#[cfg(feature = "desktop")] +mod liveness; + #[cfg(feature = "desktop")] mod diagnostics; @@ -417,6 +420,7 @@ mod native { view.status("Waiting for screen"); let control = channel.control_handle(); let (progress, last_frame) = tokio::sync::watch::channel(tokio::time::Instant::now()); + let control_progress = liveness::ControlWatchdog::default(); // Keep the entire receive/decode future alive while controls progress. // Selecting individual recv calls and awaiting decode in their handler // prevents input, heartbeat and close from being polled during decode. @@ -462,10 +466,16 @@ mod native { let event_observation = async { loop { match events.recv().await.transpose()? { - Some(rds_core::DesktopEvent::Heartbeat { ts_ms, .. }) => view - .control_rtt((started.elapsed().as_millis() as u64).saturating_sub(ts_ms)), + Some(rds_core::DesktopEvent::Heartbeat { seq, ts_ms }) => { + if control_progress.echoed(seq, ts_ms) { + view.control_rtt( + (started.elapsed().as_millis() as u64).saturating_sub(ts_ms), + ); + } + } Some(rds_core::DesktopEvent::InputAck { seq, .. }) => view.input_ack(seq), Some(rds_core::DesktopEvent::ClipboardReady { id, bytes }) => { + control_progress.clipboard_ready(id, bytes); view.clipboard_ack(id, bytes); } None => { @@ -481,9 +491,16 @@ mod native { result = media => result, } }; - let controls = control::pump(input, &control, &last_frame, started, |message| { - view.input_sent(message); - }); + let controls = control::pump_with_liveness( + input, + &control, + &last_frame, + &control_progress, + started, + |message| { + view.input_sent(message); + }, + ); let result = control::run(controls, incoming, stop).await; // A winning leg may cancel a partially written control on the other // leg. EOF closes the manager's desktop; never append Finished to a @@ -529,6 +546,7 @@ mod native { let ctrl = session.control_sender(); let mut last_frame = tokio::time::Instant::now(); let mut watchdog = control::VideoWatchdog::default(); + let control_progress = liveness::ControlWatchdog::default(); let mut tick = tokio::time::interval(Duration::from_secs(1)); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let result = loop { @@ -537,11 +555,13 @@ mod native { message = input.recv() => match message { Some(ViewerInput::Control(message)) => { view.input_sent(&message); + control_progress.sent(&message); tokio::time::timeout(Duration::from_secs(2),ctrl.send(message)).await.map_err(|_|anyhow::anyhow!("direct desktop control stalled"))??; }, Some(ViewerInput::Close)|None => break Ok(true), }, _ = tick.tick() => { + control_progress.check()?; match watchdog.observe(last_frame) { control::VideoAction::Reconnect => anyhow::bail!("remote video stopped making progress"), control::VideoAction::Repair => { @@ -550,12 +570,16 @@ mod native { }, control::VideoAction::Healthy => {}, } - tokio::time::timeout(Duration::from_secs(2),ctrl.send(rds_core::DesktopControl::Heartbeat { seq: 0,ts_ms: started.elapsed().as_millis() as u64 })).await.map_err(|_|anyhow::anyhow!("direct desktop heartbeat stalled"))??; + let heartbeat = control_progress.heartbeat(started.elapsed().as_millis() as u64)?; + control_progress.sent(&heartbeat); + tokio::time::timeout(Duration::from_secs(2),ctrl.send(heartbeat)).await.map_err(|_|anyhow::anyhow!("direct desktop heartbeat stalled"))??; }, event = session.events.recv() => match event { - Some(rds_core::DesktopEvent::Heartbeat { ts_ms,.. }) => view.control_rtt((started.elapsed().as_millis() as u64).saturating_sub(ts_ms)), + Some(rds_core::DesktopEvent::Heartbeat { seq, ts_ms }) => { + if control_progress.echoed(seq, ts_ms) { view.control_rtt((started.elapsed().as_millis() as u64).saturating_sub(ts_ms)); } + }, Some(rds_core::DesktopEvent::InputAck { seq,.. }) => view.input_ack(seq), - Some(rds_core::DesktopEvent::ClipboardReady { id, bytes }) => {view.clipboard_ack(id, bytes);}, + Some(rds_core::DesktopEvent::ClipboardReady { id, bytes }) => {control_progress.clipboard_ready(id, bytes);view.clipboard_ack(id, bytes);}, None => break Ok(false), }, frame = session.frames.recv() => match frame { diff --git a/crates/rds-cli/src/desktop/control.rs b/crates/rds-cli/src/desktop/control.rs index a0d0e91..d29bebb 100644 --- a/crates/rds-cli/src/desktop/control.rs +++ b/crates/rds-cli/src/desktop/control.rs @@ -79,10 +79,44 @@ async fn send_control( Ok(()) } -pub(super) async fn pump( +#[cfg(test)] +async fn pump( + input: &mut impl Input, + sender: &impl Sender, + progress: &watch::Receiver, + started: Instant, + sent: impl Fn(&DesktopControl), +) -> anyhow::Result { + pump_inner(input, sender, progress, None, started, sent).await +} + +pub(super) async fn pump_with_liveness( input: &mut impl Input, sender: &impl Sender, progress: &watch::Receiver, + control_progress: &super::liveness::ControlWatchdog, + started: Instant, + sent: impl Fn(&DesktopControl), +) -> anyhow::Result { + pump_inner( + input, + sender, + progress, + Some(control_progress), + started, + |message| { + control_progress.sent(message); + sent(message); + }, + ) + .await +} + +async fn pump_inner( + input: &mut impl Input, + sender: &impl Sender, + progress: &watch::Receiver, + control_progress: Option<&super::liveness::ControlWatchdog>, started: Instant, sent: impl Fn(&DesktopControl), ) -> anyhow::Result { @@ -93,6 +127,7 @@ pub(super) async fn pump( let message = tokio::select! { biased; _ = tick.tick() => { + if let Some(monitor) = control_progress { monitor.check()?; } let progress = *progress.borrow(); match watchdog.observe(progress) { VideoAction::Reconnect => { @@ -107,8 +142,10 @@ pub(super) async fn pump( }, VideoAction::Healthy => {}, } - DesktopControl::Heartbeat { - seq: 0, ts_ms: started.elapsed().as_millis() as u64, + let ts_ms = started.elapsed().as_millis() as u64; + match control_progress { + Some(monitor) => monitor.heartbeat(ts_ms)?, + None => DesktopControl::Heartbeat { seq:0, ts_ms }, } }, message = input.recv() => match message { @@ -169,6 +206,113 @@ mod tests { } } + #[tokio::test(start_paused = true)] + async fn fresh_video_cannot_mask_unacknowledged_control_and_cancellation_drops_media() { + let (wire, mut remote) = tokio::io::duplex(256); + let (tx, mut input) = mpsc::channel(8); + let (progress, last_frame) = watch::channel(tokio::time::Instant::now()); + let stop = CancellationToken::new(); + let dropped = Arc::new(AtomicBool::new(false)); + let held = dropped.clone(); + let mut task = tokio::spawn(async move { + let monitor = super::super::liveness::ControlWatchdog::default(); + let media = async { + let _drop = Dropped(held); + std::future::pending().await + }; + run( + pump_with_liveness( + &mut input, + &Wire(Mutex::new(wire)), + &last_frame, + &monitor, + Instant::now(), + |_| {}, + ), + media, + &stop, + ) + .await + }); + tx.send(ViewerInput::Control(DesktopControl::Input(InputEvent { + seq: 37, + event_ts_ms: 0, + display_id: 0, + kind: InputKind::KeyDown { code: 56 }, + }))) + .await + .unwrap(); + let started = tokio::time::Instant::now(); + let mut inputs = 0; + let error = loop { + tokio::select! { + result = &mut task => break result.unwrap().unwrap_err(), + message = rds_net::read_frame::<_,DesktopUp>(&mut remote) => { + let message = match message { + Ok(DesktopUp::Control(message)) => message, + Err(error) => { + assert_eq!(error.kind(),std::io::ErrorKind::UnexpectedEof); + break (&mut task).await.unwrap().unwrap_err(); + }, + _ => panic!("unexpected framing"), + }; + match message { + DesktopControl::Heartbeat {..} => { progress.send_replace(tokio::time::Instant::now()); }, + DesktopControl::Input(event) => { assert_eq!(event.seq,37);inputs+=1; }, + DesktopControl::RequestIdr => panic!("fresh video does not need repair"), + _ => panic!("unexpected control"), + } + } + } + }; + assert!( + error + .to_string() + .contains("control stopped making progress") + ); + assert_eq!(started.elapsed(), Duration::from_secs(8)); + assert_eq!(inputs, 1, "no input replay while stalled"); + assert!(dropped.load(Ordering::SeqCst)); + } + + #[tokio::test(start_paused = true)] + async fn matched_control_echoes_preserve_the_healthy_session_during_continuous_media() { + let (wire, mut remote) = tokio::io::duplex(256); + let (tx, mut input) = mpsc::channel(8); + let (progress, last_frame) = watch::channel(tokio::time::Instant::now()); + let monitor = super::super::liveness::ControlWatchdog::default(); + let sending = monitor.clone(); + let task = tokio::spawn(async move { + pump_with_liveness( + &mut input, + &Wire(Mutex::new(wire)), + &last_frame, + &sending, + Instant::now(), + |_| {}, + ) + .await + }); + let started = tokio::time::Instant::now(); + let mut previous = None; + while started.elapsed() < Duration::from_secs(60) { + let DesktopUp::Control(DesktopControl::Heartbeat { seq, ts_ms }) = + rds_net::read_frame(&mut remote).await.unwrap() + else { + panic!("unexpected heartbeat framing"); + }; + if let Some(n) = previous { + assert_eq!(seq, n + 1); + } + previous = Some(seq); + assert!(monitor.echoed(seq, ts_ms)); + progress.send_replace(tokio::time::Instant::now()); + assert!(!task.is_finished()); + } + tx.send(ViewerInput::Close).await.unwrap(); + assert!(task.await.unwrap().unwrap()); + } + #[tokio::test(start_paused = true)] async fn video_watchdog_repairs_before_reconnect_and_rearms_only_on_frames() { let first = tokio::time::Instant::now(); diff --git a/crates/rds-cli/src/desktop/liveness.rs b/crates/rds-cli/src/desktop/liveness.rs new file mode 100644 index 0000000..f59cebc --- /dev/null +++ b/crates/rds-cli/src/desktop/liveness.rs @@ -0,0 +1,246 @@ +//! Acknowledged control progress is independent of video and local writes. +use rds_core::DesktopControl; +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::time::Instant; + +const CONTROL_STALL: Duration = Duration::from_secs(8); +const MAX_PROBES: usize = 16; +const CLIPBOARD_GRACE: Duration = Duration::from_secs(30); + +struct Clipboard { + id: u64, + total: u32, + deadline: Instant, + last_sent: Instant, + finished: bool, +} + +struct State { + next: u64, + confirmed: Instant, + pending: VecDeque<(u64, u64, Instant)>, + clipboard: Option, +} + +#[derive(Clone)] +pub(super) struct ControlWatchdog(Arc>); + +impl Default for ControlWatchdog { + fn default() -> Self { + Self(Arc::new(Mutex::new(State { + next: 0, + confirmed: Instant::now(), + pending: VecDeque::new(), + clipboard: None, + }))) + } +} + +impl ControlWatchdog { + pub(super) fn heartbeat(&self, ts_ms: u64) -> anyhow::Result { + let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let seq = state.next; + state.next = seq + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("desktop heartbeat sequence exhausted"))?; + Ok(DesktopControl::Heartbeat { seq, ts_ms }) + } + + pub(super) fn sent(&self, message: &DesktopControl) { + let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner()); + if let DesktopControl::ClipboardChunk { + id, + offset, + total, + data, + } = message + { + let end = u64::from(*offset).saturating_add(data.len() as u64); + if *total > 1024 * 1024 || end > u64::from(*total) { + return; + } + if *offset == 0 { + let deadline = state + .clipboard + .as_ref() + .map_or_else(|| Instant::now() + CLIPBOARD_GRACE, |c| c.deadline); + state.clipboard = Some(Clipboard { + id: *id, + total: *total, + deadline, + last_sent: Instant::now(), + finished: false, + }); + } + if let Some(c) = &mut state.clipboard + && c.id == *id + && c.total == *total + { + c.last_sent = Instant::now(); + c.finished = end == u64::from(*total); + } + return; + } + let DesktopControl::Heartbeat { seq, ts_ms } = *message else { + return; + }; + state.pending.retain(|(s, t, _)| (*s, *t) != (seq, ts_ms)); + if state.pending.len() == MAX_PROBES { + state.pending.pop_front(); + } + state.pending.push_back((seq, ts_ms, Instant::now())); + } + + pub(super) fn clipboard_ready(&self, id: u64, bytes: u32) { + let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(c) = &state.clipboard + && c.id == id + && c.total == bytes + && c.finished + { + state.confirmed = state.confirmed.max(c.last_sent); + state.clipboard = None; + } + } + + /// Only an exact outstanding probe is evidence. Confirmation advances to + /// send time, so a very late echo cannot make old control look current. + pub(super) fn echoed(&self, seq: u64, ts_ms: u64) -> bool { + let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let Some(index) = state + .pending + .iter() + .position(|(s, t, _)| (*s, *t) == (seq, ts_ms)) + else { + return false; + }; + if let Some((_, _, sent)) = state.pending.remove(index) { + state.confirmed = state.confirmed.max(sent); + true + } else { + false + } + } + + pub(super) fn check(&self) -> anyhow::Result<()> { + let state = self.0.lock().unwrap_or_else(|p| p.into_inner()); + let age = state.confirmed.elapsed(); + let publishing = state + .clipboard + .as_ref() + .is_some_and(|c| Instant::now() < c.deadline); + if age >= CONTROL_STALL && !publishing { + tracing::warn!( + last_confirmed_probe_age_ms = age.as_millis() as u64, + pending_probes = state.pending.len(), + "desktop control progress watchdog expired" + ); + anyhow::bail!("remote control stopped making progress"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(start_paused = true)] + async fn late_wrong_duplicate_and_retired_echoes_cannot_mask_stalled_control() { + let monitor = ControlWatchdog::default(); + let first = monitor.heartbeat(42).unwrap(); + monitor.sent(&first); + assert!(!monitor.echoed(0, 43)); + tokio::time::advance(CONTROL_STALL).await; + assert!(monitor.echoed(0, 42)); + assert!( + monitor.check().is_err(), + "late reply must retain its original send age" + ); + assert!(!monitor.echoed(0, 42), "duplicate is not new progress"); + for _ in 0..MAX_PROBES + 1 { + let message = monitor.heartbeat(42).unwrap(); + monitor.sent(&message); + } + assert_eq!(monitor.0.lock().unwrap().pending.len(), MAX_PROBES); + assert!( + !monitor.echoed(1, 42), + "evicted probe is not current evidence" + ); + assert!(monitor.echoed((MAX_PROBES + 1) as u64, 42)); + assert!(monitor.check().is_ok()); + } + + #[tokio::test(start_paused = true)] + async fn timely_confirmed_controls_rearm_without_rewinding_or_clock_collision() { + let monitor = ControlWatchdog::default(); + let first = monitor.heartbeat(0).unwrap(); + monitor.sent(&first); + tokio::time::advance(Duration::from_secs(1)).await; + let second = monitor.heartbeat(0).unwrap(); + monitor.sent(&second); + assert!(monitor.echoed(1, 0)); + tokio::time::advance(Duration::from_secs(6)).await; + assert!(monitor.echoed(0, 0)); + assert!( + monitor.check().is_ok(), + "older matched echo must not rewind confirmation" + ); + tokio::time::advance(Duration::from_secs(2)).await; + assert!(monitor.check().is_err()); + monitor.0.lock().unwrap().next = u64::MAX; + assert!(monitor.heartbeat(0).is_err()); + } + + #[tokio::test(start_paused = true)] + async fn clipboard_keeps_existing_absolute_budget_and_exact_ready_confirmation() { + let monitor = ControlWatchdog::default(); + monitor.sent(&DesktopControl::ClipboardChunk { + id: 7, + offset: 0, + total: 4, + data: vec![0; 2], + }); + tokio::time::advance(Duration::from_secs(9)).await; + assert!(monitor.check().is_ok()); + monitor.clipboard_ready(7, 4); // Incomplete transfer cannot confirm anything. + monitor.sent(&DesktopControl::ClipboardChunk { + id: 7, + offset: 2, + total: 4, + data: vec![0; 2], + }); + monitor.clipboard_ready(8, 4); + monitor.clipboard_ready(7, 3); + assert!(monitor.0.lock().unwrap().clipboard.is_some()); + monitor.clipboard_ready(7, 4); + assert!(monitor.0.lock().unwrap().clipboard.is_none()); + assert!(monitor.check().is_ok()); + tokio::time::advance(Duration::from_secs(8)).await; + assert!(monitor.check().is_err()); + let monitor = ControlWatchdog::default(); + monitor.sent(&DesktopControl::ClipboardChunk { + id: 1, + offset: 0, + total: 4, + data: vec![0; 2], + }); + tokio::time::advance(Duration::from_secs(29)).await; + monitor.sent(&DesktopControl::ClipboardChunk { + id: 2, + offset: 0, + total: 4, + data: vec![0; 2], + }); + assert!(monitor.check().is_ok()); + tokio::time::advance(Duration::from_secs(1)).await; + assert!( + monitor.check().is_err(), + "new starts cannot extend the absolute grace indefinitely" + ); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 46a8a28..8b2c824 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,6 +105,12 @@ input ACK/heartbeat observation. Both sockets share desktop termination, without closing the manager's unrelated peer streams. Existing combined APIs and remote wire formats remain; see [the native contract](native-viewer.md). +Native control liveness is independent of decoded-frame progress. Exact bounded +heartbeat confirmations advance per-session monotonic send-time evidence; +late/duplicate replies cannot mask stalled control with a live picture. The +viewer retains clipboard's absolute budget and ends only the owning desktop +attempt, without replay or managed peer teardown. See [native control progress](native-viewer.md#independent-control-progress). + Explicit video repair advances a session-local media epoch and interrupts only obsolete media work. An independent-picture lease holds admission until receipt; repeated requests coalesce during that recovery. X11 keyboard holds similarly diff --git a/docs/native-viewer.md b/docs/native-viewer.md index f06958b..ae91bd3 100644 --- a/docs/native-viewer.md +++ b/docs/native-viewer.md @@ -704,3 +704,23 @@ no-replace. Failed publication removes only its temporary inode. Neither boundary claims fsync/power-loss durability. Bounded file retention and the log queue can still lose records; health counts make that uncertainty visible. Content, key identity, clipboard payload and typed characters are not added. + + +### Independent control progress + +A picture or successful local control write does not prove current remote input +handling. Managed and direct native sessions now require an exact outstanding +heartbeat confirmation independent of decoded video. Probes have distinct, +checked per-session sequence numbers and opaque timestamp matching;16entries +bound tracking. Confirmation advances to the matched probe's monotonic send +time, not the time a stale response arrived. Older responses cannot rewind it. + +The native control tick observes an8-second ordinary progress budget with its +own initial grace. A live clipboard transfer keeps the existing30-second +absolute allowance until exact completed ID/size confirmation; repeated starts +do not extend that unconfirmed allowance. Write/decode/media deadlines remain +separate. A control expiry ends the owning desktop attempt without replaying +input or paste; managed sessions retain the authenticated peer and unrelated +streams. This does not guarantee8-second recovery when networking is unavailable +or revoke already applied input. The existing no-replay/canceled-ACK diagnostics +remain essential to interpreting uncertain delivery. diff --git a/docs/reports/rds-control-liveness-20261005.md b/docs/reports/rds-control-liveness-20261005.md new file mode 100644 index 0000000..677855b --- /dev/null +++ b/docs/reports/rds-control-liveness-20261005.md @@ -0,0 +1,23 @@ +# Independent native control liveness — 2026-10-05 + +This W6.7/W2.6 increment closes a native recovery gap: decoded video previously +rearmed the only progress watchdog even when control had no current heartbeat +confirmation. A successful write only supplies local writer evidence. + +Implemented:16exact outstanding heartbeat tuples, checked per-session sequence, +monotonic send-time confirmation,8-second ordinary progress threshold and the +existing30-second absolute clipboard allowance. Late/unmatched/duplicate/retired +responses do not fabricate current control progress. Both native entry paths +use the same monitor. Managed teardown remains desktop-scoped; unrelated peer +streams and server processes remain outside it. No replay or wire change. + +27 CLI desktop library tests pass. A real bounded Tokio pipe regression keeps +video progress fresh and successful control writes flowing without echoes: +control expires at8seconds, one input arrives once, and cancellation drops the +held media leg. A60-second matched-echo pipe retains the existing session. +Tests cover tuple matching, stale/out-of-order response, bounded retirement, +sequence exhaustion and exact clipboard completion/absolute grace. An initial +new pipe test raced EOF against the completed task; it now explicitly verifies +EOF then awaits the actual control failure. Strict expanded workspace clippy +passes. Formatting, ordinary/full platform CI and installed measurements must +be recorded independently before acceptance. No wave-close gate is claimed. diff --git a/docs/research.md b/docs/research.md index fd737c9..409802f 100644 --- a/docs/research.md +++ b/docs/research.md @@ -1,5 +1,37 @@ # Deep research: remote + sync, all-Rust, minimum latency +## 2026-10-05 independent control liveness + +QUIC delivers bytes in order within a stream; progress on another stream does +not establish progress of an earlier control record ([RFC9000§2.2](https://www.rfc-editor.org/rfc/rfc9000.html#section-2.2)). +The [Noq write/cancellation contract](https://docs.rs/noq/1.3.0/noq/struct.SendStream.html) +and its `stopped` documentation distinguish writable/transport state from +application processing. RDS's short write deadline therefore cannot substitute +for an application heartbeat confirmation. + +The native viewer now tracks at most16outstanding exact `(seq,timestamp)` probes +per desktop session. Monotonic send time establishes acknowledged progress; +very late, duplicate, unmatched or retired replies cannot make old control +look current. Fresh video never rearms control progress. The ordinary budget is +8seconds, observed at the next one-second control tick; writes retain their +separate2-second bound. Scheduling and reopening are not hard real-time promises. +Clipboard keeps its existing30-second absolute transfer budget with one bounded +metadata entry. Only exact ID/size confirmation after the final chunk advances +that entry; repeated starts cannot extend the first unconfirmed grace forever. + +Both native managed/direct paths apply the policy. Managed failure drops only +the desktop's two IPC legs and retains the manager's peer/unrelated streams. +Direct mode keeps its existing owned-connection cleanup. No input/paste replay, +remote wire change, endpoint replacement, bitrate/path default or server restart +is added. This improves a proved missing-control-watchdog boundary, not proof of +the root cause of every installed network outage. + +[Upstream Iroh4424](https://github.com/n0-computer/iroh/issues/4424) describes +periodic direct-path loss attributed by its reporter to absolute CGNAT mapping +expiry despite heartbeats. It is a useful analogous report on different versions +and networks, not evidence identifying this installation's provider or cause. +Do not blindly shorten transport idle policy or change VPN/routing from it. + ## 2026-10-05 relay loss and evidence preservation [Iroh1.2 documentation](https://docs.rs/iroh/1.2.0/iroh/#relay-servers)