Compare commits

..

1 Commits

Author SHA1 Message Date
enricobuehler a38adad943 fix(encode): bound GPU waits, validate encode status, repair command-buffer and cache invariants
Five of the nine medium findings from the pf-encode sweep. The remaining four
need cross-crate plumbing or an unwind refactor and are deliberately left out.

- vulkan_video `enqueue` waited on the backpressure fence with `u64::MAX`. That
  wait runs ON the host encode thread — the same thread the stall watchdog's
  `reset()` would run on — so a wedged GPU parked the one thread that could
  recover the session: no error, no reset, and teardown blocking on the join.
  This is the DEFAULT encode path for AMD/Intel Linux hosts (both shipped build
  recipes enable `vulkan-encode` and `vulkan_encode_enabled()` defaults true).
  Now bounded by ENCODE_FENCE_TIMEOUT_NS with expiry surfaced as an error.

- vulkan_video `import_cached` evicted a cached dmabuf import and destroyed its
  image/view/memory with no fence wait, while up to `ring_depth - 1` submitted
  frames may still reference it — a GPU-side use-after-free. `Drop` and `reset`
  both idle first; this was the one unguarded destroy. Now idles before the
  eviction loop, guarded on the length test so the steady state pays nothing.

- vulkan_video `read_slot` never asked for the encode's operation status, so a
  FAILED encode was indistinguishable from a successful one and its feedback
  was read as if it described real bitstream. Now requests WITH_STATUS_KHR and
  refuses anything that is not COMPLETE.

- linux/pyrowave `encode_frame` opens its recording window early and has six
  fallible steps inside it; every one returned with `cmd` still RECORDING, and
  nothing repaired it (one `begin_command_buffer` in the file, and neither
  `reset()` nor `Drop` touches `cmd`), so the next frame called `begin` on a
  recording buffer — invalid usage. `submit` now resets the buffer on error;
  legal on all these paths since the pool carries RESET_COMMAND_BUFFER and the
  buffer is not pending.

- windows/pyrowave `encode_frame` ignored `frame.width`/`height` and imported
  planes at the encoder's configured extent, so a ring recreate at a new mode
  (the IDD capturer does this autonomously on a confirmed descriptor change)
  read the planes under a stale VkImageCreateInfo. Added the size guard its QSV
  and AMF siblings already carry, and keyed the plane cache on
  (address, width, height) so a recycled COM address cannot resurrect an import
  of a different size. NOTE: a recycle at the SAME size is still theoretically
  possible; the complete fix keys on the capturer's ring generation and needs
  that plumbed onto `PyroFrameShare`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:34:16 +02:00
