fix(host/phase-lock): a dead error signal freezes the hold instead of orbiting the period

First on-glass closed-loop run (NP3 ↔ .173, canary 0.23.10498) proved the
transport and both endpoints work — and exposed the controller chasing a
statistic that cannot respond: under period-spanning arrival jitter the
client's latch DISTRIBUTION is ~uniform mod the refresh, so shifting its
mean (the hold — provably reaching the client: the paced-drop bursts locked
to each wrap) cannot move its MEDIAN. The controller stepped +2 ms/s in one
direction forever, wrapping the period every ~4 s, and every wrap coalesced
~30 frames at the client — a periodic hiccup that must not ship.

The controller now remembers its last error: three consecutive steps that
fail to move it ≥1.5 ms freeze the hold (kept, not zeroed) and log; a later
report whose error moved re-arms stepping — a tight-jitter regime still
locks exactly as designed. Controller v2 (a circular phase statistic with a
coherence gate, per the design doc's follow-ups) replaces the median
outright; this freeze stays as its safety net.

Gate: docker amd64 clippy --all-targets -D warnings (host, nvenc) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 10:12:56 +02:00
co-authored by Claude Fable 5
parent 21787ca6cd
commit 74ca42568a
@@ -262,6 +262,10 @@ struct PhaseController {
hold_ns: i64,
/// Last adjust instant (~1 Hz cadence).
last_adjust: std::time::Instant,
/// The previous adjust's error (ns) — the dead-signal detector's memory.
last_error_ns: Option<i64>,
/// Consecutive adjusts whose step failed to move the error (see `DEAD_SIGNAL_ADJUSTS`).
unresponsive_adjusts: u32,
}
impl PhaseController {
@@ -279,9 +283,24 @@ impl PhaseController {
PhaseController {
hold_ns: 0,
last_adjust: std::time::Instant::now(),
last_error_ns: None,
unresponsive_adjusts: 0,
}
}
/// Consecutive adjusts allowed to move the hold WITHOUT the reported error responding
/// before the controller freezes (a dead error signal). Under period-spanning arrival
/// jitter the latch DISTRIBUTION is ~uniform mod the refresh: shifting its mean cannot
/// move its median, so a naive controller steps one way forever, orbiting the period —
/// and every wrap coalesces a burst of frames at the client (the 2026-07-31 on-glass
/// finding: `paced` spikes of ~30 every ~4 s, locked to the wraps). Frozen ≠ off: the
/// applied hold stays, and a report whose error moved ≥ [`Self::UNFREEZE_DELTA_NS`]
/// (the regime changed — jitter tightened) re-arms stepping. Controller v2 replaces the
/// median with a circular phase statistic; this freeze is the safety net either way.
const DEAD_SIGNAL_ADJUSTS: u32 = 3;
/// Error movement that counts as "the signal responded" / re-arms a frozen controller.
const UNFREEZE_DELTA_NS: i64 = 1_500_000;
/// Fold the client's latest report into the hold. `period_ns` is the wire interval (the
/// session's frame period). Sign convention: `arrival_lead` is how long before its latch the
/// median frame arrives — a LARGE lead means frames sit waiting at the client, so the host
@@ -296,12 +315,40 @@ impl PhaseController {
let target = Self::TARGET_LEAD_FLOOR_NS.max(r.uncertainty_ns as i64 + 1_000_000);
let error = r.arrival_lead_ns as i64 - target;
if error.abs() < Self::DEADBAND_NS {
self.unresponsive_adjusts = 0;
self.last_error_ns = Some(error);
return;
}
// Dead-signal detection: if stepping the hold hasn't moved the error, stop stepping —
// and while frozen, only a genuinely-moved error re-arms.
if let Some(last) = self.last_error_ns {
if (error - last).abs() >= Self::UNFREEZE_DELTA_NS {
self.unresponsive_adjusts = 0; // the signal responded (or the regime changed)
} else if self.frozen() {
self.last_error_ns = Some(error);
return;
} else {
self.unresponsive_adjusts += 1;
if self.frozen() {
tracing::info!(
error_ms = error as f64 / 1e6,
hold_ms = self.hold_ns as f64 / 1e6,
"phase lock: error signal unresponsive — freezing the hold"
);
self.last_error_ns = Some(error);
return;
}
}
}
self.last_error_ns = Some(error);
let step = error.clamp(-Self::MAX_STEP_NS, Self::MAX_STEP_NS);
self.hold_ns = (self.hold_ns + step).rem_euclid(period_ns);
}
fn frozen(&self) -> bool {
self.unresponsive_adjusts >= Self::DEAD_SIGNAL_ADJUSTS
}
/// Whether the ~1 Hz adjust window has elapsed.
fn due(&self) -> bool {
self.last_adjust.elapsed() >= std::time::Duration::from_secs(1)