fix(client/net): split the receipt stamp from the pull + bleed standing latency
ci / docs-site (push) Successful in 56s
apple / swift (push) Successful in 1m14s
ci / web (push) Successful in 2m30s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m57s
release / apple (push) Successful in 9m8s
deb / build-publish (push) Successful in 9m33s
arch / build-publish (push) Successful in 13m14s
docker / deploy-docs (push) Successful in 23s
flatpak / build-publish (push) Failing after 8m1s
deb / build-publish-host (push) Successful in 10m24s
android / android (push) Successful in 14m56s
apple / screenshots (push) Successful in 6m43s
ci / rust (push) Successful in 19m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m29s
windows-host / package (push) Successful in 15m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 7m45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 8m51s

The two-pair investigation (wired Mac clients stuck at a rock-steady
~18-19 ms "network" that survived the load ending and cleared only on
reconnect) exposed two structural gaps, one of measurement and one of
recovery:

- Receipt was stamped at the hand-off PULL (Swift nextAU, pf-client-core,
  Android decode loops), not at reassembly completion — so any client-side
  standing state between the reassembler and the pull read as NETWORK
  latency, undiagnosable from the HUD. ABI v9: `PunktfunkFrame`/`Frame`
  grow `received_ns`, stamped by `Session::poll_frame` as the AU crosses
  the session boundary. Every embedder now uses the core stamp; the Apple
  client keeps the pull instant as `AccessUnit.pulledNs` and shows the
  receipt→pull wait as its own "client queue" term (detailed HUD tier from
  2 ms + a `queue_p50` stats-log field). Decode stages keep their pull
  anchor on all platforms, so no historical stage shifts meaning.

- The jump-to-live detectors deliberately ignore anything under 6 queued
  frames / 400 ms behind — so a small, constant, loss-free elevation (a
  sub-frame standing backlog, or a stale clock offset after a wall-clock
  step/slew) is carried for the rest of the session. New third detector
  (`StandingLatency`, unit-tested ladder): window-MIN one-way delay
  ≥ 10 ms above the session floor with zero loss for ~4.5 s escalates
  gently — a free clock re-sync first (an applied re-sync re-bases the
  floor), then at most 3 flush+keyframe bleeds sharing the jump-to-live
  cooldown, then a loud disarm naming what it means. Loss windows reset
  the run: congestion belongs to FEC/ABR, not this detector.

Also: mid-stream re-sync apply/discard logs debug→info — they are the
forensic trail for the stale-offset case and were invisible in the field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 23:38:11 +02:00
co-authored by Claude Fable 5
parent 134874b3c8
commit a784682d4c
16 changed files with 510 additions and 29 deletions
+23 -3
View File
@@ -30,6 +30,14 @@ pub struct Frame {
/// ([`crate::packet::USER_FLAG_CHUNK_ALIGNED`]) are ever delivered partial; missing
/// shard ranges are zero-filled at their exact offsets.
pub complete: bool,
/// Wall-clock instant (ns since the Unix epoch, CLOCK_REALTIME basis — the same clock the
/// skew handshake compares and the host stamps `pts_ns` with) at which this AU finished
/// reassembly, stamped by [`Session::poll_frame`] as the frame leaves the session. Embedders
/// that previously stamped receipt themselves at the hand-off pull should use this instead:
/// the pull stamp additionally contains the pre-decode queue wait, silently folding any
/// client-side standing backlog into the apparent NETWORK latency. The reassembler itself
/// leaves this 0 (it owns no clock — the stamp is the session boundary's job).
pub received_ns: u64,
}
/// One end of a stream. Constructed for a single [`Role`]; calling the other role's
@@ -82,6 +90,18 @@ pub struct Session {
lane_scratch: Vec<Vec<u8>>,
}
/// Stamp [`Frame::received_ns`] as the frame crosses the session boundary in
/// [`Session::poll_frame`] — completed frames return the moment their last shard lands, so
/// stamping at return IS stamping at reassembly completion (µs apart). CLOCK_REALTIME to match
/// `pts_ns` / the skew handshake (deliberately not monotonic — cross-machine latency math).
fn stamp_received(mut f: Frame) -> Frame {
f.received_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
f
}
/// Wire-packet count at which a frame's sealing splits across two lanes (plan Phase 1.5):
/// below it the channel rendezvous (~µs) isn't worth it; at it the halved AES-GCM span
/// (≥ ~125 µs of ~1 µs/packet work) dwarfs the hand-off. ≈300 KB of wire, i.e. ≥150 Mbps
@@ -684,7 +704,7 @@ impl Session {
// Nothing new on the wire — hand over an aged-out partial if one is
// waiting (it can only get staler).
if let Some(p) = self.reassembler.take_partial() {
return Ok(p);
return Ok(stamp_received(p));
}
return Err(PunktfunkError::NoFrame);
}
@@ -748,12 +768,12 @@ impl Session {
}
if let Some(frame) = pushed {
StatsCounters::add(&self.stats.frames_completed, 1);
return Ok(frame);
return Ok(stamp_received(frame));
}
// A push that completed nothing may still have aged a partial out — deliver it
// ahead of further draining (its successors are already arriving).
if let Some(p) = self.reassembler.take_partial() {
return Ok(p);
return Ok(stamp_received(p));
}
}
}