23 changed files with 137 additions and 551 deletions
@@ -404,14 +404,7 @@ fn feeder_loop(
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The // stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay. // HUD-only `received` point + host/network split stay gated on the overlay.
if stats.enabled() || measure_decode { if stats.enabled() || measure_decode {
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping let received_ns = now_realtime_ns();
// here would fold the hand-off queue wait into the network latency figure
// (a client-side standing backlog masquerading as network). 0 = older core.
let received_ns = if frame.received_ns > 0 {
frame.received_ns as i128
} else {
now_realtime_ns()
};
{ {
let mut g = in_flight let mut g = in_flight
.lock() .lock()
@@ -221,13 +221,7 @@ pub(super) fn run_sync(
// samplers (`received` point, host/network split) stay gated on the overlay so // samplers (`received` point, host/network split) stay gated on the overlay so
// the hidden steady state adds only a wall-clock read + the receipt push. // the hidden steady state adds only a wall-clock read + the receipt push.
if stats.enabled() || measure_decode { if stats.enabled() || measure_decode {
// Core reassembly-completion stamp (ABI v9), not the pull instant — see let received_ns = now_realtime_ns();
// async_loop: a pull stamp folds hand-off queue wait into "network".
let received_ns = if frame.received_ns > 0 {
frame.received_ns as i128
} else {
now_realtime_ns()
};
in_flight.push_back((frame.pts_ns / 1000, received_ns)); in_flight.push_back((frame.pts_ns / 1000, received_ns));
if in_flight.len() > IN_FLIGHT_CAP { if in_flight.len() > IN_FLIGHT_CAP {
in_flight.pop_front(); // stale — codec never echoed it back in_flight.pop_front(); // stale — codec never echoed it back
@@ -579,21 +579,13 @@ struct ContentView: View {
model?.disconnect() // the captured-state D combo model?.disconnect() // the captured-state D combo
}, },
onFrame: { [meter = model.meter, latency = model.latency, onFrame: { [meter = model.meter, latency = model.latency,
split = model.latencySplit, queue = model.clientQueue, split = model.latencySplit, offset = conn.clockOffsetNs] au in
offset = conn.clockOffsetNs] au in
meter.note(byteCount: au.data.count) meter.note(byteCount: au.data.count)
latency.record(ptsNs: au.ptsNs, offsetNs: offset) latency.record(ptsNs: au.ptsNs, offsetNs: offset)
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the // The same receipt, keyed by pts, awaiting its 0xCF host timing (the
// host/network split drained by the 1 s stats tick). receivedNs is // host/network split drained by the 1 s stats tick).
// the core's reassembly stamp (ABI v9), so the split's network term no
// longer contains the client-queue wait...
split.recordReceipt( split.recordReceipt(
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset) ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
// ...which is measured as its own term instead (receiptpull, both
// client-local).
queue.record(
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
offsetNs: 0)
}, },
onSessionEnd: { [weak model] in onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() } Task { @MainActor in model?.sessionEnded() }
@@ -102,12 +102,6 @@ final class SessionModel: ObservableObject {
@Published var decodeValid = false @Published var decodeValid = false
@Published var displayP50Ms = 0.0 @Published var displayP50Ms = 0.0
@Published var displayValid = false @Published var displayValid = false
/// Client-queue wait: core reassembly receipt the pump's pull (`AccessUnit.pulledNs
/// receivedNs`, ABI v9 receipt split the 2026-07 two-pair investigation). ~0 on a healthy
/// stream; a persistent value is a client-side standing backlog that used to hide inside
/// "network". Shown in the detailed tier only when it says something ( ~2 ms).
@Published var clientQueueP50Ms = 0.0
@Published var clientQueueValid = false
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline /// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
/// engine's vendglass pipeline depth an OS property no client can pace under (~2 refresh /// engine's vendglass pipeline depth an OS property no client can pace under (~2 refresh
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from /// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
@@ -153,9 +147,6 @@ final class SessionModel: ObservableObject {
let endToEnd = LatencyMeter() let endToEnd = LatencyMeter()
let decodeStage = LatencyMeter() let decodeStage = LatencyMeter()
let displayStage = LatencyMeter() let displayStage = LatencyMeter()
/// Client-queue sampler (see `clientQueueP50Ms`) fed per AU by the stream view's onFrame,
/// drained by the same 1 s tick as the stage meters.
let clientQueue = LatencyMeter()
/// The OS present floor sampler (see `osFloorP50Ms`) fed one sample per display-link /// The OS present floor sampler (see `osFloorP50Ms`) fed one sample per display-link
/// update by the deadline engine, drained by the same 1 s tick as the stage meters. /// update by the deadline engine, drained by the same 1 s tick as the stage meters.
let presentFloor = LatencyMeter() let presentFloor = LatencyMeter()
@@ -498,7 +489,6 @@ final class SessionModel: ObservableObject {
endToEndValid = false endToEndValid = false
decodeValid = false decodeValid = false
displayValid = false displayValid = false
clientQueueValid = false
osFloorValid = false osFloorValid = false
lostFrames = 0 lostFrames = 0
lostPct = 0 lostPct = 0
@@ -689,12 +679,6 @@ final class SessionModel: ObservableObject {
} else { } else {
self.osFloorValid = false self.osFloorValid = false
} }
if let q = self.clientQueue.drain() {
self.clientQueueP50Ms = q.p50Ms
self.clientQueueValid = true
} else {
self.clientQueueValid = false
}
// Mirror the window to the unified log (see statsLog) one line per second, // Mirror the window to the unified log (see statsLog) one line per second,
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs; // stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
// `presents` counts frames that reached glass (the display meter's sample count) // `presents` counts frames that reached glass (the display meter's sample count)
@@ -707,7 +691,7 @@ final class SessionModel: ObservableObject {
let line = String( let line = String(
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f " format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%d " + "decode_p50=%.1f display_p50=%.1f lost=%d "
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f", + "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f",
frames, frames,
displayWindow?.count ?? 0, displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1, self.endToEndValid ? self.endToEndP50Ms : -1,
@@ -718,8 +702,7 @@ final class SessionModel: ObservableObject {
lost, lost,
self.osFloorValid ? self.osFloorP50Ms : -1, self.osFloorValid ? self.osFloorP50Ms : -1,
self.displayValid ? self.displayAdjP50Ms : -1, self.displayValid ? self.displayAdjP50Ms : -1,
self.endToEndValid ? self.endToEndAdjP50Ms : -1, self.endToEndValid ? self.endToEndAdjP50Ms : -1)
self.clientQueueValid ? self.clientQueueP50Ms : -1)
statsLog.info("\(line, privacy: .public)") statsLog.info("\(line, privacy: .public)")
} }
} }
@@ -118,16 +118,6 @@ struct StreamHUDView: View {
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
// Client-queue wait (reassembly receipt decode pull, ABI v9 split): ~0 on
// a healthy stream and hidden as noise; shown from 2 ms a persistent value
// is a client-side standing backlog that pre-split builds displayed as
// "network" (the 2026-07 two-pair plateau). The core's standing-latency
// bleed logs alongside when it acts on the same state.
if model.clientQueueValid && model.clientQueueP50Ms >= 2 {
Text("client queue +\(model.clientQueueP50Ms, specifier: "%.1f") (receive backlog — standing if it persists)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
} }
} else if model.hostNetworkValid { } else if model.hostNetworkValid {
// Stage-1 fallback presenter: the layer decodes + presents internally with no // Stage-1 fallback presenter: the layer decodes + presents internally with no
@@ -35,31 +35,10 @@ public struct AccessUnit: Sendable {
public let ptsNs: UInt64 public let ptsNs: UInt64
public let frameIndex: UInt32 public let frameIndex: UInt32
public let flags: UInt32 public let flags: UInt32
/// Client `CLOCK_REALTIME` instant the AU finished reassembly in the core (post-FEC, /// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
/// decrypted `PunktfunkFrame.received_ns`, ABI v9) the **received** measurement point of /// the **received** measurement point of design/stats-unification.md. The decode stage is
/// design/stats-unification.md. NOT the pull instant: stamping at the pull folded the /// `decodedNs - receivedNs`, both client-local (no skew offset applies).
/// pre-decode hand-off wait into the network term, which is how the 2026-07 two-pair
/// standing-latency plateau hid as "network". The decode stage is `decodedNs - receivedNs`,
/// both client-local (no skew offset applies).
public let receivedNs: Int64 public let receivedNs: Int64
/// Client `CLOCK_REALTIME` instant this pull returned. `pulledNs - receivedNs` is the
/// client-queue wait (kernel hand-off + FrameChannel dwell) the term the HUD splits out
/// so a client-side standing backlog can never masquerade as network latency again.
public let pulledNs: Int64
/// `pulledNs` defaults to `receivedNs` (zero queue wait) for callers with no pull instant
/// the synthetic probe AUs and decode tests, where the split is meaningless.
public init(
data: Data, ptsNs: UInt64, frameIndex: UInt32, flags: UInt32,
receivedNs: Int64, pulledNs: Int64? = nil
) {
self.data = data
self.ptsNs = ptsNs
self.frameIndex = frameIndex
self.flags = flags
self.receivedNs = receivedNs
self.pulledNs = pulledNs ?? receivedNs
}
} }
/// One Opus audio packet (48 kHz stereo, 5 ms frames) decode with AVAudioConverter /// One Opus audio packet (48 kHz stereo, 5 ms frames) decode with AVAudioConverter
@@ -683,16 +662,11 @@ public final class PunktfunkConnection {
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
var ts = timespec() var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts) clock_gettime(CLOCK_REALTIME, &ts)
let pulledNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec) let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
// Receipt = the core's reassembly-completion stamp (ABI v9); the pull instant is
// kept separately so the client-queue wait is its own measured term. 0 would mean a
// pre-v9 core impossible here (core and Kit ship in one binary), but fall back to
// the pull instant rather than record a 1970 receipt.
let receivedNs = frame.received_ns > 0 ? Int64(frame.received_ns) : pulledNs
return AccessUnit( return AccessUnit(
data: data, ptsNs: frame.pts_ns, data: data, ptsNs: frame.pts_ns,
frameIndex: frame.frame_index, flags: frame.flags, frameIndex: frame.frame_index, flags: frame.flags,
receivedNs: receivedNs, pulledNs: pulledNs) receivedNs: receivedNs)
case statusNoFrame: case statusNoFrame:
return nil return nil
case statusClosed: case statusClosed:
@@ -1213,9 +1213,7 @@ public final class Stage2Pipeline {
let chunkAligned = let chunkAligned =
au.flags & PunktfunkConnection.userFlagChunkAligned != 0 au.flags & PunktfunkConnection.userFlagChunkAligned != 0
let ptsNs = au.ptsNs let ptsNs = au.ptsNs
// Decode stage starts at the PULL (matching the VT path's FrameContext let receivedNs = au.receivedNs
// receiptpull is the HUD's separate client-queue term, ABI v9 split).
let receivedNs = au.pulledNs
let flags = au.flags let flags = au.flags
let submitted = decoder.decode( let submitted = decoder.decode(
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
@@ -32,12 +32,9 @@ public enum ReadyImage: @unchecked Sendable {
public struct ReadyFrame: @unchecked Sendable { public struct ReadyFrame: @unchecked Sendable {
/// Host capture clock (the AU's pts), in nanoseconds. /// Host capture clock (the AU's pts), in nanoseconds.
public let ptsNs: UInt64 public let ptsNs: UInt64
/// Client `CLOCK_REALTIME` instant the AU left `nextAU` (`AccessUnit.pulledNs`, threaded /// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
/// through the decode via the frame refcon), in nanoseconds the decode stage's start /// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
/// point. (Named for its historical role; since the ABI v9 receipt split the true /// didn't stamp receipt) the decode-stage meter then drops the sample via its sanity guard.
/// reassembly receipt lives on `AccessUnit.receivedNs`, and receiptpull is the HUD's own
/// client-queue term.) 0 when unknown (a caller that didn't stamp) the decode-stage meter
/// then drops the sample via its sanity guard.
public let receivedNs: Int64 public let receivedNs: Int64
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds. /// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
public let decodedNs: Int64 public let decodedNs: Int64
@@ -170,11 +167,7 @@ public final class VideoDecoder: @unchecked Sendable {
var infoOut = VTDecodeInfoFlags() var infoOut = VTDecodeInfoFlags()
// The AU's receipt instant + wire flags ride through as a retained context; the output // The AU's receipt instant + wire flags ride through as a retained context; the output
// callback reclaims it. Retain immediately before submit so no early return can leak it. // callback reclaims it. Retain immediately before submit so no early return can leak it.
// The decode stage starts at the PULL (the AU leaving nextAU), not the reassembly let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
// receipt: both consumers the decode-stage meter and the ABR decode signal are
// specified from the pull, and the receiptpull wait is the HUD's separate client-queue
// term (see AccessUnit.pulledNs).
let ctx = FrameContext(receivedNs: au.pulledNs, flags: au.flags)
let refcon = Unmanaged.passRetained(ctx).toOpaque() let refcon = Unmanaged.passRetained(ctx).toOpaque()
let status = VTDecompressionSessionDecodeFrame( let status = VTDecompressionSessionDecodeFrame(
session, session,
+2 -6
View File
@@ -27,13 +27,9 @@ ui = ["dep:pf-console-ui", "dep:serde_json"]
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub # Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
# binary. # binary.
[target.'cfg(any(target_os = "linux", windows))'.dependencies] [target.'cfg(any(target_os = "linux", windows))'.dependencies]
# `default-features = false` on both: THIS crate's `pyrowave` feature (above) is the single pf-presenter = { path = "../../crates/pf-presenter" }
# switch that turns the wavelet codec on, and it enables it explicitly on each. Inheriting their
# defaults instead would make `--no-default-features` a lie — the Windows ARM64 leg builds that
# way precisely to skip the vendored PyroWave C++, which has no ARM64 SIMD path.
pf-presenter = { path = "../../crates/pf-presenter", default-features = false }
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true } pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
pf-client-core = { path = "../../crates/pf-client-core", default-features = false } pf-client-core = { path = "../../crates/pf-client-core" }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] } punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON. # The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
serde_json = { version = "1", optional = true } serde_json = { version = "1", optional = true }
+1 -11
View File
@@ -29,17 +29,7 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the # The shared client service layer: the trust/settings stores (ONE `Settings` struct for the
# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library # shell and the spawned session binary — src/trust.rs re-exports it) and the game-library
# data model (fetch + art pipeline) behind the library page. # data model (fetch + art pipeline) behind the library page.
# pf-client-core = { path = "../../crates/pf-client-core" }
# `default-features = false` drops pf-client-core's default `pyrowave`, which would otherwise
# build the vendored PyroWave C++ INTO THE SHELL — dead weight here (the shell never decodes;
# it only offers "pyrowave" as a codec preference string the session binary acts on) and fatal
# on ARM64, where Granite's math falls back to x86 SSE intrinsics and stops at
# `simd.hpp: #error "Implement me."`. This does NOT drop PyroWave from the Windows client:
# decode lives in the spawned punktfunk-session binary, whose own default enables the feature,
# and cargo's feature unification turns it back on for the shared pf-client-core whenever that
# binary is in the same build (x64). On the ARM64 leg both are built --no-default-features, so
# nothing enables it and the C++ is never compiled.
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its # WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri # `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
+3 -8
View File
@@ -59,14 +59,9 @@
</Application> </Application>
<!-- <!--
Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the
desktop shell is the wrong first screen. Its own executable (punktfunk-console.exe) desktop shell is the wrong first screen. Same full-trust executable, launched with
because an MSIX Application entry cannot pass arguments to a full-trust exe; it hands `--console`, which hands straight off to the session binary's controller-driven
straight off to the session binary's controller-driven browse mode (host list, browse mode (host list, pairing, settings, library) fullscreen.
pairing, settings, library) fullscreen.
NOTE: never write a double hyphen in this file. XML forbids it inside a comment, and
makepri rejects the whole manifest ("Appx manifest not found or is invalid") — which
is exactly how the console flag spelled out here broke the v0.15.0 MSIX build.
--> -->
<Application Id="PunktfunkConsole" Executable="punktfunk-console.exe" <Application Id="PunktfunkConsole" Executable="punktfunk-console.exe"
EntryPoint="Windows.FullTrustApplication"> EntryPoint="Windows.FullTrustApplication">
+2 -10
View File
@@ -447,16 +447,8 @@ fn pump(
// every ~816 ms at 60120 Hz anyway, so this rarely times out mid-stream). // every ~816 ms at 60120 Hz anyway, so this rarely times out mid-stream).
match connector.next_frame(Duration::from_millis(20)) { match connector.next_frame(Duration::from_millis(20)) {
Ok(frame) => { Ok(frame) => {
// The `received` point: reassembly COMPLETION, stamped by the core session as // The `received` point: AU fully reassembled, in hand, before decode.
// the AU crossed poll_frame (ABI v9). Stamping here at the hand-off pull instead let received_ns = now_ns();
// would fold the pre-decode queue wait into `host+network` — a client-side
// standing backlog masquerading as network latency (the 2026-07 two-pair
// investigation). 0 = a core predating the stamp; fall back to the pull instant.
let received_ns = if frame.received_ns > 0 {
frame.received_ns
} else {
now_ns()
};
// fps / goodput count every received AU (spec), decoded or not. // fps / goodput count every received AU (spec), decoded or not.
frames_n += 1; frames_n += 1;
bytes_n += frame.data.len() as u64; bytes_n += frame.data.len() as u64;
+18 -1
View File
@@ -1176,7 +1176,24 @@ impl Encoder for PyroWaveEncoder {
fn submit(&mut self, frame: &CapturedFrame) -> Result<()> { fn submit(&mut self, frame: &CapturedFrame) -> Result<()> {
// SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this // SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this
// struct owns and waits its own fence before touching results. // struct owns and waits its own fence before touching results.
unsafe { self.encode_frame(frame) } let r = unsafe { self.encode_frame(frame) };
if r.is_err() {
// `encode_frame` opens the recording window early and has several fallible steps
// inside it (cursor prep, dmabuf import, format mapping, the CPU-RGB staging path,
// an unsupported-payload bail, and the encode call itself). Every one returns with
// `self.cmd` still RECORDING, and nothing downstream repairs it — there is exactly
// one `begin_command_buffer` in this file and `reset()`/`Drop` never touch `cmd` —
// so the NEXT frame would call `begin` on a recording buffer, which is invalid usage.
// Legal here on every path: the pool carries RESET_COMMAND_BUFFER and the buffer is
// not pending (we never reached the submit, or the submit itself failed).
// SAFETY: `self.cmd` is owned by this encoder and, on these paths, not in flight.
unsafe {
let _ = self
.device
.reset_command_buffer(self.cmd, vk::CommandBufferResetFlags::empty());
}
}
r
} }
fn caps(&self) -> EncoderCaps { fn caps(&self) -> EncoderCaps {
+53 -4
View File
@@ -37,6 +37,12 @@ const DPB_SLOTS: u32 = 8;
/// latency — on-glass validated as rock-solid at 1080p@240, so it is the real-time default; /// latency — on-glass validated as rock-solid at 1080p@240, so it is the real-time default;
/// backpressure kicks in at the 2nd unread frame. Distinct from `DPB_SLOTS` (reference pool). /// backpressure kicks in at the 2nd unread frame. Distinct from `DPB_SLOTS` (reference pool).
const RING_DEFAULT: usize = 2; const RING_DEFAULT: usize = 2;
/// Ceiling on any blocking GPU fence wait on the encode thread (5 s). Generous against a real
/// encode (single-digit ms even on a loaded GPU) and against a driver hiccup, but finite: this is
/// the thread the stall watchdog's `reset()` runs on, so an unbounded wait would deadlock the very
/// path that recovers the session. Matches the Windows NVENC retrieve-thread budget.
const ENCODE_FENCE_TIMEOUT_NS: u64 = 5_000_000_000;
/// AV1 base quantizer index (0..=255) seeded into every frame. CBR rate control overrides it per /// AV1 base quantizer index (0..=255) seeded into every frame. CBR rate control overrides it per
/// frame; it only matters as the starting point and for the (rate-control-ignored) constant-Q path. /// frame; it only matters as the starting point and for the (rate-control-ignored) constant-Q path.
const AV1_BASE_Q_IDX: u8 = 128; const AV1_BASE_Q_IDX: u8 = 128;
@@ -868,6 +874,15 @@ impl VulkanVideoEncoder {
let (img, mem, view) = self.import_dmabuf(d, cw, ch)?; let (img, mem, view) = self.import_dmabuf(d, cw, ch)?;
// Bound the cache; evict oldest (FIFO). A stable PipeWire pool never trips this in steady state // Bound the cache; evict oldest (FIFO). A stable PipeWire pool never trips this in steady state
// (all imports resident); it only cycles across a pool change (which also rebuilds the session). // (all imports resident); it only cycles across a pool change (which also rebuilds the session).
// Up to `ring_depth - 1` submitted frames may still be executing against a cached image
// (`enqueue` only drains down to `frames.len()`, and `record_submit` imports before it
// records), so destroying an evicted import here is a GPU-side use-after-free. `Drop` and
// `reset()` both idle the device first; this was the one unguarded destroy. Guarded on the
// length test so the steady-state path — where the cache is resident and never evicts —
// pays nothing.
if self.import_cache.len() >= IMPORT_CACHE_CAP {
let _ = self.device.device_wait_idle();
}
while self.import_cache.len() >= IMPORT_CACHE_CAP { while self.import_cache.len() >= IMPORT_CACHE_CAP {
let (_, _, oi, om, ov) = self.import_cache.remove(0); let (_, _, oi, om, ov) = self.import_cache.remove(0);
self.device.destroy_image_view(ov, None); self.device.destroy_image_view(ov, None);
@@ -1849,8 +1864,25 @@ impl VulkanVideoEncoder {
unsafe fn read_slot(&mut self, slot: usize) -> Result<EncodedFrame> { unsafe fn read_slot(&mut self, slot: usize) -> Result<EncodedFrame> {
let dev = self.device.clone(); let dev = self.device.clone();
let f = &self.frames[slot]; let f = &self.frames[slot];
let mut fb = [[0u32; 2]; 1]; // Ask for the operation status alongside the two feedback words: without it a FAILED encode
dev.get_query_pool_results(f.query_pool, 0, &mut fb, vk::QueryResultFlags::WAIT)?; // is indistinguishable from a successful one, and its offset/bytes-written are read as if
// they described real bitstream. The status rides as a trailing element (signed:
// `VkQueryResultStatusKHR` is >0 COMPLETE, 0 NOT_READY, <0 error).
let mut fb = [[0i32; 3]; 1];
dev.get_query_pool_results(
f.query_pool,
0,
&mut fb,
vk::QueryResultFlags::WAIT | vk::QueryResultFlags::WITH_STATUS_KHR,
)?;
let status = fb[0][2];
if status <= 0 {
anyhow::bail!(
"vulkan-encode: encode feedback for slot {slot} reports status {status} \
(not COMPLETE) dropping the frame rather than shipping its bitstream"
);
}
let fb = [[fb[0][0] as u32, fb[0][1] as u32]];
// The (offset, bytes-written) pair is driver-reported: validate it against the bitstream // The (offset, bytes-written) pair is driver-reported: validate it against the bitstream
// allocation BEFORE mapping, or the `from_raw_parts` below reads outside the buffer and // allocation BEFORE mapping, or the `from_raw_parts` below reads outside the buffer and
// ships whatever it finds straight onto the wire. Checked in u64 so the add cannot wrap, // ships whatever it finds straight onto the wire. Checked in u64 so the add cannot wrap,
@@ -1892,8 +1924,25 @@ impl VulkanVideoEncoder {
// and free it — that oldest slot is exactly the round-robin `ring` cursor we reuse next. // and free it — that oldest slot is exactly the round-robin `ring` cursor we reuse next.
while self.in_flight.len() >= self.frames.len() { while self.in_flight.len() >= self.frames.len() {
let slot = self.in_flight.pop_front().unwrap(); let slot = self.in_flight.pop_front().unwrap();
self.device // Bounded, not `u64::MAX`: this runs ON the host encode thread, which is also the
.wait_for_fences(&[self.frames[slot].fence], true, u64::MAX)?; // thread the stall watchdog's `reset()` would run on. An infinite wait against a
// wedged GPU/driver therefore parks the one thread that could recover the session —
// it never errors, never resets, and teardown blocks joining it. Surfacing expiry as
// an error hands control back to the existing recovery path (same convention as the
// pyrowave and Windows NVENC backends).
match self.device.wait_for_fences(
&[self.frames[slot].fence],
true,
ENCODE_FENCE_TIMEOUT_NS,
) {
Ok(()) => {}
Err(vk::Result::TIMEOUT) => anyhow::bail!(
"vulkan-encode: fence for slot {slot} did not signal within {} ms — GPU or \
driver wedged; failing the submit so the session can reset",
ENCODE_FENCE_TIMEOUT_NS / 1_000_000
),
Err(e) => return Err(e.into()),
}
let done = self.read_slot(slot)?; let done = self.read_slot(slot)?;
self.pending.push_back(done); self.pending.push_back(done);
} }
+30 -6
View File
@@ -43,6 +43,9 @@ const BS_SLACK: usize = 256 * 1024;
/// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed. /// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed.
const IMPORT_CACHE_CAP: usize = 8; const IMPORT_CACHE_CAP: usize = 8;
/// Plane-import cache key: the texture's COM address plus the extent it was imported at.
type PlaneKey = (isize, u32, u32);
// --- Vulkan enum values not surfaced by pyrowave-sys' bindgen (only enums *reachable* from the // --- Vulkan enum values not surfaced by pyrowave-sys' bindgen (only enums *reachable* from the
// pyrowave C API are generated; these plain #define / flags-typedef values are stable spec // pyrowave C API are generated; these plain #define / flags-typedef values are stable spec
// constants). bindgen renders every reachable Vulkan enum as a `u32` type alias, so these u32 // constants). bindgen renders every reachable Vulkan enum as a `u32` type alias, so these u32
@@ -136,8 +139,8 @@ pub struct PyroWaveEncoder {
// Imported plane textures, cached by the out-ring texture's raw pointer (stable per ring slot): // Imported plane textures, cached by the out-ring texture's raw pointer (stable per ring slot):
// the full-res R8 Y plane and the half-res R8G8 CbCr plane, imported SEPARATELY (a single planar // the full-res R8 Y plane and the half-res R8G8 CbCr plane, imported SEPARATELY (a single planar
// NV12 import is unreliable on NVIDIA at arbitrary sizes). // NV12 import is unreliable on NVIDIA at arbitrary sizes).
y_images: Vec<(isize, pw::pyrowave_image)>, y_images: Vec<(PlaneKey, pw::pyrowave_image)>,
cbcr_images: Vec<(isize, pw::pyrowave_image)>, cbcr_images: Vec<(PlaneKey, pw::pyrowave_image)>,
width: u32, width: u32,
height: u32, height: u32,
@@ -351,10 +354,16 @@ impl PyroWaveEncoder {
/// ///
/// # Safety /// # Safety
/// Same contract as [`import_plane`]. /// Same contract as [`import_plane`].
/// Keyed on `(texture address, width, height)` rather than the bare address: the COM pointer
/// carries no reference here, so a released texture's address can be recycled by a later
/// allocation and return an import describing the WRONG surface. Folding the extent in means a
/// recycled address at a different size can never alias. (A recycle at the SAME size is still
/// possible in principle — the complete fix is to key on the capturer's ring generation, which
/// needs that generation plumbed onto `PyroFrameShare`.)
unsafe fn cached_plane( unsafe fn cached_plane(
cache: &mut Vec<(isize, pw::pyrowave_image)>, cache: &mut Vec<(PlaneKey, pw::pyrowave_image)>,
make: impl FnOnce() -> Result<pw::pyrowave_image>, make: impl FnOnce() -> Result<pw::pyrowave_image>,
key: isize, key: PlaneKey,
) -> Result<pw::pyrowave_image> { ) -> Result<pw::pyrowave_image> {
if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) { if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) {
return Ok(*img); return Ok(*img);
@@ -423,6 +432,21 @@ impl PyroWaveEncoder {
!self.pw_enc.is_null(), !self.pw_enc.is_null(),
"pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)" "pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)"
); );
// The plane textures are imported at the encoder's CONFIGURED extent, not the frame's, so a
// capture that changed size would be read under a stale `VkImageCreateInfo`. This is
// reachable without any client Reconfigure: the IDD capturer autonomously recreates its ring
// on a confirmed display-descriptor change (e.g. a fullscreen game mode-setting the virtual
// display). Refuse instead — the session must reopen the encoder at the new mode. Mirrors
// the guard the QSV and AMF backends already carry.
anyhow::ensure!(
frame.width == self.width && frame.height == self.height,
"pyrowave: captured frame {}x{} != encoder {}x{} (the capturer recreated its ring at a \
new mode the encoder must be reopened)",
frame.width,
frame.height,
self.width,
self.height
);
let FramePayload::D3d11(d3d) = &frame.payload else { let FramePayload::D3d11(d3d) = &frame.payload else {
bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)") bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)")
}; };
@@ -465,7 +489,7 @@ impl PyroWaveEncoder {
}; };
let pw_dev = self.pw_dev; let pw_dev = self.pw_dev;
let y_img = { let y_img = {
let key = d3d.texture.as_raw() as isize; let key = (d3d.texture.as_raw() as isize, w, h);
let tex = &d3d.texture; let tex = &d3d.texture;
Self::cached_plane( Self::cached_plane(
&mut self.y_images, &mut self.y_images,
@@ -474,7 +498,7 @@ impl PyroWaveEncoder {
)? )?
}; };
let cbcr_img = { let cbcr_img = {
let key = share.cbcr.as_raw() as isize; let key = (share.cbcr.as_raw() as isize, cw, ch);
let tex = &share.cbcr; let tex = &share.cbcr;
Self::cached_plane( Self::cached_plane(
&mut self.cbcr_images, &mut self.cbcr_images,
+1 -5
View File
@@ -11,11 +11,7 @@ repository.workspace = true
# Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one # Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one
# Linux-only module — see lib.rs). # Linux-only module — see lib.rs).
[target.'cfg(any(target_os = "linux", windows))'.dependencies] [target.'cfg(any(target_os = "linux", windows))'.dependencies]
# `default-features = false`: the PyroWave decode backend is turned on through THIS crate's own pf-client-core = { path = "../pf-client-core" }
# `pyrowave` feature (which re-exports it below), never by inheriting the dependency's default.
# Otherwise a consumer that deliberately builds us without `pyrowave` still drags the vendored
# C++ in — fatal on Windows ARM64, where Granite has no SIMD path.
pf-client-core = { path = "../pf-client-core", default-features = false }
# AVVkFrame access (Vulkan Video frames: live sync state under the frames lock). # AVVkFrame access (Vulkan Video frames: live sync state under the frames lock).
pf-ffvk = { path = "../pf-ffvk" } pf-ffvk = { path = "../pf-ffvk" }
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
-8
View File
@@ -125,12 +125,6 @@ pub struct PunktfunkFrame {
pub frame_index: u32, pub frame_index: u32,
pub pts_ns: u64, pub pts_ns: u64,
pub flags: u32, pub flags: u32,
/// Wall-clock reassembly-completion instant (ns since the Unix epoch, CLOCK_REALTIME — the
/// clock `pts_ns` and the skew handshake use). THIS is the receipt stamp for latency math:
/// a stamp the embedder takes itself at the poll return additionally contains the
/// pre-decode hand-off queue wait, so a client-side standing backlog would masquerade as
/// network latency (ABI v9 — the 2026-07 two-pair standing-latency investigation).
pub received_ns: u64,
} }
/// Snapshot of session counters. /// Snapshot of session counters.
@@ -397,7 +391,6 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
frame_index: f.frame_index, frame_index: f.frame_index,
pts_ns: f.pts_ns, pts_ns: f.pts_ns,
flags: f.flags, flags: f.flags,
received_ns: f.received_ns,
}; };
} }
PunktfunkStatus::Ok PunktfunkStatus::Ok
@@ -1751,7 +1744,6 @@ pub unsafe extern "C" fn punktfunk_connection_next_au(
frame_index: f.frame_index, frame_index: f.frame_index,
pts_ns: f.pts_ns, pts_ns: f.pts_ns,
flags: f.flags, flags: f.flags,
received_ns: f.received_ns,
}; };
} }
PunktfunkStatus::Ok PunktfunkStatus::Ok
@@ -73,142 +73,6 @@ pub(crate) const NOOP_CLOCK_FLUSHES_TO_DISARM: u32 = 2;
/// FIRST no-op clock flush — the moment a step is actually suspected. /// FIRST no-op clock flush — the moment a step is actually suspected.
pub(crate) const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60); pub(crate) const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60);
/// Standing-latency bleed (the 2026-07 two-pair investigation): how far above the session's own
/// one-way-delay floor a report window's MINIMUM must sit to count as a standing elevation. The
/// jump-to-live detectors above deliberately ignore anything below ~6 frames / 400 ms, so a
/// small standing state — a sub-frame kernel/reassembly backlog, or a stale clock offset after a
/// wall-clock step — is carried forever and reads as permanent extra "network" latency. 10 ms
/// sits above skew-handshake error + normal LAN jitter, and below a single 60 fps frame period,
/// so the observed one-frame plateau (~17 ms) trips it while a healthy stream cannot.
pub(crate) const STANDING_LAT_THRESH_NS: i128 = 10_000_000;
/// Consecutive elevated report windows (~750 ms each) before the bleed escalates — ~4.5 s of a
/// continuously standing, loss-free elevation. Windows with any loss reset the run: loss means
/// genuine congestion, which the FEC/ABR machinery owns, not this detector.
pub(crate) const STANDING_LAT_WINDOWS: u32 = 6;
/// Per-session cap on flush+keyframe bleeds. A standing state that survives a clock re-sync AND
/// this many local flushes is not local and not clock — the path latency itself changed; the
/// detector disarms with a warning instead of paying a recovery keyframe every few seconds.
pub(crate) const STANDING_LAT_MAX_BLEEDS: u32 = 3;
/// What the standing-latency detector asks the pump to do this window (see [`StandingLatency`]).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum StandingLatAction {
None,
/// First escalation: ask for a mid-stream clock re-sync — free, and a stale offset from a
/// stepped/slewed wall clock produces exactly this signature (an applied re-sync re-bases
/// the floor via the pump's `clock_gen` watch, clearing the elevation if that was the cause).
Resync {
above_ms: i64,
},
/// The elevation survived a re-sync attempt: flush the local receive backlog + request a
/// keyframe (the jump-to-live action), draining a real sub-threshold standing queue. The
/// pump reports execution back via [`StandingLatency::bled`]; an unexecuted action simply
/// re-arms next window.
Bleed {
above_ms: i64,
},
/// Bleed cap reached and the elevation is back: give up and say so.
Disarm {
above_ms: i64,
},
}
/// Detector for a small, constant, loss-free one-way-delay elevation — the standing state the
/// jump-to-live thresholds deliberately tolerate. Tracks the session's OWD floor (minimum of
/// report-window minimums since start / last re-base) and escalates when windows sit
/// persistently above it: re-sync first, then a bounded number of flush+keyframe bleeds, then
/// disarm. Pure state machine (no clocks, no I/O) so the escalation ladder is unit-testable.
pub(crate) struct StandingLatency {
/// Lowest window-minimum OWD seen since session start / last [`rebase`](Self::rebase).
floor_ns: Option<i128>,
/// Minimum per-frame OWD this report window; `None` = no frames yet.
window_min_ns: Option<i128>,
/// Consecutive elevated windows.
run: u32,
/// The current elevation already got its re-sync request — next escalation is a bleed.
resync_tried: bool,
bleeds: u32,
disarmed: bool,
}
impl StandingLatency {
pub(crate) fn new() -> Self {
StandingLatency {
floor_ns: None,
window_min_ns: None,
run: 0,
resync_tried: false,
bleeds: 0,
disarmed: false,
}
}
/// Feed one frame's skew-corrected OWD (capture→reassembly-complete, ns). Caller gates on a
/// live clock offset and plausibility (0 < owd < 10 s), like the ABR OWD signal.
pub(crate) fn note_frame(&mut self, owd_ns: i128) {
self.window_min_ns = Some(match self.window_min_ns {
Some(m) => m.min(owd_ns),
None => owd_ns,
});
}
/// Close a report window. `loss_free` = the window carried zero loss (loss resets the run —
/// congestion is the FEC/ABR machinery's problem, and queues under loss are not "standing").
pub(crate) fn on_window(&mut self, loss_free: bool) -> StandingLatAction {
let Some(wmin) = self.window_min_ns.take() else {
return StandingLatAction::None; // no frames this window — no evidence either way
};
let floor = *self.floor_ns.get_or_insert(wmin);
self.floor_ns = Some(floor.min(wmin));
let above_ns = wmin - floor;
if self.disarmed {
return StandingLatAction::None;
}
if !loss_free || above_ns < STANDING_LAT_THRESH_NS {
self.run = 0;
if above_ns < STANDING_LAT_THRESH_NS {
self.resync_tried = false; // elevation cleared — a future one re-syncs first again
}
return StandingLatAction::None;
}
self.run += 1;
if self.run < STANDING_LAT_WINDOWS {
return StandingLatAction::None;
}
self.run = 0; // each escalation gets a fresh observation run
let above_ms = (above_ns / 1_000_000) as i64;
if !self.resync_tried {
self.resync_tried = true;
StandingLatAction::Resync { above_ms }
} else if self.bleeds < STANDING_LAT_MAX_BLEEDS {
StandingLatAction::Bleed { above_ms }
} else {
self.disarmed = true;
StandingLatAction::Disarm { above_ms }
}
}
/// The pump executed a [`StandingLatAction::Bleed`] (flush + keyframe). The floor is KEPT: a
/// successful bleed brings OWD back down to it (elevation clears naturally); an unsuccessful
/// one leaves the elevation visible so the ladder continues toward the cap.
pub(crate) fn bled(&mut self) {
self.bleeds += 1;
self.window_min_ns = None;
}
/// A mid-stream clock re-sync was APPLIED (the pump's `clock_gen` watch): every OWD reading
/// shifted, so the floor and any elevation measured under the old offset are meaningless —
/// re-learn from scratch. The bleed budget survives (it caps keyframes per session).
pub(crate) fn rebase(&mut self) {
self.floor_ns = None;
self.window_min_ns = None;
self.run = 0;
self.resync_tried = false;
}
}
/// Client decode-stage latency accumulator for the adaptive-bitrate controller's decode signal. /// 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 /// 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 /// AU leaving [`NativeClient::next_frame`] to its decoded output) and the data-plane pump drains a
@@ -327,7 +191,6 @@ mod frame_channel_tests {
pts_ns: i as u64, pts_ns: i as u64,
flags: 0, flags: 0,
complete: true, complete: true,
received_ns: 0,
} }
} }
@@ -395,143 +258,3 @@ mod frame_channel_tests {
assert_eq!(popped(&ch), Some(total - FRAME_QUEUE_HARD_CAP as u32)); assert_eq!(popped(&ch), Some(total - FRAME_QUEUE_HARD_CAP as u32));
} }
} }
#[cfg(test)]
mod standing_latency_tests {
use super::{
StandingLatAction, StandingLatency, STANDING_LAT_MAX_BLEEDS, STANDING_LAT_THRESH_NS,
STANDING_LAT_WINDOWS,
};
const FLOOR: i128 = 2_000_000; // a healthy 2 ms LAN OWD
const ELEVATED: i128 = FLOOR + STANDING_LAT_THRESH_NS + 7_000_000; // ~one 60fps frame above
/// Run `n` windows at `owd`, asserting every window but the last returns None; returns the
/// last window's action.
fn run_windows(d: &mut StandingLatency, owd: i128, n: u32) -> StandingLatAction {
for i in 0..n {
d.note_frame(owd);
let a = d.on_window(true);
if i + 1 < n {
assert_eq!(a, StandingLatAction::None, "window {i} escalated early");
} else {
return a;
}
}
unreachable!("n > 0 by construction");
}
/// Learn a clean floor: one window at the healthy OWD.
fn learned(d: &mut StandingLatency) {
d.note_frame(FLOOR);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
#[test]
fn healthy_stream_never_escalates() {
let mut d = StandingLatency::new();
learned(&mut d);
// Jitter riding above the floor but under the threshold: never a run.
for _ in 0..(STANDING_LAT_WINDOWS * 4) {
d.note_frame(FLOOR + STANDING_LAT_THRESH_NS - 1);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
}
#[test]
fn escalation_ladder_resync_then_bleeds_then_disarm() {
let mut d = StandingLatency::new();
learned(&mut d);
// First full elevated run asks for the free fix: a clock re-sync.
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
// Re-sync didn't help (no rebase came) — each further run is a bleed, up to the cap...
for _ in 0..STANDING_LAT_MAX_BLEEDS {
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Bleed { .. }
));
d.bled();
}
// ...then the detector gives up loudly, once, and stays quiet.
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Disarm { .. }
));
d.note_frame(ELEVATED);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
#[test]
fn loss_windows_reset_the_run() {
let mut d = StandingLatency::new();
learned(&mut d);
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
d.note_frame(ELEVATED);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
// A lossy window means congestion, not a standing state: run resets...
d.note_frame(ELEVATED);
assert_eq!(d.on_window(false), StandingLatAction::None);
// ...so the ladder needs the full run again before acting.
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
}
#[test]
fn recovery_resets_the_ladder_to_resync_first() {
let mut d = StandingLatency::new();
learned(&mut d);
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
// The elevation clears on its own (e.g. the successful bleed case, or transient): the
// next episode starts back at the free escalation, not at a bleed.
d.note_frame(FLOOR);
assert_eq!(d.on_window(true), StandingLatAction::None);
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
}
#[test]
fn applied_resync_rebases_and_clears_a_stale_offset_elevation() {
let mut d = StandingLatency::new();
learned(&mut d);
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
// The re-sync APPLIES (pump sees clock_gen move) → rebase. The corrected offset brings
// OWD readings back to truth; the floor re-learns and nothing ever escalates to a bleed.
d.rebase();
for _ in 0..(STANDING_LAT_WINDOWS * 2) {
d.note_frame(FLOOR);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
}
#[test]
fn empty_windows_are_no_evidence() {
let mut d = StandingLatency::new();
learned(&mut d);
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
d.note_frame(ELEVATED);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
// A frameless window (paused stream) neither advances nor resets the run...
assert_eq!(d.on_window(true), StandingLatAction::None);
// ...so one more elevated window completes it.
d.note_frame(ELEVATED);
assert!(matches!(
d.on_window(true),
StandingLatAction::Resync { .. }
));
}
}
+6 -73
View File
@@ -1,9 +1,8 @@
//! The client worker: QUIC handshake + control/input/datagram tasks + the blocking data-plane pump. //! The client worker: QUIC handshake + control/input/datagram tasks + the blocking data-plane pump.
use super::frame_channel::{ use super::frame_channel::{
StandingLatAction, StandingLatency, CLOCK_RESYNC_INTERVAL, FLUSH_AFTER, FLUSH_COOLDOWN, CLOCK_RESYNC_INTERVAL, FLUSH_AFTER, FLUSH_COOLDOWN, FLUSH_LATENCY,
FLUSH_LATENCY, NOOP_CLOCK_FLUSHES_TO_DISARM, NOOP_FLUSH_DATAGRAMS, QUEUE_HIGH, QUEUE_LOW, NOOP_CLOCK_FLUSHES_TO_DISARM, NOOP_FLUSH_DATAGRAMS, QUEUE_HIGH, QUEUE_LOW, STANDING_TIME,
STANDING_TIME,
}; };
use super::worker::reject_from_close; use super::worker::reject_from_close;
use super::*; use super::*;
@@ -515,19 +514,15 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// late exactly then) — keep the old estimate and let the next // late exactly then) — keep the old estimate and let the next
// periodic batch try again. // periodic batch try again.
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) { if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
// info, not debug: ≤1/min, and it is THE forensic clock_offset.store(offset_ns, Ordering::Relaxed);
// trail for a stale-offset (stepped/slewed wall clock) clock_gen.fetch_add(1, Ordering::Relaxed);
// latency plateau — the 2026-07 two-pair investigation tracing::debug!(
// had to reconstruct this blind.
tracing::info!(
offset_ns, offset_ns,
rtt_us = rtt_ns / 1000, rtt_us = rtt_ns / 1000,
"mid-stream clock re-sync applied" "mid-stream clock re-sync applied"
); );
clock_offset.store(offset_ns, Ordering::Relaxed);
clock_gen.fetch_add(1, Ordering::Relaxed);
} else { } else {
tracing::info!( tracing::debug!(
rtt_us = rtt_ns / 1000, rtt_us = rtt_ns / 1000,
"clock re-sync batch discarded — RTT above the \ "clock re-sync batch discarded — RTT above the \
connect-time baseline (congested window)" connect-time baseline (congested window)"
@@ -734,12 +729,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
let mut clock_detector_armed = true; let mut clock_detector_armed = true;
let mut resync_wanted = false; let mut resync_wanted = false;
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed); let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
// Standing-latency bleed (see StandingLatency): the third detector, for the small,
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
// tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing
// backlog, or a stale clock offset after a wall-clock step, either of which otherwise
// reads as permanent extra "network" latency for the rest of the session.
let mut standing_lat = StandingLatency::new();
while !pump_shutdown.load(Ordering::SeqCst) { while !pump_shutdown.load(Ordering::SeqCst) {
// The live host↔client offset: re-loaded every iteration so an applied mid-stream // The live host↔client offset: re-loaded every iteration so an applied mid-stream
// re-sync takes effect on the very next frame's latency math. // re-sync takes effect on the very next frame's latency math.
@@ -751,10 +740,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
seen_clock_gen = gen; seen_clock_gen = gen;
stale_since = None; stale_since = None;
noop_clock_flushes = 0; noop_clock_flushes = 0;
// Every OWD reading shifted with the offset — the standing-latency floor and
// any elevation measured under the old one are meaningless now. If a stale
// offset WAS the elevation, this is also the moment it gets fixed.
standing_lat.rebase();
if !clock_detector_armed { if !clock_detector_armed {
clock_detector_armed = true; clock_detector_armed = true;
tracing::info!( tracing::info!(
@@ -858,51 +843,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
window_dropped, window_dropped,
); );
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm })); let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
// Standing-latency bleed: close the detector's window with this report's loss
// verdict and run its escalation ladder — re-sync first (free; a stale offset
// from a stepped wall clock produces exactly this signature and the applied
// re-sync rebases the floor), then a bounded flush+keyframe (drains a real
// sub-threshold standing backlog the jump-to-live thresholds tolerate), then a
// loud disarm (the path latency itself changed; nothing local fixes that).
match standing_lat.on_window(loss_ppm == 0 && window_dropped == 0) {
StandingLatAction::None => {}
StandingLatAction::Resync { above_ms } => {
tracing::info!(
above_ms,
"standing latency above the session floor with zero loss — \
requesting a clock re-sync first (a stale offset reads exactly \
like this)"
);
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
}
StandingLatAction::Bleed { above_ms } => {
// Shares the jump-to-live cooldown: an unexecuted bleed simply re-arms
// over the next windows (the detector's run rebuilds).
if last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN) {
last_flush = Some(Instant::now());
flush_in_window = true;
let flushed = session.flush_backlog().unwrap_or(0);
let dropped = frames.clear();
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
standing_lat.bled();
tracing::warn!(
above_ms,
flushed_datagrams = flushed,
dropped_frames = dropped,
"standing latency survived a clock re-sync — bled the local \
backlog (flush + keyframe)"
);
}
}
StandingLatAction::Disarm { above_ms } => {
tracing::warn!(
above_ms,
"standing latency persists after a re-sync and every bleed — not \
local, not clock; the path latency changed. Leaving it be \
(reconnect re-baselines)"
);
}
}
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then // Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
// feed the controller this window's congestion signals; a decision becomes a // feed the controller this window's congestion signals; a decision becomes a
// SetBitrate on the control stream. // SetBitrate on the control stream.
@@ -1041,13 +981,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
if clock_offset_ns != 0 && lat_ns > 0 { if clock_offset_ns != 0 && lat_ns > 0 {
owd_sum_ns += lat_ns; owd_sum_ns += lat_ns;
owd_frames += 1; owd_frames += 1;
// The standing-latency detector rides the same signal, but off the
// window MINIMUM (robust against jitter/burst spikes — a standing
// state elevates the floor itself). Same 10 s plausibility clamp as
// the hn stats use.
if lat_ns < 10_000_000_000 {
standing_lat.note_frame(lat_ns);
}
} }
if clock_detector_armed if clock_detector_armed
&& clock_offset_ns != 0 && clock_offset_ns != 0
+1 -6
View File
@@ -83,12 +83,7 @@ pub use stats::Stats;
/// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` + /// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
/// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control /// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control
/// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged. /// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged.
/// v9: `PunktfunkFrame` grew `received_ns` — the reassembly-completion receipt stamp, so pub const ABI_VERSION: u32 = 8;
/// embedders stop stamping receipt at the hand-off pull (which folds the pre-decode queue wait
/// into apparent network latency). Struct-size change on the frame poll surface = a hard ABI
/// break for embedders reading `PunktfunkFrame`; nothing on the wire moved, so [`WIRE_VERSION`]
/// is unchanged.
pub const ABI_VERSION: u32 = 9;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -489,7 +489,6 @@ impl Reassembler {
pts_ns: done.pts_ns, pts_ns: done.pts_ns,
flags: done.user_flags, flags: done.user_flags,
complete: true, complete: true,
received_ns: 0, // stamped by Session::poll_frame at the session boundary
})); }));
} }
Ok(None) Ok(None)
@@ -593,7 +592,6 @@ impl ReassemblyWindow {
pts_ns: f.pts_ns, pts_ns: f.pts_ns,
flags: f.user_flags, flags: f.user_flags,
complete: false, complete: false,
received_ns: 0, // stamped by Session::poll_frame at the session boundary
}); });
} }
} }
+3 -23
View File
@@ -30,14 +30,6 @@ pub struct Frame {
/// ([`crate::packet::USER_FLAG_CHUNK_ALIGNED`]) are ever delivered partial; missing /// ([`crate::packet::USER_FLAG_CHUNK_ALIGNED`]) are ever delivered partial; missing
/// shard ranges are zero-filled at their exact offsets. /// shard ranges are zero-filled at their exact offsets.
pub complete: bool, 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 /// One end of a stream. Constructed for a single [`Role`]; calling the other role's
@@ -90,18 +82,6 @@ pub struct Session {
lane_scratch: Vec<Vec<u8>>, 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): /// 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 /// 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 /// (≥ ~125 µs of ~1 µs/packet work) dwarfs the hand-off. ≈300 KB of wire, i.e. ≥150 Mbps
@@ -704,7 +684,7 @@ impl Session {
// Nothing new on the wire — hand over an aged-out partial if one is // Nothing new on the wire — hand over an aged-out partial if one is
// waiting (it can only get staler). // waiting (it can only get staler).
if let Some(p) = self.reassembler.take_partial() { if let Some(p) = self.reassembler.take_partial() {
return Ok(stamp_received(p)); return Ok(p);
} }
return Err(PunktfunkError::NoFrame); return Err(PunktfunkError::NoFrame);
} }
@@ -768,12 +748,12 @@ impl Session {
} }
if let Some(frame) = pushed { if let Some(frame) = pushed {
StatsCounters::add(&self.stats.frames_completed, 1); StatsCounters::add(&self.stats.frames_completed, 1);
return Ok(stamp_received(frame)); return Ok(frame);
} }
// A push that completed nothing may still have aged a partial out — deliver it // A push that completed nothing may still have aged a partial out — deliver it
// ahead of further draining (its successors are already arriving). // ahead of further draining (its successors are already arriving).
if let Some(p) = self.reassembler.take_partial() { if let Some(p) = self.reassembler.take_partial() {
return Ok(stamp_received(p)); return Ok(p);
} }
} }
} }
+1 -12
View File
@@ -37,12 +37,7 @@
// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` + // `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
// `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control // `punktfunk_connection_next_clipboard`. Additive; the wire grows only backward-compatible control
// messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged. // messages (0x40-0x44) and a new `Welcome::host_caps` bit, so [`WIRE_VERSION`] is unchanged.
// v9: `PunktfunkFrame` grew `received_ns` — the reassembly-completion receipt stamp, so #define ABI_VERSION 8
// embedders stop stamping receipt at the hand-off pull (which folds the pre-decode queue wait
// into apparent network latency). Struct-size change on the frame poll surface = a hard ABI
// break for embedders reading `PunktfunkFrame`; nothing on the wire moved, so [`WIRE_VERSION`]
// is unchanged.
#define ABI_VERSION 9
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -1117,12 +1112,6 @@ typedef struct {
uint32_t frame_index; uint32_t frame_index;
uint64_t pts_ns; uint64_t pts_ns;
uint32_t flags; uint32_t flags;
// Wall-clock reassembly-completion instant (ns since the Unix epoch, CLOCK_REALTIME — the
// clock `pts_ns` and the skew handshake use). THIS is the receipt stamp for latency math:
// a stamp the embedder takes itself at the poll return additionally contains the
// pre-decode hand-off queue wait, so a client-side standing backlog would masquerade as
// network latency (ABI v9 — the 2026-07 two-pair standing-latency investigation).
uint64_t received_ns;
} PunktfunkFrame; } PunktfunkFrame;
// A single input event. `#[repr(C)]` — shared verbatim with the C ABI as // A single input event. `#[repr(C)]` — shared verbatim with the C ABI as