refactor(core/W7): split client.rs into client/ facade + submodules

Turn the 2674-line client.rs into a client/ directory module (mod.rs facade +
8 submodules) behind glob/`use self::` re-exports, so crate::client::X paths
(NativeClient, ProbeOutcome, AudioPacket, display_hdr_env_override) stay
byte-stable. Leaf lifts: frame_channel.rs (the FIFO hand-off + jump-to-live
consts + DecodeLatAcc), recovery.rs (RfiRecovery loss-range detector),
probe.rs (ProbeState/ProbeOutcome), planes.rs (side-plane queues + AudioPacket),
control.rs (CtrlRequest/Negotiated), worker.rs (WorkerArgs + reject_from_close),
pairing.rs (NativeClient::pair). The per-frame pump moves WHOLE as a plain
`pub(super) async fn run_pump` (was worker_main) — the only edit is the
signature line: no trait object, no Box, no per-frame allocation or indirection.
NativeClient + its public impl + Drop + the cfg-gated thread-pin/hot-tid helpers
stay in the facade. Visibility bumps are pub(crate) (struct + each field for
WorkerArgs/Negotiated/ProbeState; FrameChannel + each method); reject_from_close
is pub(crate) (sibling access). No behavior change.

Verified: Linux clippy (quic + no-default, -D warnings) + full cargo test;
Windows clippy (both) + test --lib; macOS clippy (apple thread-pin variant) +
165 lib tests. On-glass jump-to-live + ABR smoke still owed (pump is a pure
relocation, so this is a formality) per the plan's pump gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:29:16 +02:00
parent 716875dd09
commit ffa63a74f2
10 changed files with 2743 additions and 2674 deletions
@@ -0,0 +1,256 @@
//! The pre-decode FIFO video hand-off (`FrameChannel`) + jump-to-live tuning consts + `DecodeLatAcc`.
use crate::session::Frame;
use std::collections::VecDeque;
use std::sync::{Condvar, Mutex};
use std::time::Duration;
/// Depth at/above which the pre-decode hand-off queue counts as "not draining" for the clock-free
/// standing-queue detector. A consumer that keeps up (or drains newest-per-vsync, like the Apple
/// client) holds this near 0; a transient Wi-Fi clump or a small jitter buffer spikes it briefly then
/// drains. Sits above a reasonable jitter buffer (~100 ms @ 60 fps) so only a genuine backlog trips it.
pub(crate) const QUEUE_HIGH: usize = 6;
/// Depth at/below which the hand-off queue is considered drained — resets the standing-queue counter.
/// A true standing queue never falls back to this; a clump does within a few frames.
pub(crate) const QUEUE_LOW: usize = 2;
/// Consecutive frames the hand-off queue must sit ≥ [`QUEUE_HIGH`] (never dropping to [`QUEUE_LOW`])
/// before the pump declares a standing backlog and jumps to live. ~0.5 s at 60 fps — long enough that
/// a burst/clump (which drains in a few frames) never reaches it.
pub(crate) const STANDING_FRAMES: u32 = 30;
/// Memory backstop on the pre-decode hand-off queue. The standing-queue detector jumps to live long
/// before this (typically ≤ QUEUE_HIGH + STANDING_FRAMES deep), and a jump already requested a
/// keyframe, so on the rare path that outruns it (a wedged consumer during the flush cooldown) dropping
/// the OLDEST queued AU is safe — the pending IDR re-anchors decode regardless. Purely bounds memory.
const FRAME_QUEUE_HARD_CAP: usize = 90;
/// Backlog latency bound: when completed frames keep arriving further than this behind the host's
/// capture clock (skew-corrected), the pump jumps to live (discards the receive backlog + the queued
/// AUs and requests a keyframe) instead of playing that far behind forever. Deliberately generous — an
/// interactive stream is unusable well before 400 ms, but the bound must sit safely above the skew
/// handshake's own error (≈ RTT/2) plus normal delivery jitter so a healthy stream can never trip it.
/// This is the CLOCK-BASED detector; the clock-free [`QUEUE_HIGH`]/[`STANDING_FRAMES`] detector covers
/// same-clock and no-handshake sessions (where `clock_offset_ns == 0` disarms this one).
pub(crate) const FLUSH_LATENCY: Duration = Duration::from_millis(400);
/// How many CONSECUTIVE over-bound frames arm the clock-based jump (~0.5 s at 60 fps). A genuine
/// standing queue puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears
/// within a frame or two and never reaches the count.
pub(crate) const FLUSH_AFTER_FRAMES: u32 = 30;
/// Minimum spacing between jump-to-live events, so a bottleneck that instantly rebuilds the queue (a
/// link/consumer that can't sustain the bitrate at all) degrades into a periodic skip + a logged
/// warning instead of a continuous flush/keyframe storm.
pub(crate) const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
/// shifted the skew-corrected latency by a constant — every future frame reads over-bound and the
/// detector would fire forever, one flush + recovery IDR per cooldown, dragging the bitrate
/// controller to its floor; or the delay is standing in an **upstream queue** (router bufferbloat),
/// which a local flush can't drain — the OWD signal already feeds the bitrate controller, the
/// actual remedy. Even at the 5 Mbps bitrate floor a genuine 400 ms backlog is ~170 datagrams, so
/// 64 cleanly separates "empty" from "real". See `NOOP_CLOCK_FLUSHES_TO_DISARM`.
pub(crate) const NOOP_FLUSH_DATAGRAMS: u64 = 64;
/// Consecutive no-op clock-triggered flushes (see [`NOOP_FLUSH_DATAGRAMS`]) before the clock-based
/// detector is disarmed. The clock-free standing-queue detector stays armed — it measures the
/// local queue directly and can't be fooled by a clock step. No longer for the rest of the
/// session: an applied mid-stream clock re-sync re-arms the detector (the disarm stays as the
/// final backstop between re-syncs).
pub(crate) const NOOP_CLOCK_FLUSHES_TO_DISARM: u32 = 2;
/// Cadence of the control task's periodic mid-stream clock re-sync (see [`ClockResync`]): often
/// enough to bound slow drift and pick up an NTP step within a minute, rare enough to be free
/// (8 tiny control messages per batch). The pump additionally fires one immediately after the
/// FIRST no-op clock flush — the moment a step is actually suspected.
pub(crate) const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60);
/// Client decode-stage latency accumulator for the adaptive-bitrate controller's decode signal.
/// The embedder adds one sample per decoded frame ([`NativeClient::report_decode_us`], µs from the
/// AU leaving [`NativeClient::next_frame`] to its decoded output) and the data-plane pump drains a
/// window mean once per report window to feed [`crate::abr::BitrateController::on_window`]. This is
/// the only signal that sees the CLIENT'S decoder: on a fast LAN a mobile HW decoder saturates long
/// before the link, backlogging frames inside the decoder where loss/OWD never register. Sum+count
/// (not a running mean) so the pump takes an unweighted window mean and resets. Always accumulated —
/// the controller ignores it when Automatic is off, and the pump drains it every window regardless,
/// so it stays bounded (a full window at 240 fps is ~180 samples).
#[derive(Default)]
pub(crate) struct DecodeLatAcc {
pub(crate) sum_us: u64,
pub(crate) count: u32,
}
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
/// IDR. So this queue is strictly FIFO and never drops a frame from the middle. When the embedder falls
/// PERSISTENTLY behind — the queue stops draining — the pump JUMPS TO LIVE instead ([`clear`] + a
/// keyframe request), so decode resumes cleanly at an IDR rather than ratcheting latency forever (the
/// old bounded channel silently dropped the NEWEST AU on overflow — backwards for a live stream, and a
/// reference-chain break the loss counters never saw). A transient burst fills it briefly and drains on
/// its own, so a clump never costs a keyframe.
///
/// [`clear`]: FrameChannel::clear
pub(crate) struct FrameChannel {
inner: Mutex<FrameQueue>,
ready: Condvar,
}
struct FrameQueue {
q: VecDeque<Frame>,
/// Set when the pump exits so a blocked [`FrameChannel::pop`] reports the stream ended
/// ([`PunktfunkError::Closed`]) rather than a spurious timeout (the old mpsc did this on sender drop).
closed: bool,
}
/// Outcome of [`FrameChannel::pop`] — mirrors the old `recv_timeout` results so `next_frame`'s
/// Timeout/Closed mapping is unchanged.
pub(crate) enum FramePop {
Frame(Frame),
Timeout,
Closed,
}
impl FrameChannel {
pub(crate) fn new() -> Self {
Self {
inner: Mutex::new(FrameQueue {
q: VecDeque::new(),
closed: false,
}),
ready: Condvar::new(),
}
}
/// Pump side: append a completed AU and wake a blocked consumer. Enforces the memory backstop
/// ([`FRAME_QUEUE_HARD_CAP`]) by dropping the oldest (see its doc — a jump-to-live keyframe is
/// already in flight by the time this can bite).
pub(crate) fn push(&self, frame: Frame) {
let mut st = self.inner.lock().unwrap();
st.q.push_back(frame);
while st.q.len() > FRAME_QUEUE_HARD_CAP {
st.q.pop_front();
}
drop(st);
self.ready.notify_one();
}
/// Pump side: current queued depth — the clock-free standing-queue signal.
pub(crate) fn depth(&self) -> usize {
self.inner.lock().unwrap().q.len()
}
/// Pump side: discard the whole backlog (the jump-to-live path); returns how many were dropped.
pub(crate) fn clear(&self) -> usize {
let mut st = self.inner.lock().unwrap();
let n = st.q.len();
st.q.clear();
n
}
/// Pump side: mark the stream ended and wake every blocked consumer.
pub(crate) fn close(&self) {
self.inner.lock().unwrap().closed = true;
self.ready.notify_all();
}
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive.
pub(crate) fn pop(&self, timeout: Duration) -> FramePop {
let mut st = self.inner.lock().unwrap();
if st.q.is_empty() && !st.closed {
st = self.ready.wait_timeout(st, timeout).unwrap().0;
}
if let Some(f) = st.q.pop_front() {
FramePop::Frame(f)
} else if st.closed {
FramePop::Closed
} else {
FramePop::Timeout
}
}
}
#[cfg(test)]
mod frame_channel_tests {
use super::{FrameChannel, FramePop, FRAME_QUEUE_HARD_CAP};
use crate::session::Frame;
use std::time::Duration;
fn frame(i: u32) -> Frame {
Frame {
data: vec![i as u8],
frame_index: i,
pts_ns: i as u64,
flags: 0,
complete: true,
}
}
fn popped(ch: &FrameChannel) -> Option<u32> {
match ch.pop(Duration::from_millis(0)) {
FramePop::Frame(f) => Some(f.frame_index),
_ => None,
}
}
#[test]
fn fifo_order_and_depth() {
let ch = FrameChannel::new();
assert_eq!(ch.depth(), 0);
ch.push(frame(1));
ch.push(frame(2));
assert_eq!(ch.depth(), 2);
assert_eq!(popped(&ch), Some(1)); // oldest first (never newest-wins pre-decode)
assert_eq!(popped(&ch), Some(2));
assert_eq!(ch.depth(), 0);
}
#[test]
fn empty_pop_times_out_not_closed() {
let ch = FrameChannel::new();
assert!(matches!(
ch.pop(Duration::from_millis(1)),
FramePop::Timeout
));
}
#[test]
fn clear_drops_backlog_and_reports_count() {
let ch = FrameChannel::new();
for i in 0..5 {
ch.push(frame(i));
}
assert_eq!(ch.clear(), 5); // the jump-to-live discard returns what it dropped
assert_eq!(ch.depth(), 0);
assert!(matches!(
ch.pop(Duration::from_millis(1)),
FramePop::Timeout
));
}
#[test]
fn close_after_drain_reports_closed() {
let ch = FrameChannel::new();
ch.push(frame(7));
ch.close();
// Queued frames still drain BEFORE the Closed signal.
assert_eq!(popped(&ch), Some(7));
assert!(matches!(ch.pop(Duration::from_millis(1)), FramePop::Closed));
}
#[test]
fn hard_cap_drops_oldest() {
let ch = FrameChannel::new();
let total = FRAME_QUEUE_HARD_CAP as u32 + 10;
for i in 0..total {
ch.push(frame(i));
}
// Capped at the backstop; the OLDEST were dropped, so the newest survive in order.
assert_eq!(ch.depth(), FRAME_QUEUE_HARD_CAP);
assert_eq!(popped(&ch), Some(total - FRAME_QUEUE_HARD_CAP as u32));
}
}