feat(core): mid-stream clock re-sync — live offset survives wall-clock steps and drift

Networking-audit deferred plan §2. The host↔client offset was measured once
at connect; an NTP step or slow drift silently corrupted the clock-based
jump-to-live signal, the ABR one-way-delay signal, and every latency stat —
4a3b1ae2's disarm backstop stopped the IDR storm but lost the detector for
the session. Now the client re-estimates mid-stream and recovers it.

- quic: ClockResync — the connect-time 8-round probe/echo estimate as a
  select!-driven state machine (rounds matched by echoed t1, stale batches
  ignored), plus accept_resync (batch min-RTT ≤ max(2 ms, 1.5× connect RTT)
  so a congested window can never bias the offset). No wire change: the
  host has always answered ClockProbe at any time on the control stream.
- client: the offset lives in an Arc<AtomicI64> seeded at connect; the
  control task re-probes every 60 s and immediately after the pump's FIRST
  no-op clock flush (the "clock stepped under me" signal, sent on the next
  report tick). On apply: store, reset stale_frames/noop_clock_flushes,
  re-arm the clock detector if a step had disarmed it. The disarm heuristic
  stays as the final backstop. Public NativeClient::clock_offset_ns keeps
  the connect-time value (ABI untouched); new clock_offset_now_ns() /
  clock_offset_shared() expose the live value.
- consumers migrated to the live offset: pf-client-core session stats, the
  pf-presenter e2e stamp, Windows session/render, Android feeder/drain/
  DisplayTracker (the tracker holds the shared handle, not the client, so
  the leaked render-callback refcount can't pin the session).
- probe: --clock-resync runs a second full handshake mid-connection and
  asserts a sane, consistent estimate. Live against the local canary host:
  offsets 8646/2139 ns, disagreement 6 µs, 8/8 rounds — OK.

Unit tests cover the round collection, stale-echo rejection, batch restart,
min-RTT selection, and the acceptance guard. cargo ndk check green.
Remaining manual validation: `sudo date -s "+2 sec"` on a live streaming
client → expect one no-op flush, a re-sync, re-armed detector, no IDR pulse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:43:43 +02:00
parent 68a863866a
commit d4467a44e2
10 changed files with 436 additions and 59 deletions
+13 -6
View File
@@ -12,7 +12,7 @@
use crate::present::Presenter;
use crate::session::{FrameRx, FrameTimes};
use crossbeam_channel::RecvTimeoutError;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -122,12 +122,13 @@ unsafe impl Send for SendPresenter {}
/// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our
/// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid
/// (same math as the pump's host+network stage).
/// (same math as the pump's host+network stage). A live handle (loaded per present) so
/// mid-stream clock re-syncs keep the number honest after an NTP step / drift.
pub fn spawn(
presenter: Presenter,
frames: FrameRx,
shared: Arc<RenderShared>,
clock_offset_ns: i64,
clock_offset_ns: Arc<AtomicI64>,
) -> RenderThread {
let boxed = SendPresenter(presenter);
let shared_w = shared.clone();
@@ -162,7 +163,12 @@ fn poll_window_dpi() -> Option<u32> {
}
}
fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clock_offset_ns: i64) {
fn run(
presenter: SendPresenter,
frames: FrameRx,
shared: Arc<RenderShared>,
clock_offset_ns: Arc<AtomicI64>,
) {
let mut p = presenter.0;
let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter
let mut presented = 0u32;
@@ -232,8 +238,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clo
let displayed_ns = now_ns();
// End-to-end = capture → displayed, host-clock corrected, measured directly
// (never the sum of stage percentiles). Clamped (0, 10 s).
let e2e =
(displayed_ns as i128 + clock_offset_ns as i128 - t.pts_ns as i128).max(0) as u64;
let e2e = (displayed_ns as i128 + clock_offset_ns.load(Ordering::Relaxed) as i128
- t.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 {
e2e_us.push(e2e / 1000);
}