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:
@@ -18,7 +18,7 @@ use punktfunk_core::error::PunktfunkError;
|
||||
use punktfunk_core::session::Frame;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -213,12 +213,12 @@ fn run_sync(
|
||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||
// HUD flags it "(same-host clock)").
|
||||
let clock_offset = client.clock_offset_ns;
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
// Display stage (spec `display` + the capture→displayed headline): frames released with
|
||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||
@@ -256,6 +256,7 @@ fn run_sync(
|
||||
// the output buffer) for the decoded-point pairing in `drain`.
|
||||
if stats.enabled() {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||
.then_some((lat_ns / 1000) as u64);
|
||||
@@ -320,7 +321,7 @@ fn run_sync(
|
||||
wait,
|
||||
&stats,
|
||||
&mut in_flight,
|
||||
clock_offset,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
);
|
||||
rendered += r;
|
||||
@@ -418,8 +419,10 @@ fn now_monotonic_ns() -> i128 {
|
||||
/// endpoint whenever the platform delivers render callbacks).
|
||||
struct DisplayTracker {
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
/// Host-minus-client clock offset (ns) for the skew-corrected end-to-end sample.
|
||||
clock_offset: i64,
|
||||
/// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample —
|
||||
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
||||
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||
/// callback early-outs) while the overlay is hidden.
|
||||
@@ -427,7 +430,10 @@ struct DisplayTracker {
|
||||
}
|
||||
|
||||
impl DisplayTracker {
|
||||
fn new(stats: Arc<crate::stats::VideoStats>, clock_offset: i64) -> Arc<DisplayTracker> {
|
||||
fn new(
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
) -> Arc<DisplayTracker> {
|
||||
Arc::new(DisplayTracker {
|
||||
stats,
|
||||
clock_offset,
|
||||
@@ -554,7 +560,8 @@ unsafe extern "C" fn on_frame_rendered(
|
||||
}
|
||||
}
|
||||
}
|
||||
let e2e_ns = displayed_ns + t.clock_offset as i128 - pts_us as i128 * 1000;
|
||||
let e2e_ns = displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128
|
||||
- pts_us as i128 * 1000;
|
||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||
t.stats.note_displayed(e2e_us, display_us);
|
||||
@@ -827,13 +834,13 @@ fn run_async(
|
||||
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
|
||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||
// HUD is visible.
|
||||
let clock_offset = client.clock_offset_ns;
|
||||
let clock_offset = client.clock_offset_shared();
|
||||
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||
// reclaimed after the codec is dropped below.
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
|
||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||
let render_cb = install_render_callback(&codec, &tracker);
|
||||
|
||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||
@@ -842,6 +849,7 @@ fn run_async(
|
||||
let client = client.clone();
|
||||
let stats = stats.clone();
|
||||
let in_flight = in_flight.clone();
|
||||
let clock_offset = clock_offset.clone();
|
||||
let shutdown = shutdown.clone();
|
||||
let ev_tx = ev_tx.clone();
|
||||
std::thread::Builder::new()
|
||||
@@ -851,7 +859,7 @@ fn run_async(
|
||||
client,
|
||||
stats,
|
||||
in_flight,
|
||||
clock_offset as i128,
|
||||
clock_offset,
|
||||
shutdown,
|
||||
ev_tx,
|
||||
);
|
||||
@@ -929,7 +937,7 @@ fn run_async(
|
||||
&mut ready,
|
||||
&stats,
|
||||
&in_flight,
|
||||
clock_offset,
|
||||
clock_offset.load(Ordering::Relaxed),
|
||||
&tracker,
|
||||
&mut rendered,
|
||||
&mut discarded,
|
||||
@@ -999,7 +1007,7 @@ fn feeder_loop(
|
||||
client: Arc<NativeClient>,
|
||||
stats: Arc<crate::stats::VideoStats>,
|
||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||
clock_offset: i128,
|
||||
clock_offset: Arc<AtomicI64>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
ev_tx: mpsc::Sender<DecodeEvent>,
|
||||
) {
|
||||
@@ -1010,6 +1018,7 @@ fn feeder_loop(
|
||||
Ok(frame) => {
|
||||
if stats.enabled() {
|
||||
let received_ns = now_realtime_ns();
|
||||
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||
let lat_us =
|
||||
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
||||
|
||||
@@ -111,6 +111,11 @@ struct Args {
|
||||
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
||||
/// seconds (default 4), print what's found, and exit. No connection is made.
|
||||
discover: Option<u64>,
|
||||
/// `--clock-resync` — after the connect-time skew handshake, immediately run a SECOND
|
||||
/// handshake on the same control stream and assert both estimates are sane and consistent:
|
||||
/// the headless validator for the host answering `ClockProbe` at any time (what the native
|
||||
/// clients' mid-stream re-sync relies on). Aborts the session when the re-probe fails.
|
||||
clock_resync: bool,
|
||||
}
|
||||
|
||||
fn parse_mode(m: &str) -> Option<Mode> {
|
||||
@@ -274,6 +279,7 @@ fn parse_args() -> Args {
|
||||
.iter()
|
||||
.any(|a| a == "--discover")
|
||||
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
|
||||
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +529,8 @@ async fn session(args: Args) -> Result<()> {
|
||||
// Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test
|
||||
// take it): align our clock to the host's so the per-frame capture→received latency is valid
|
||||
// across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0).
|
||||
let clock_offset_ns = match punktfunk_core::quic::clock_sync(&mut send, &mut recv).await {
|
||||
let first_skew = punktfunk_core::quic::clock_sync(&mut send, &mut recv).await;
|
||||
let clock_offset_ns = match &first_skew {
|
||||
Some(skew) => {
|
||||
tracing::info!(
|
||||
offset_ns = skew.offset_ns,
|
||||
@@ -536,6 +543,39 @@ async fn session(args: Args) -> Result<()> {
|
||||
None => None,
|
||||
};
|
||||
|
||||
// `--clock-resync`: prove the host answers `ClockProbe` mid-session, not just at connect —
|
||||
// the contract the native clients' mid-stream re-sync rests on. Run a full second handshake
|
||||
// and require a sane, consistent estimate: both batches measure the same physical skew, so
|
||||
// they must agree to within RTT-scale error (the handshake's own uncertainty is ≈ RTT/2).
|
||||
if args.clock_resync {
|
||||
let first = first_skew
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("clock-resync: host never answered the connect-time handshake"))?;
|
||||
let second = punktfunk_core::quic::clock_sync(&mut send, &mut recv)
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("clock-resync: host did not answer the re-probe"))?;
|
||||
let disagree_ns = (second.offset_ns - first.offset_ns).unsigned_abs();
|
||||
let bound_ns = (first.rtt_ns + second.rtt_ns).max(2_000_000);
|
||||
tracing::info!(
|
||||
first_offset_ns = first.offset_ns,
|
||||
second_offset_ns = second.offset_ns,
|
||||
disagree_us = disagree_ns / 1000,
|
||||
bound_us = bound_ns / 1000,
|
||||
second_rtt_us = second.rtt_ns / 1000,
|
||||
rounds = second.rounds,
|
||||
"clock re-probe answered"
|
||||
);
|
||||
if second.rounds < 8 || disagree_ns > bound_ns {
|
||||
return Err(anyhow!(
|
||||
"clock-resync: re-probe unsound (rounds {}, disagreement {} µs > bound {} µs)",
|
||||
second.rounds,
|
||||
disagree_ns / 1000,
|
||||
bound_ns / 1000
|
||||
));
|
||||
}
|
||||
println!("clock-resync OK: offsets {} / {} ns", first.offset_ns, second.offset_ns);
|
||||
}
|
||||
|
||||
// Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The
|
||||
// speed test reads their delta over the burst window so throughput/loss reflect every delivered
|
||||
// wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs.
|
||||
|
||||
@@ -52,7 +52,7 @@ impl PartialEq for StreamProps {
|
||||
thread_local! {
|
||||
/// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires
|
||||
/// later, once the native panel exists).
|
||||
static PENDING: RefCell<Option<(crate::session::FrameRx, i64)>> = const { RefCell::new(None) };
|
||||
static PENDING: RefCell<Option<(crate::session::FrameRx, std::sync::Arc<std::sync::atomic::AtomicI64>)>> = const { RefCell::new(None) };
|
||||
/// The live render thread; stopped + joined by the unmount cleanup (before panel teardown).
|
||||
static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) };
|
||||
}
|
||||
@@ -88,7 +88,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
||||
move || {
|
||||
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
||||
let mode = connector.mode();
|
||||
let clock_offset = connector.clock_offset_ns;
|
||||
let clock_offset = connector.clock_offset_shared();
|
||||
connector_ref.set(Some(connector.clone()));
|
||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
||||
crate::input::install(connector, mode, inhibit, show_stats, stop);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -330,7 +330,9 @@ fn pump(
|
||||
// "PPS id out of range" (a black screen) until one arrives.
|
||||
let _ = connector.request_keyframe();
|
||||
|
||||
let clock_offset = connector.clock_offset_ns;
|
||||
// Live host↔client clock offset: loaded per use (Relaxed) so mid-stream re-syncs (an NTP
|
||||
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||
let clock_offset_live = connector.clock_offset_shared();
|
||||
let mut total_frames = 0u64;
|
||||
let session_start = Instant::now();
|
||||
let mut window_start = Instant::now();
|
||||
@@ -363,6 +365,7 @@ fn pump(
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
// `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s).
|
||||
let clock_offset = clock_offset_live.load(Ordering::Relaxed);
|
||||
let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if hostnet > 0 && hostnet < 10_000_000_000 {
|
||||
@@ -500,7 +503,7 @@ fn pump(
|
||||
host_ms: host_p50 as f32 / 1000.0,
|
||||
net_ms: net_p50 as f32 / 1000.0,
|
||||
split,
|
||||
same_host: clock_offset == 0,
|
||||
same_host: clock_offset_live.load(Ordering::Relaxed) == 0,
|
||||
hardware,
|
||||
hdr,
|
||||
codec: connector.codec,
|
||||
|
||||
Reference in New Issue
Block a user