Compare commits

..

2 Commits

Author SHA1 Message Date
enricobuehler 7cde80ce84 fix(encode): NVENC partial-init session leak + three backend-parity gaps
All four found in the pf-encode quality sweep and verified against source.

- NVENC partial-init leak (BOTH platforms, high): `init_session` publishes
  `self.encoder` — and on Windows charges LIVE_SESSION_UNITS — *before* its
  remaining fallible steps (bitstream buffers; on Linux also the input-surface
  alloc and `register_resource`). A failure there left a live session with
  `inited == false`, and every guard on the re-init path keys off `inited`, so
  the next submit skipped teardown and overwrote `self.encoder`: the session
  leaked permanently toward the driver's per-process cap, and its budget units
  never returned, progressively starving parallel-display admission. `teardown`
  already keys off `encoder.is_null()` rather than `inited`, so it cleans up
  exactly this half-built state — it just was never called. Now invoked on the
  `init_session` error path on both platforms.

- `can_encode_10bit` asked the wrong backend (medium): it resolved via
  `linux_auto_is_vaapi`, which ignores `encoder_pref`, while `can_encode_444`
  and `open_video` honour it. On a host that forces a backend (e.g.
  `encoder_pref = "vaapi"` on an NVIDIA box) the probe answered for NVENC while
  the session opened VAAPI, so the negotiated bit depth — and the HDR/SDR colour
  label derived from it — described a backend that never ran. Now uses the same
  `linux_zero_copy_is_vaapi` mirror, and `linux_auto_is_vaapi` carries a warning
  that it resolves the `auto` case only and is not a dispatch mirror.

- Linux software arm ignored SW_BITRATE_CEIL (low): the Windows arm clamped
  openh264 to 100 Mbps, the Linux arm passed the full negotiated rate. The
  constant is now module-scope so both arms share one value.

- QSV/AMF env-parity (low): `PUNKTFUNK_IR_PERIOD_FRAMES` was a no-op on QSV
  despite the comment claiming parity with AMF, and `PUNKTFUNK_NO_QSV_LTR` /
  `PUNKTFUNK_INTRA_REFRESH` had dropped AMF's `trim()` and `yes`/`on` spellings,
  so a value with stray whitespace silently did nothing on Intel while the same
  value worked on AMD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:43:16 +02:00
enricobuehler ab679a56e7 chore(release): bump workspace version to 0.15.0
MINOR, not patch: 44 commits since v0.14.0 split 21 features / 19 fixes, and
/api/v1 gained the per-scanner library-toggle endpoints (additive, already
regenerated into api/openapi.json by c2bba134). sdk-publish pushes this version
to consumers, so a patch bump would understate a new API surface to anyone
pinning ~0.14.

Headline work is the desktop clients drawing level with the Apple revamp: the
Windows client picked up settings parity, a findable console UI in the header,
the shared clipboard (with a per-host toggle), PyroWave decode in the codec
picker, and D3D11VA-first decode + HDR pass-through on Intel; the Linux GTK4
client got the same category-map settings rebuild. Apple landed the intent-based
presenter rebuild and the Dynamic Island redesign. Also: the punktfunk-host
plugins CLI, per-scanner library toggles in the console, and PyroWave raw-dmabuf
zero-copy capture on the Linux NVIDIA host.

Notable fixes: LTR-RFI loss recovery under sustained loss, two encode teardown
memory-safety holes, the audio first-open retry that was leaving sessions
silent, and the GameStream stream-marker announcement on the compat plane.

Every workspace crate is on version.workspace = true, so this stayed a one-line
bump plus the lock sync. (fec-rs, pf-driver-proto, usbip-sim, the Windows driver
crates and pf-vkhdr-layer are deliberately versioned independently and stay put.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:32:43 +02:00
77 changed files with 550 additions and 3442 deletions
Generated
+27 -27
View File
@@ -2159,7 +2159,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.16.0" version = "0.15.0"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2264,7 +2264,7 @@ dependencies = [
[[package]] [[package]]
name = "libvpl-sys" name = "libvpl-sys"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-capture" name = "pf-capture"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2832,7 +2832,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-clipboard" name = "pf-clipboard"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2850,7 +2850,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-encode" name = "pf-encode"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2894,7 +2894,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2903,7 +2903,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-frame" name = "pf-frame"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@@ -2915,7 +2915,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-gpu" name = "pf-gpu"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-host-config", "pf-host-config",
@@ -2929,11 +2929,11 @@ dependencies = [
[[package]] [[package]]
name = "pf-host-config" name = "pf-host-config"
version = "0.16.0" version = "0.15.0"
[[package]] [[package]]
name = "pf-inject" name = "pf-inject"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2961,14 +2961,14 @@ dependencies = [
[[package]] [[package]]
name = "pf-paths" name = "pf-paths"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"tracing", "tracing",
] ]
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2983,7 +2983,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-vdisplay" name = "pf-vdisplay"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -3013,7 +3013,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-win-display" name = "pf-win-display"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-paths", "pf-paths",
@@ -3025,7 +3025,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-zerocopy" name = "pf-zerocopy"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3221,7 +3221,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3237,7 +3237,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3268,7 +3268,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"ffmpeg-next", "ffmpeg-next",
@@ -3287,7 +3287,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -3318,7 +3318,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3400,7 +3400,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3414,7 +3414,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
@@ -3437,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "pyrowave-sys" name = "pyrowave-sys"
version = "0.16.0" version = "0.15.0"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
+1 -1
View File
@@ -48,7 +48,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.16.0" version = "0.15.0"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.16.0" "version": "0.15.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -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">
+61 -88
View File
@@ -1310,26 +1310,21 @@ mod pipewire {
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position. /// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) { fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued). // SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for // `spa_buffer_find_meta_data` scans its metadata array for a `SPA_META_Cursor` of at least
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically // `size_of::<spa_meta_cursor>()` bytes and returns a pointer into that buffer's metadata
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below // (or null), valid until requeue. The size argument matches the struct the result is cast to.
// are ALL producer-written, and without a bound against the actual region they drive let cur = unsafe {
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read spa::sys::spa_buffer_find_meta_data(
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot spa_buf,
// catch). Every offset below is validated against `region_size` with checked arithmetic, spa::sys::SPA_META_Cursor,
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr. std::mem::size_of::<spa::sys::spa_meta_cursor>(),
let meta = ) as *const spa::sys::spa_meta_cursor
unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor as u32) }; };
if meta.is_null() { if cur.is_null() {
return; return;
} }
// SAFETY: `meta` is non-null and points into the held buffer's metadata array. // SAFETY: `cur` is non-null and points to a `spa_meta_cursor` of at least its own size
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) }; // inside the held buffer (guaranteed by the size arg above), so every field read is in bounds.
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
return;
}
let cur = data as *const spa::sys::spa_meta_cursor;
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe { let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
( (
(*cur).id, (*cur).id,
@@ -1352,18 +1347,13 @@ mod pipewire {
// Position-only update — keep the cached bitmap. // Position-only update — keep the cached bitmap.
return; return;
} }
let bmp_off = bmp_off as usize; // SAFETY: `bitmap_offset` is a byte offset from `cur` to a `spa_meta_bitmap`, which the
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it — // producer placed inside the same meta region it sized for this cursor (>= the size we
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata. // requested). The resulting pointer is in bounds and aligned for `spa_meta_bitmap`.
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) { let bmp =
Some(end) if end <= region_size => {} unsafe { (cur as *const u8).add(bmp_off as usize) as *const spa::sys::spa_meta_bitmap };
_ => return, // SAFETY: `bmp` is the in-bounds, aligned `spa_meta_bitmap` pointer computed just above; the
} // producer fully initialized this header, so reading its scalar fields is sound.
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
// so the header is fully in bounds; the producer places it aligned as before.
let bmp = unsafe { data.add(bmp_off) as *const spa::sys::spa_meta_bitmap };
// SAFETY: `bmp` is the in-bounds `spa_meta_bitmap` header validated just above; reading its
// scalar fields is sound.
let (vfmt, bw, bh, stride, pix_off) = unsafe { let (vfmt, bw, bh, stride, pix_off) = unsafe {
( (
(*bmp).format, (*bmp).format,
@@ -1379,27 +1369,10 @@ mod pipewire {
} }
let row = bw as usize * 4; let row = bw as usize * 4;
let stride = if stride < row { row } else { stride }; let stride = if stride < row { row } else { stride };
// `span` is the exact byte extent the strided loop reads: `stride·(bh-1) + row`. Compute it let span = stride * (bh as usize - 1) + row;
// with checked arithmetic (a producer stride near `i32::MAX` would otherwise overflow) and // SAFETY: the bitmap pixels live at `bmp + pix_off` for `span` bytes, within the
// require the whole pixel block `[bmp_off + pix_off, +span)` to lie inside the region before // producer-sized meta region. `span` is the exact extent the strided copy below reads.
// fabricating the slice — this is the check whose absence made the read go out of bounds. let src = unsafe { std::slice::from_raw_parts((bmp as *const u8).add(pix_off), span) };
let span = match stride
.checked_mul(bh as usize - 1)
.and_then(|v| v.checked_add(row))
{
Some(s) => s,
None => return,
};
match bmp_off
.checked_add(pix_off)
.and_then(|v| v.checked_add(span))
{
Some(end) if end <= region_size => {}
_ => return,
}
// SAFETY: `bmp_off + pix_off + span <= region_size` (checked directly above), so the slice
// is fully within the producer's meta region; `span` is exactly the strided loop's extent.
let src = unsafe { std::slice::from_raw_parts(data.add(bmp_off + pix_off), span) };
let mut rgba = vec![0u8; bw as usize * bh as usize * 4]; let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
for y in 0..bh as usize { for y in 0..bh as usize {
for x in 0..bw as usize { for x in 0..bw as usize {
@@ -2191,37 +2164,36 @@ mod pipewire {
} }
}) })
.process(|stream, ud| { .process(|stream, ud| {
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its // PipeWire dispatches this from a C trampoline with no catch_unwind; a
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue // panic crossing that FFI boundary would abort the whole host. Contain it.
// the older ones, keep only the newest. This dequeue/requeue runs OUTSIDE the
// `catch_unwind` below — they are non-panicking C FFI pointer ops, and `newest` is
// requeued exactly once AFTER the panic-containing region. Previously the whole thing was
// inside the catch, so a caught panic (in `update_cursor_meta`/`consume_frame`) stranded
// `newest` forever, permanently shrinking the stream's fixed pool until capture wedged.
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on the
// loop thread; `dequeue_raw_buffer` returns a stream-owned `*mut pw_buffer` or null
// (null-checked), single-threaded so no concurrent access.
let mut newest = unsafe { stream.dequeue_raw_buffer() };
if newest.is_null() {
return;
}
let mut drained = 1u32;
loop {
// SAFETY: same stream/loop-thread contract; returns the next stream-owned buffer or null.
let next = unsafe { stream.dequeue_raw_buffer() };
if next.is_null() {
break;
}
// SAFETY: `newest` was dequeued from this stream and not yet requeued; we immediately
// overwrite it, so the requeued pointer is never touched again.
unsafe { stream.queue_raw_buffer(newest) };
newest = next;
drained += 1;
}
// PipeWire dispatches from a C trampoline with no catch_unwind; a panic crossing that FFI
// boundary would abort the whole host. Contain the inspect/consume work — the only Rust
// code here that can panic — and requeue `newest` unconditionally after it.
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
// queued buffers, requeue the older ones, keep only the newest.
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
// the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
// a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
// null-checked before any use. The loop is single-threaded, so no concurrent access.
let mut newest = unsafe { stream.dequeue_raw_buffer() };
if newest.is_null() {
return;
}
let mut drained = 1u32;
loop {
// SAFETY: same stream/loop-thread contract as the dequeue above; each call returns
// the next stream-owned `*mut pw_buffer` or null (null-checked before use).
let next = unsafe { stream.dequeue_raw_buffer() };
if next.is_null() {
break;
}
// SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
// stream and not yet requeued; `pw_stream_queue_buffer` hands ownership back to the
// stream. We immediately overwrite `newest = next`, so the requeued pointer is never
// touched again (no use-after-requeue). Loop thread, single-threaded.
unsafe { stream.queue_raw_buffer(newest) };
newest = next;
drained += 1;
}
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued); // SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field // `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
// load through a valid pointer — no mutation or aliasing. // load through a valid pointer — no mutation or aliasing.
@@ -2300,18 +2272,19 @@ mod pipewire {
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)" "capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
); );
} }
// Skip this stale/cursor buffer — `newest` is requeued unconditionally below. // SAFETY: `newest` is the non-null buffer we own (dequeued, never requeued on this
// skip path); hand it back to the stream exactly once and return without touching it
// again. Loop thread inside `.process`.
unsafe { stream.queue_raw_buffer(newest) };
return; return;
} }
consume_frame(ud, spa_buf); consume_frame(ud, spa_buf);
// SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
// from `newest`), so requeuing the owned `newest` exactly once here is sound — no
// use-after-requeue. Loop thread inside `.process`.
unsafe { stream.queue_raw_buffer(newest) };
})); }));
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
// or a caught panic in the closure above. This single requeue is what keeps the fixed
// buffer pool from draining.
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed
// inside the closure above; `newest` was dequeued from this stream and not yet requeued.
unsafe { stream.queue_raw_buffer(newest) };
if outcome.is_err() { if outcome.is_err() {
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad // In the per-frame `.process` callback: a deterministic panic (e.g. a bad
// format) would fire this every frame, so power-of-two throttle it — enough to // format) would fire this every frame, so power-of-two throttle it — enough to
@@ -1341,12 +1341,6 @@ impl IddPushCapturer {
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
self.hdr_p010_conv = None; self.hdr_p010_conv = None;
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
// the freshly HDR-formatted pyro ring — every frame corrupted. `ensure_pyro_conv` only
// builds when None, so it must be reset here like its siblings.
self.pyro_conv = None;
self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode
self.pyro_last = None; self.pyro_last = None;
self.out_idx = 0; self.out_idx = 0;
@@ -1867,7 +1861,6 @@ impl IddPushCapturer {
cbcr, cbcr,
fence_handle, fence_handle,
fence_value, fence_value,
ring_gen: self.generation,
}), }),
) )
} else { } else {
@@ -1926,7 +1919,6 @@ impl IddPushCapturer {
cbcr: dst_cbcr, cbcr: dst_cbcr,
fence_handle, fence_handle,
fence_value, fence_value,
ring_gen: self.generation,
}), }),
}), }),
cursor: None, cursor: None,
+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;
-10
View File
@@ -293,16 +293,6 @@ pub trait Encoder: Send {
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire. /// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly). /// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
fn set_wire_chunking(&mut self, _shard_payload: usize) {} fn set_wire_chunking(&mut self, _shard_payload: usize) {}
/// How many frames the CAPTURER guarantees the encoder may hold in flight before it starts
/// reusing an input texture (`Capturer::pipeline_depth`). Backends that encode the capturer's
/// textures IN PLACE — no `CopyResource` — must not pipeline deeper than this: the capturer
/// rotates its output ring per delivered frame with no regard for encode completion, so a
/// deeper pipeline lets it overwrite a texture mid-encode. That is visual corruption (torn or
/// mixed frames), not UB, so it fails silently and intermittently.
///
/// Called once by the session glue after the capturer is known; a backend that copies its
/// input, or is synchronous, ignores it. Default: no-op.
fn set_input_ring_depth(&mut self, _depth: usize) {}
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll) /// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`. /// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
fn flush(&mut self) -> Result<()>; fn flush(&mut self) -> Result<()>;
+1 -18
View File
@@ -1176,24 +1176,7 @@ 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.
let r = unsafe { self.encode_frame(frame) }; 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 {
+5 -22
View File
@@ -183,8 +183,7 @@ pub(crate) unsafe fn make_plain_image(
None, None,
)?; )?;
let req = device.get_image_memory_requirements(img); let req = device.get_image_memory_requirements(img);
// Unwind on failure: callers (the encoders' open paths) only ever see the completed triple. let mem = device.allocate_memory(
let mem = match device.allocate_memory(
&vk::MemoryAllocateInfo::default() &vk::MemoryAllocateInfo::default()
.allocation_size(req.size) .allocation_size(req.size)
.memory_type_index(find_mem( .memory_type_index(find_mem(
@@ -193,24 +192,8 @@ pub(crate) unsafe fn make_plain_image(
vk::MemoryPropertyFlags::DEVICE_LOCAL, vk::MemoryPropertyFlags::DEVICE_LOCAL,
)), )),
None, None,
) { )?;
Ok(m) => m, device.bind_image_memory(img, mem, 0)?;
Err(e) => { let view = make_view(device, img, fmt, 0)?;
device.destroy_image(img, None); Ok((img, mem, view))
return Err(e.into());
}
};
if let Err(e) = device.bind_image_memory(img, mem, 0) {
device.destroy_image(img, None);
device.free_memory(mem, None);
return Err(e.into());
}
match make_view(device, img, fmt, 0) {
Ok(view) => Ok((img, mem, view)),
Err(e) => {
device.destroy_image(img, None);
device.free_memory(mem, None);
Err(e)
}
}
} }
+144 -419
View File
@@ -37,12 +37,6 @@ 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;
@@ -120,10 +114,6 @@ fn build_h265_rps_s0(
/// `submit()` records into a free slot and returns without blocking; `poll()` reads back the /// `submit()` records into a free slot and returns without blocking; `poll()` reads back the
/// oldest slot once its `fence` signals. Everything here is written by one frame and read by the /// oldest slot once its `fence` signals. Everything here is written by one frame and read by the
/// next-but-K, so it cannot be shared while a submission is outstanding. /// next-but-K, so it cannot be shared while a submission is outstanding.
///
/// [`Frame::default`] is the all-null placeholder `open_inner` pre-pushes into its unwind guard so
/// `make_frame` can build in place; destroying one is a no-op (`vkDestroy*` ignores null handles).
#[derive(Default)]
struct Frame { struct Frame {
compute_cmd: vk::CommandBuffer, // CSC (compute+transfer) compute_cmd: vk::CommandBuffer, // CSC (compute+transfer)
cmd: vk::CommandBuffer, // encode queue cmd: vk::CommandBuffer, // encode queue
@@ -294,11 +284,6 @@ impl VulkanVideoEncoder {
None, None,
) )
.context("create instance")?; .context("create instance")?;
// From here on, every created object is mirrored into `guard` the moment it exists, so any
// early `?`/`bail!` unwinds exactly what was built (see [`VkTeardown`]). The locals keep
// aliasing the handles for the rest of the build; only the `Ok(Self)` hand-off at the
// bottom disarms the guard.
let mut guard = VkTeardown::new(instance.clone());
let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance); let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance);
@@ -435,8 +420,6 @@ impl VulkanVideoEncoder {
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device); let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
let vq_dev = ash::khr::video_queue::Device::new(&instance, &device); let vq_dev = ash::khr::video_queue::Device::new(&instance, &device);
let venc_dev = ash::khr::video_encode_queue::Device::new(&instance, &device); let venc_dev = ash::khr::video_encode_queue::Device::new(&instance, &device);
guard.device = Some(device.clone());
guard.vq_dev = Some(vq_dev.clone());
// ---- video session ---- (AV1 pins the max level from caps via a chained create-info) // ---- video session ---- (AV1 pins the max level from caps via a chained create-info)
let av1_sci = av1b::VideoEncodeAV1SessionCreateInfoKHR { let av1_sci = av1b::VideoEncodeAV1SessionCreateInfoKHR {
@@ -470,13 +453,13 @@ impl VulkanVideoEncoder {
if r != vk::Result::SUCCESS { if r != vk::Result::SUCCESS {
bail!("create_video_session: {r:?}"); bail!("create_video_session: {r:?}");
} }
guard.session = session;
// bind session memory // bind session memory
let get_mem = vq_dev.fp().get_video_session_memory_requirements_khr; let get_mem = vq_dev.fp().get_video_session_memory_requirements_khr;
let mut n = 0u32; let mut n = 0u32;
let _ = get_mem(device.handle(), session, &mut n, std::ptr::null_mut()); let _ = get_mem(device.handle(), session, &mut n, std::ptr::null_mut());
let mut reqs = vec![vk::VideoSessionMemoryRequirementsKHR::default(); n as usize]; let mut reqs = vec![vk::VideoSessionMemoryRequirementsKHR::default(); n as usize];
let _ = get_mem(device.handle(), session, &mut n, reqs.as_mut_ptr()); let _ = get_mem(device.handle(), session, &mut n, reqs.as_mut_ptr());
let mut session_mem = Vec::new();
let mut binds = Vec::new(); let mut binds = Vec::new();
for rq in &reqs { for rq in &reqs {
let mr = rq.memory_requirements; let mr = rq.memory_requirements;
@@ -491,7 +474,7 @@ impl VulkanVideoEncoder {
.memory_type_index(ti), .memory_type_index(ti),
None, None,
)?; )?;
guard.session_mem.push(m); session_mem.push(m);
binds.push( binds.push(
vk::BindVideoSessionMemoryInfoKHR::default() vk::BindVideoSessionMemoryInfoKHR::default()
.memory_bind_index(rq.memory_bind_index) .memory_bind_index(rq.memory_bind_index)
@@ -529,7 +512,6 @@ impl VulkanVideoEncoder {
build_parameters_h265(&device, &vq_dev, &venc_dev, session, w, h, rw, rh)?; build_parameters_h265(&device, &vq_dev, &venc_dev, session, w, h, rw, rh)?;
(p, hdr, Vec::new()) (p, hdr, Vec::new())
}; };
guard.params = params;
// ---- DPB image (NV12 OPTIMAL, ring of slots) — encode queue only ---- // ---- DPB image (NV12 OPTIMAL, ring of slots) — encode queue only ----
let mut profile_list = let mut profile_list =
@@ -545,13 +527,9 @@ impl VulkanVideoEncoder {
&mut profile_list, &mut profile_list,
&[], &[],
)?; )?;
guard.dpb_image = dpb_image; let dpb_views: Vec<vk::ImageView> = (0..DPB_SLOTS)
guard.dpb_mem = dpb_mem; .map(|slot| make_view(&device, dpb_image, NV12, slot))
for slot in 0..DPB_SLOTS { .collect::<Result<_>>()?;
guard
.dpb_views
.push(make_view(&device, dpb_image, NV12, slot)?);
}
// NV12 encode-src, CSC scratch (Y/UV), bitstream, query and command buffers are all per // NV12 encode-src, CSC scratch (Y/UV), bitstream, query and command buffers are all per
// in-flight frame (built in `make_frame` below); only the queue-family list is shared here. // in-flight frame (built in `make_frame` below); only the queue-family list is shared here.
@@ -570,11 +548,9 @@ impl VulkanVideoEncoder {
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE), .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE),
None, None,
)?; )?;
guard.sampler = sampler;
let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?; let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?;
let shader = let shader =
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?; device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
guard.shader = shader;
let sb = |b: u32, t: vk::DescriptorType| { let sb = |b: u32, t: vk::DescriptorType| {
vk::DescriptorSetLayoutBinding::default() vk::DescriptorSetLayoutBinding::default()
.binding(b) .binding(b)
@@ -592,7 +568,6 @@ impl VulkanVideoEncoder {
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings), &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
None, None,
)?; )?;
guard.csc_dsl = csc_dsl;
let dsls = [csc_dsl]; let dsls = [csc_dsl];
// Push constant: cursor {ivec2 origin, ivec2 size} = 16 bytes (size.x<=0 disables the blend). // Push constant: cursor {ivec2 origin, ivec2 size} = 16 bytes (size.x<=0 disables the blend).
let pc_ranges = [vk::PushConstantRange::default() let pc_ranges = [vk::PushConstantRange::default()
@@ -605,7 +580,6 @@ impl VulkanVideoEncoder {
.push_constant_ranges(&pc_ranges), .push_constant_ranges(&pc_ranges),
None, None,
)?; )?;
guard.csc_layout = csc_layout;
let stage = vk::PipelineShaderStageCreateInfo::default() let stage = vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::COMPUTE) .stage(vk::ShaderStageFlags::COMPUTE)
.module(shader) .module(shader)
@@ -619,10 +593,7 @@ impl VulkanVideoEncoder {
None, None,
) )
.map_err(|(_, e)| e)?[0]; .map_err(|(_, e)| e)?[0];
guard.csc_pipe = csc_pipe;
device.destroy_shader_module(shader, None); device.destroy_shader_module(shader, None);
// The shader is gone — null the guard's copy so a later failure doesn't unwind it again.
guard.shader = vk::ShaderModule::null();
// One CSC descriptor set + its own Y/UV/NV12/bitstream per in-flight frame. // One CSC descriptor set + its own Y/UV/NV12/bitstream per in-flight frame.
let nframes = ring_depth(); let nframes = ring_depth();
let pool_sizes = [ let pool_sizes = [
@@ -640,7 +611,6 @@ impl VulkanVideoEncoder {
.pool_sizes(&pool_sizes), .pool_sizes(&pool_sizes),
None, None,
)?; )?;
guard.csc_pool = csc_pool;
// ---- bitstream size (shared) + shared command pools ---- // ---- bitstream size (shared) + shared command pools ----
let bs_size = align_up( let bs_size = align_up(
@@ -653,21 +623,17 @@ impl VulkanVideoEncoder {
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER), .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER),
None, None,
)?; )?;
guard.cmd_pool = cmd_pool;
let compute_pool = device.create_command_pool( let compute_pool = device.create_command_pool(
&vk::CommandPoolCreateInfo::default() &vk::CommandPoolCreateInfo::default()
.queue_family_index(compute_family) .queue_family_index(compute_family)
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER), .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER),
None, None,
)?; )?;
guard.compute_pool = compute_pool;
// ---- build the in-flight frame ring ---- // ---- build the in-flight frame ring ----
let mut frames = Vec::with_capacity(nframes);
for _ in 0..nframes { for _ in 0..nframes {
// Pre-push a null Frame and build it in place, so a mid-`make_frame` failure leaves frames.push(make_frame(
// the partial handles in the guard rather than losing them with the Err.
guard.frames.push(Frame::default());
make_frame(
&device, &device,
&mem_props, &mem_props,
w, w,
@@ -681,17 +647,9 @@ impl VulkanVideoEncoder {
compute_pool, compute_pool,
bs_size, bs_size,
sampler, sampler,
guard.frames.last_mut().expect("frame just pushed"), )?);
)?;
} }
// Fully constructed: move the built collections out and disarm the guard — from here every
// handle is owned by `Self`, whose own `Drop` is the (only) teardown path.
let session_mem = std::mem::take(&mut guard.session_mem);
let dpb_views = std::mem::take(&mut guard.dpb_views);
let frames = std::mem::take(&mut guard.frames);
std::mem::forget(guard);
Ok(Self { Ok(Self {
_entry: entry, _entry: entry,
instance, instance,
@@ -910,15 +868,6 @@ 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);
@@ -1900,39 +1849,9 @@ 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];
// Ask for the operation status alongside the two feedback words: without it a FAILED encode let mut fb = [[0u32; 2]; 1];
// is indistinguishable from a successful one, and its offset/bytes-written are read as if dev.get_query_pool_results(f.query_pool, 0, &mut fb, vk::QueryResultFlags::WAIT)?;
// they described real bitstream. The status rides as a trailing element (signed: let (off, len) = (fb[0][0] as usize, fb[0][1] as usize);
// `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
// 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,
// and before `map_memory` so there is no unmap to unwind on the error path.
let (off64, len64) = (fb[0][0] as u64, fb[0][1] as u64);
if off64.saturating_add(len64) > self.bs_size {
anyhow::bail!(
"vulkan-encode: driver reported bitstream feedback offset={off64} \
bytes_written={len64}, outside the {} byte bitstream buffer the encode likely \
overflowed its destination range",
self.bs_size
);
}
let (off, len) = (off64 as usize, len64 as usize);
let p = let p =
dev.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8; dev.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8;
let prefix: &[u8] = if f.keyframe { let prefix: &[u8] = if f.keyframe {
@@ -1960,25 +1879,8 @@ 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();
// Bounded, not `u64::MAX`: this runs ON the host encode thread, which is also the self.device
// thread the stall watchdog's `reset()` would run on. An infinite wait against a .wait_for_fences(&[self.frames[slot].fence], true, u64::MAX)?;
// 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);
} }
@@ -2021,28 +1923,7 @@ impl Encoder for VulkanVideoEncoder {
if first_frame < 0 || first_frame > last_frame { if first_frame < 0 || first_frame > last_frame {
return false; return false;
} }
// Taint sweep BEFORE picking the anchor (the fecbec2d fix AMF and QSV got; this backend was
// carved out one commit later and never received it). "Resident and older than THIS loss" is
// not the same as "the client decoded it": after an earlier loss [a,b] was recovered at wire
// r, everything in [a, r-1] is undecodable at the client — the lost frames plus every frame
// that predicted through the gap. Those wires stay valid anchor candidates here until the
// 8-slot ring rolls them out, so a LATER loss can anchor on one and ship corruption tagged
// `recovery_anchor` — which is the client's definitive re-anchor signal (reanchor.rs), so it
// lifts the post-loss freeze onto a picture built from a reference it never had.
//
// Blank `slot_wire` ONLY. `slot_poc` must keep naming every physically-resident DPB picture
// for `build_h265_rps_s0`, or a conforming decoder evicts them and the anchor references a
// picture the client already dropped. `slot_wire` is the RFI/loss domain; `slot_poc` is the
// reference-delta domain. `prev_slot` and the normal P-frame path are indices, not wires, so
// ordinary prediction is unaffected.
for w in self.slot_wire.iter_mut() {
if *w >= first_frame {
*w = -1;
}
}
// Can we anchor a clean P-frame to a resident slot strictly older than the loss? // Can we anchor a clean P-frame to a resident slot strictly older than the loss?
// (A sweep that empties every candidate yields `None` here and declines the RFI, matching
// `qsv_live_ltr_rfi_taint_sweep_declines`.)
match pick_recovery_slot(&self.slot_wire, first_frame) { match pick_recovery_slot(&self.slot_wire, first_frame) {
Some(_) => { Some(_) => {
self.pending_loss = Some(first_frame); self.pending_loss = Some(first_frame);
@@ -2127,180 +2008,79 @@ impl Encoder for VulkanVideoEncoder {
} }
} }
/// Every destructible Vulkan object the encoder owns, with the one `Drop` that destroys them in
/// dependency order. Both teardown paths run through it so they cannot drift:
///
/// - `open_inner` mirrors each object into one as it is created, so any early `?`/`bail!` (or
/// panic) unwinds exactly what was built — previously every open failure leaked all prior
/// objects (a `VkDevice` + GPU memory per retried open). The `Ok(Self)` hand-off disarms the
/// guard with `mem::forget` after moving the collections out.
/// - [`VulkanVideoEncoder`]'s `Drop` rebuilds one from its fields and drops it.
///
/// Handles a failed build never reached stay null, and `vkDestroy*`/`vkFree*` are defined no-ops
/// on `VK_NULL_HANDLE`, so the full sequence is safe to run against any prefix of the build.
struct VkTeardown {
instance: Option<ash::Instance>,
// `device` and `vq_dev` are set together (the wrapper constructors after `create_device` are
// infallible), so device-level objects can only exist once both are `Some`.
device: Option<ash::Device>,
vq_dev: Option<ash::khr::video_queue::Device>,
import_cache: Vec<(u64, u64, vk::Image, vk::DeviceMemory, vk::ImageView)>,
frames: Vec<Frame>,
compute_pool: vk::CommandPool,
cmd_pool: vk::CommandPool,
// Transient: alive only between its creation and the post-pipeline destroy in `open_inner`
// (which nulls this); always null when rebuilt from the encoder's `Drop`.
shader: vk::ShaderModule,
csc_pipe: vk::Pipeline,
csc_layout: vk::PipelineLayout,
csc_pool: vk::DescriptorPool,
csc_dsl: vk::DescriptorSetLayout,
sampler: vk::Sampler,
dpb_views: Vec<vk::ImageView>,
dpb_image: vk::Image,
dpb_mem: vk::DeviceMemory,
params: vk::VideoSessionParametersKHR,
session: vk::VideoSessionKHR,
session_mem: Vec<vk::DeviceMemory>,
}
impl VkTeardown {
/// A fresh guard owning only the instance — every other handle starts null/empty. Written out
/// field by field because struct-update syntax is not allowed on a `Drop` type (E0509).
fn new(instance: ash::Instance) -> Self {
Self {
instance: Some(instance),
device: None,
vq_dev: None,
import_cache: Vec::new(),
frames: Vec::new(),
compute_pool: vk::CommandPool::null(),
cmd_pool: vk::CommandPool::null(),
shader: vk::ShaderModule::null(),
csc_pipe: vk::Pipeline::null(),
csc_layout: vk::PipelineLayout::null(),
csc_pool: vk::DescriptorPool::null(),
csc_dsl: vk::DescriptorSetLayout::null(),
sampler: vk::Sampler::null(),
dpb_views: Vec::new(),
dpb_image: vk::Image::null(),
dpb_mem: vk::DeviceMemory::null(),
params: vk::VideoSessionParametersKHR::null(),
session: vk::VideoSessionKHR::null(),
session_mem: Vec::new(),
}
}
}
impl Drop for VkTeardown {
fn drop(&mut self) {
// SAFETY: `device_wait_idle` first guarantees no GPU work still references any object, so
// every handle destroyed below is idle and owned solely by `self`; each is freed exactly
// once (the takes prevent a double free) and in dependency order (views before images
// before memory, per-frame objects before their shared pools, session params before
// session, session memory after the session, the device before the instance). Null handles
// (a build prefix from a failed `open_inner`) are no-ops per the Vulkan spec.
unsafe {
if let Some(device) = self.device.take() {
let _ = device.device_wait_idle();
for (_, _, img, mem, view) in std::mem::take(&mut self.import_cache) {
device.destroy_image_view(view, None);
device.destroy_image(img, None);
device.free_memory(mem, None);
}
// Per-frame ring resources (command buffers, descriptor sets freed with their pools).
for f in std::mem::take(&mut self.frames) {
device.destroy_semaphore(f.csc_sem, None);
device.destroy_fence(f.fence, None);
device.destroy_query_pool(f.query_pool, None);
device.destroy_buffer(f.bs_buf, None);
device.free_memory(f.bs_mem, None);
for (img, mem, view) in [
(f.y_img, f.y_mem, f.y_view),
(f.uv_img, f.uv_mem, f.uv_view),
(f.nv12_src, f.nv12_mem, f.nv12_view),
] {
device.destroy_image_view(view, None);
device.destroy_image(img, None);
device.free_memory(mem, None);
}
if let Some((i, m, v, _)) = f.cpu_img {
device.destroy_image_view(v, None);
device.destroy_image(i, None);
device.free_memory(m, None);
}
if let Some((b, m, _)) = f.cpu_stage {
device.destroy_buffer(b, None);
device.free_memory(m, None);
}
device.destroy_image_view(f.cursor_view, None);
device.destroy_image(f.cursor_img, None);
device.free_memory(f.cursor_mem, None);
device.destroy_buffer(f.cursor_stage, None);
device.free_memory(f.cursor_stage_mem, None);
}
device.destroy_command_pool(self.compute_pool, None);
device.destroy_command_pool(self.cmd_pool, None);
device.destroy_shader_module(self.shader, None);
device.destroy_pipeline(self.csc_pipe, None);
device.destroy_pipeline_layout(self.csc_layout, None);
device.destroy_descriptor_pool(self.csc_pool, None);
device.destroy_descriptor_set_layout(self.csc_dsl, None);
device.destroy_sampler(self.sampler, None);
for &v in &self.dpb_views {
device.destroy_image_view(v, None);
}
device.destroy_image(self.dpb_image, None);
device.free_memory(self.dpb_mem, None);
if let Some(vq_dev) = self.vq_dev.take() {
(vq_dev.fp().destroy_video_session_parameters_khr)(
device.handle(),
self.params,
std::ptr::null(),
);
(vq_dev.fp().destroy_video_session_khr)(
device.handle(),
self.session,
std::ptr::null(),
);
}
for &m in &self.session_mem {
device.free_memory(m, None);
}
device.destroy_device(None);
}
if let Some(instance) = self.instance.take() {
instance.destroy_instance(None);
}
}
}
}
impl Drop for VulkanVideoEncoder { impl Drop for VulkanVideoEncoder {
fn drop(&mut self) { fn drop(&mut self) {
// The whole teardown sequence lives in `VkTeardown` (shared with `open_inner`'s failure // SAFETY: `device_wait_idle` first guarantees no GPU work still references any object, so
// unwind): rebuild one from our fields and let its Drop run it. // every handle destroyed below is idle and owned solely by `self`; each is freed exactly once
drop(VkTeardown { // (the drains prevent a double free) and in dependency order (views before images before
instance: Some(self.instance.clone()), // memory, per-frame objects before their shared pools, session params before session).
device: Some(self.device.clone()), unsafe {
vq_dev: Some(self.vq_dev.clone()), let _ = self.device.device_wait_idle();
import_cache: std::mem::take(&mut self.import_cache), for (_, _, img, mem, view) in std::mem::take(&mut self.import_cache) {
frames: std::mem::take(&mut self.frames), self.device.destroy_image_view(view, None);
compute_pool: self.compute_pool, self.device.destroy_image(img, None);
cmd_pool: self.cmd_pool, self.device.free_memory(mem, None);
shader: vk::ShaderModule::null(), }
csc_pipe: self.csc_pipe, // Per-frame ring resources (command buffers, descriptor sets freed with their pools).
csc_layout: self.csc_layout, for f in std::mem::take(&mut self.frames) {
csc_pool: self.csc_pool, self.device.destroy_semaphore(f.csc_sem, None);
csc_dsl: self.csc_dsl, self.device.destroy_fence(f.fence, None);
sampler: self.sampler, self.device.destroy_query_pool(f.query_pool, None);
dpb_views: std::mem::take(&mut self.dpb_views), self.device.destroy_buffer(f.bs_buf, None);
dpb_image: self.dpb_image, self.device.free_memory(f.bs_mem, None);
dpb_mem: self.dpb_mem, for (img, mem, view) in [
params: self.params, (f.y_img, f.y_mem, f.y_view),
session: self.session, (f.uv_img, f.uv_mem, f.uv_view),
session_mem: std::mem::take(&mut self.session_mem), (f.nv12_src, f.nv12_mem, f.nv12_view),
}); ] {
self.device.destroy_image_view(view, None);
self.device.destroy_image(img, None);
self.device.free_memory(mem, None);
}
if let Some((i, m, v, _)) = f.cpu_img {
self.device.destroy_image_view(v, None);
self.device.destroy_image(i, None);
self.device.free_memory(m, None);
}
if let Some((b, m, _)) = f.cpu_stage {
self.device.destroy_buffer(b, None);
self.device.free_memory(m, None);
}
self.device.destroy_image_view(f.cursor_view, None);
self.device.destroy_image(f.cursor_img, None);
self.device.free_memory(f.cursor_mem, None);
self.device.destroy_buffer(f.cursor_stage, None);
self.device.free_memory(f.cursor_stage_mem, None);
}
self.device.destroy_command_pool(self.compute_pool, None);
self.device.destroy_command_pool(self.cmd_pool, None);
self.device.destroy_pipeline(self.csc_pipe, None);
self.device.destroy_pipeline_layout(self.csc_layout, None);
self.device.destroy_descriptor_pool(self.csc_pool, None);
self.device
.destroy_descriptor_set_layout(self.csc_dsl, None);
self.device.destroy_sampler(self.sampler, None);
for &v in &self.dpb_views {
self.device.destroy_image_view(v, None);
}
self.device.destroy_image(self.dpb_image, None);
self.device.free_memory(self.dpb_mem, None);
(self.vq_dev.fp().destroy_video_session_parameters_khr)(
self.device.handle(),
self.params,
std::ptr::null(),
);
(self.vq_dev.fp().destroy_video_session_khr)(
self.device.handle(),
self.session,
std::ptr::null(),
);
for &m in &self.session_mem {
self.device.free_memory(m, None);
}
self.device.destroy_device(None);
self.instance.destroy_instance(None);
}
} }
} }
@@ -2345,8 +2125,7 @@ unsafe fn make_video_image(
} }
let img = device.create_image(&ci, None)?; let img = device.create_image(&ci, None)?;
let req = device.get_image_memory_requirements(img); let req = device.get_image_memory_requirements(img);
// Unwind on failure: callers (the open path) only ever see the completed pair. let mem = device.allocate_memory(
let mem = match device.allocate_memory(
&vk::MemoryAllocateInfo::default() &vk::MemoryAllocateInfo::default()
.allocation_size(req.size) .allocation_size(req.size)
.memory_type_index(find_mem( .memory_type_index(find_mem(
@@ -2355,28 +2134,14 @@ unsafe fn make_video_image(
vk::MemoryPropertyFlags::DEVICE_LOCAL, vk::MemoryPropertyFlags::DEVICE_LOCAL,
)), )),
None, None,
) { )?;
Ok(m) => m, device.bind_image_memory(img, mem, 0)?;
Err(e) => {
device.destroy_image(img, None);
return Err(e.into());
}
};
if let Err(e) = device.bind_image_memory(img, mem, 0) {
device.destroy_image(img, None);
device.free_memory(mem, None);
return Err(e.into());
}
Ok((img, mem)) Ok((img, mem))
} }
/// Build one in-flight frame's private resources: NV12 encode-src, Y/UV CSC scratch, its CSC /// Build one in-flight frame's private resources: NV12 encode-src, Y/UV CSC scratch, its CSC
/// descriptor set (Y/UV bound now, RGB per use), the bitstream buffer + feedback query, and the /// descriptor set (Y/UV bound now, RGB per use), the bitstream buffer + feedback query, and the
/// per-frame command buffers + sync. `profile_list`/`profile` are borrowed only during creation. /// per-frame command buffers + sync. `profile_list`/`profile` are borrowed only during creation.
///
/// Builds in place into `f` — a [`Frame::default`] the caller has already parked in its
/// [`VkTeardown`] guard — so every handle is owned by the unwind the moment it exists and a
/// mid-build failure leaks nothing.
unsafe fn make_frame( unsafe fn make_frame(
device: &ash::Device, device: &ash::Device,
mem_props: &vk::PhysicalDeviceMemoryProperties, mem_props: &vk::PhysicalDeviceMemoryProperties,
@@ -2391,12 +2156,9 @@ unsafe fn make_frame(
compute_pool: vk::CommandPool, compute_pool: vk::CommandPool,
bs_size: u64, bs_size: u64,
sampler: vk::Sampler, sampler: vk::Sampler,
f: &mut Frame, ) -> Result<Frame> {
) -> Result<()> {
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
f.cursor_serial = u64::MAX;
// NV12 encode-src (filled by the CSC copy) — concurrent compute+encode. // NV12 encode-src (filled by the CSC copy) — concurrent compute+encode.
(f.nv12_src, f.nv12_mem) = make_video_image( let (nv12_src, nv12_mem) = make_video_image(
device, device,
mem_props, mem_props,
NV12, NV12,
@@ -2407,9 +2169,9 @@ unsafe fn make_frame(
profile_list, profile_list,
fams, fams,
)?; )?;
f.nv12_view = make_view(device, f.nv12_src, NV12, 0)?; let nv12_view = make_view(device, nv12_src, NV12, 0)?;
// CSC scratch (Y R8 full-res, UV RG8 half-res). // CSC scratch (Y R8 full-res, UV RG8 half-res).
(f.y_img, f.y_mem, f.y_view) = make_plain_image( let (y_img, y_mem, y_view) = make_plain_image(
device, device,
mem_props, mem_props,
vk::Format::R8_UNORM, vk::Format::R8_UNORM,
@@ -2417,7 +2179,7 @@ unsafe fn make_frame(
h, h,
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC, vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
)?; )?;
(f.uv_img, f.uv_mem, f.uv_view) = make_plain_image( let (uv_img, uv_mem, uv_view) = make_plain_image(
device, device,
mem_props, mem_props,
vk::Format::R8G8_UNORM, vk::Format::R8G8_UNORM,
@@ -2428,7 +2190,7 @@ unsafe fn make_frame(
// Cursor overlay: fixed CURSOR_MAX² RGBA8 sampled image + host staging (cursor-as-metadata). The // Cursor overlay: fixed CURSOR_MAX² RGBA8 sampled image + host staging (cursor-as-metadata). The
// view/descriptor is static (bound at binding 3 below); only the image *content* changes, and // view/descriptor is static (bound at binding 3 below); only the image *content* changes, and
// only when the pointer bitmap does — see `prep_cursor`. // only when the pointer bitmap does — see `prep_cursor`.
(f.cursor_img, f.cursor_mem, f.cursor_view) = make_plain_image( let (cursor_img, cursor_mem, cursor_view) = make_plain_image(
device, device,
mem_props, mem_props,
vk::Format::R8G8B8A8_UNORM, vk::Format::R8G8B8A8_UNORM,
@@ -2436,14 +2198,14 @@ unsafe fn make_frame(
CURSOR_MAX, CURSOR_MAX,
vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST, vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
)?; )?;
f.cursor_stage = device.create_buffer( let cursor_stage = device.create_buffer(
&vk::BufferCreateInfo::default() &vk::BufferCreateInfo::default()
.size((CURSOR_MAX * CURSOR_MAX * 4) as u64) .size((CURSOR_MAX * CURSOR_MAX * 4) as u64)
.usage(vk::BufferUsageFlags::TRANSFER_SRC), .usage(vk::BufferUsageFlags::TRANSFER_SRC),
None, None,
)?; )?;
let cs_req = device.get_buffer_memory_requirements(f.cursor_stage); let cs_req = device.get_buffer_memory_requirements(cursor_stage);
f.cursor_stage_mem = device.allocate_memory( let cursor_stage_mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default() &vk::MemoryAllocateInfo::default()
.allocation_size(cs_req.size) .allocation_size(cs_req.size)
.memory_type_index(find_mem( .memory_type_index(find_mem(
@@ -2453,39 +2215,39 @@ unsafe fn make_frame(
)), )),
None, None,
)?; )?;
device.bind_buffer_memory(f.cursor_stage, f.cursor_stage_mem, 0)?; device.bind_buffer_memory(cursor_stage, cursor_stage_mem, 0)?;
// Descriptor set — Y/UV storage bindings fixed; binding 0 (RGB) rewritten per use; binding 3 // Descriptor set — Y/UV storage bindings fixed; binding 0 (RGB) rewritten per use; binding 3
// (cursor) points at the static cursor image (its layout is SHADER_READ_ONLY once prepped). // (cursor) points at the static cursor image (its layout is SHADER_READ_ONLY once prepped).
let dsls = [csc_dsl]; let dsls = [csc_dsl];
f.csc_set = device.allocate_descriptor_sets( let csc_set = device.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default() &vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(csc_pool) .descriptor_pool(csc_pool)
.set_layouts(&dsls), .set_layouts(&dsls),
)?[0]; )?[0];
let y_info = [vk::DescriptorImageInfo::default() let y_info = [vk::DescriptorImageInfo::default()
.image_view(f.y_view) .image_view(y_view)
.image_layout(vk::ImageLayout::GENERAL)]; .image_layout(vk::ImageLayout::GENERAL)];
let uv_info = [vk::DescriptorImageInfo::default() let uv_info = [vk::DescriptorImageInfo::default()
.image_view(f.uv_view) .image_view(uv_view)
.image_layout(vk::ImageLayout::GENERAL)]; .image_layout(vk::ImageLayout::GENERAL)];
let cur_info = [vk::DescriptorImageInfo::default() let cur_info = [vk::DescriptorImageInfo::default()
.sampler(sampler) .sampler(sampler)
.image_view(f.cursor_view) .image_view(cursor_view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)]; .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
device.update_descriptor_sets( device.update_descriptor_sets(
&[ &[
vk::WriteDescriptorSet::default() vk::WriteDescriptorSet::default()
.dst_set(f.csc_set) .dst_set(csc_set)
.dst_binding(1) .dst_binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE) .descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
.image_info(&y_info), .image_info(&y_info),
vk::WriteDescriptorSet::default() vk::WriteDescriptorSet::default()
.dst_set(f.csc_set) .dst_set(csc_set)
.dst_binding(2) .dst_binding(2)
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE) .descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
.image_info(&uv_info), .image_info(&uv_info),
vk::WriteDescriptorSet::default() vk::WriteDescriptorSet::default()
.dst_set(f.csc_set) .dst_set(csc_set)
.dst_binding(3) .dst_binding(3)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(&cur_info), .image_info(&cur_info),
@@ -2493,15 +2255,15 @@ unsafe fn make_frame(
&[], &[],
); );
// Bitstream buffer + feedback query. // Bitstream buffer + feedback query.
f.bs_buf = device.create_buffer( let bs_buf = device.create_buffer(
&vk::BufferCreateInfo::default() &vk::BufferCreateInfo::default()
.size(bs_size) .size(bs_size)
.usage(vk::BufferUsageFlags::VIDEO_ENCODE_DST_KHR) .usage(vk::BufferUsageFlags::VIDEO_ENCODE_DST_KHR)
.push_next(profile_list), .push_next(profile_list),
None, None,
)?; )?;
let bs_req = device.get_buffer_memory_requirements(f.bs_buf); let bs_req = device.get_buffer_memory_requirements(bs_buf);
f.bs_mem = device.allocate_memory( let bs_mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default() &vk::MemoryAllocateInfo::default()
.allocation_size(bs_req.size) .allocation_size(bs_req.size)
.memory_type_index(find_mem( .memory_type_index(find_mem(
@@ -2511,7 +2273,7 @@ unsafe fn make_frame(
)), )),
None, None,
)?; )?;
device.bind_buffer_memory(f.bs_buf, f.bs_mem, 0)?; device.bind_buffer_memory(bs_buf, bs_mem, 0)?;
let mut fb_ci = vk::QueryPoolVideoEncodeFeedbackCreateInfoKHR::default().encode_feedback_flags( let mut fb_ci = vk::QueryPoolVideoEncodeFeedbackCreateInfoKHR::default().encode_feedback_flags(
vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BUFFER_OFFSET vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BUFFER_OFFSET
| vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BYTES_WRITTEN, | vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BYTES_WRITTEN,
@@ -2521,21 +2283,51 @@ unsafe fn make_frame(
.query_type(vk::QueryType::VIDEO_ENCODE_FEEDBACK_KHR) .query_type(vk::QueryType::VIDEO_ENCODE_FEEDBACK_KHR)
.query_count(1); .query_count(1);
query_ci.p_next = &fb_ci as *const _ as *const c_void; query_ci.p_next = &fb_ci as *const _ as *const c_void;
f.query_pool = device.create_query_pool(&query_ci, None)?; let query_pool = device.create_query_pool(&query_ci, None)?;
// Command buffers + per-frame sync. // Command buffers + per-frame sync.
f.cmd = device.allocate_command_buffers( let cmd = device.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default() &vk::CommandBufferAllocateInfo::default()
.command_pool(cmd_pool) .command_pool(cmd_pool)
.command_buffer_count(1), .command_buffer_count(1),
)?[0]; )?[0];
f.compute_cmd = device.allocate_command_buffers( let compute_cmd = device.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default() &vk::CommandBufferAllocateInfo::default()
.command_pool(compute_pool) .command_pool(compute_pool)
.command_buffer_count(1), .command_buffer_count(1),
)?[0]; )?[0];
f.csc_sem = device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?; let csc_sem = device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?;
f.fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?; let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
Ok(()) Ok(Frame {
compute_cmd,
cmd,
csc_sem,
fence,
query_pool,
bs_buf,
bs_mem,
csc_set,
y_img,
y_mem,
y_view,
uv_img,
uv_mem,
uv_view,
nv12_src,
nv12_mem,
nv12_view,
cpu_img: None,
cpu_stage: None,
cursor_img,
cursor_mem,
cursor_view,
cursor_stage,
cursor_stage_mem,
cursor_serial: u64::MAX,
cursor_ready: false,
pts_ns: 0,
keyframe: false,
recovery_anchor: false,
})
} }
/// Author VPS/SPS/PPS (Main, level 4.0, low-latency, conformance-window crop) and return the /// Author VPS/SPS/PPS (Main, level 4.0, low-latency, conformance-window crop) and return the
@@ -2638,12 +2430,6 @@ unsafe fn build_parameters_h265(
std::ptr::null_mut(), std::ptr::null_mut(),
); );
if r != vk::Result::SUCCESS { if r != vk::Result::SUCCESS {
// `params` is live but not yet the caller's guard's to unwind — destroy before bailing.
(vq_dev.fp().destroy_video_session_parameters_khr)(
device.handle(),
params,
std::ptr::null(),
);
bail!("get header size: {r:?}"); bail!("get header size: {r:?}");
} }
let mut buf = vec![0u8; size]; let mut buf = vec![0u8; size];
@@ -2655,11 +2441,6 @@ unsafe fn build_parameters_h265(
buf.as_mut_ptr() as *mut c_void, buf.as_mut_ptr() as *mut c_void,
); );
if r != vk::Result::SUCCESS { if r != vk::Result::SUCCESS {
(vq_dev.fp().destroy_video_session_parameters_khr)(
device.handle(),
params,
std::ptr::null(),
);
bail!("get header bytes: {r:?}"); bail!("get header bytes: {r:?}");
} }
buf.truncate(size); buf.truncate(size);
@@ -2916,62 +2697,6 @@ mod tests {
assert_eq!(pick_recovery_slot(&[-1; 8], 5), None); assert_eq!(pick_recovery_slot(&[-1; 8], 5), None);
} }
/// The taint sweep (fecbec2d's fix, ported here): a slot encoded inside an EARLIER, still
/// unrepaired loss window must not become the "known-good" anchor of a LATER loss. Without the
/// sweep, `pick_recovery_slot` accepts it — it is resident and its wire is below the second
/// loss start — and the frame ships tagged `recovery_anchor`, lifting the client's freeze onto
/// a reference it never decoded.
#[test]
fn taint_sweep_excludes_slots_from_an_earlier_loss() {
// Apply the sweep exactly as `invalidate_ref_frames` does.
fn sweep(wires: &mut [i64], loss_first: i64) {
for w in wires.iter_mut() {
if *w >= loss_first {
*w = -1;
}
}
}
// Slots hold wires 0..7. Loss 1 starts at wire 4, so wires 4..7 are undecodable at the
// client. A second loss report arrives at wire 6 while they are all still resident.
let tainted = [4i64, 5, 6, 7];
// WITHOUT the sweep this is the bug: the newest wire below 6 is wire 5 — squarely inside
// loss 1's unrepaired window — and it would be served as the "known-good" anchor.
let unswept = [0i64, 1, 2, 3, 4, 5, 6, 7];
let picked = pick_recovery_slot(&unswept, 6).expect("unswept picks something");
assert!(
tainted.contains(&unswept[picked]),
"precondition: without the sweep the anchor comes from the earlier loss window"
);
// WITH the sweep, loss 1 blanks 4..7, so loss 2 can only reach genuinely clean wires.
let mut wires = unswept;
sweep(&mut wires, 4);
assert_eq!(wires, [0, 1, 2, 3, -1, -1, -1, -1]);
let picked = pick_recovery_slot(&wires, 6).expect("clean wires remain");
assert_eq!(picked, 3, "newest clean survivor is wire 3");
assert!(!tainted.contains(&wires[picked]));
// Encoding resumes after recovery; wires 8..11 refill the swept slots and are clean. A
// later loss at wire 10 legitimately anchors on wire 9 — the sweep must not over-reject.
wires[4] = 8;
wires[5] = 9;
wires[6] = 10;
wires[7] = 11;
sweep(&mut wires, 10);
assert_eq!(
pick_recovery_slot(&wires, 10),
Some(5),
"wire 9 is post-recovery, clean"
);
// A loss covering every live wire leaves nothing clean → decline, caller serves an IDR.
let mut all = [5i64, 6, 7, 8, 9, 10, 11, 12];
sweep(&mut all, 5);
assert_eq!(pick_recovery_slot(&all, 5), None);
}
/// The full-retention RPS: every resident picture is listed (so the decoder keeps it), the /// The full-retention RPS: every resident picture is listed (so the decoder keeps it), the
/// setup slot's dying occupant is not, and `used_by_curr_pic` marks exactly the real reference. /// setup slot's dying occupant is not, and `used_by_curr_pic` marks exactly the real reference.
#[test] #[test]
+5 -33
View File
@@ -409,12 +409,6 @@ pub struct NvencD3d11Encoder {
events: Vec<usize>, events: Vec<usize>,
/// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve). /// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve).
async_rt: Option<AsyncRetrieve>, async_rt: Option<AsyncRetrieve>,
/// The capturer's `pipeline_depth` (`set_input_ring_depth`). This backend encodes the
/// capturer's textures IN PLACE, so it is a HARD ceiling on async in-flight depth: the
/// capturer rotates its ring per delivered frame regardless of encode completion, so
/// pipelining deeper lets it overwrite a texture mid-encode (torn frames). `None` until the
/// session glue reports it — treated as "unknown, don't pipeline past the env cap".
input_ring_depth: Option<usize>,
/// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode. /// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode.
async_supported: bool, async_supported: bool,
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per /// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
@@ -511,7 +505,6 @@ impl NvencD3d11Encoder {
bitstreams: Vec::new(), bitstreams: Vec::new(),
events: Vec::new(), events: Vec::new(),
async_rt: None, async_rt: None,
input_ring_depth: None,
async_supported: false, async_supported: false,
pending: VecDeque::new(), pending: VecDeque::new(),
frame_idx: 0, frame_idx: 0,
@@ -1163,21 +1156,11 @@ impl Encoder for NvencD3d11Encoder {
// index, which is non-zero on a mid-session encoder rebuild's first frame. // index, which is non-zero on a mid-session encoder rebuild's first frame.
let opening = self.next == 0; let opening = self.next == 0;
// Async backpressure: never hand NVENC an output bitstream that is still in flight, and // Async backpressure: never hand NVENC an output bitstream that is still in flight, and
// keep in-flight depth within the capturer's texture ring. At the cap, block on the OLDEST // keep in-flight depth within the capturer's texture ring (see `async_inflight_cap`). At
// completion (the retrieve thread is already waiting on its event) before submitting more — // the cap, block on the OLDEST completion (the retrieve thread is already waiting on its
// bounding depth exactly like the sync path's per-tick blocking poll, just `cap` deep // event) before submitting more — bounding depth exactly like the sync path's per-tick
// instead of 1. // blocking poll, just `cap` deep instead of 1.
// while self.async_rt.is_some() && self.pending.len() >= async_inflight_cap() {
// The ring term is the one that matters for correctness: `async_inflight_cap()` is only the
// output-bitstream-pool ceiling plus an env knob, and consults NOTHING about the capturer,
// despite this comment previously claiming otherwise. Since this backend encodes the
// capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it
// rotate a texture out from under a live encode — torn frames, silently.
let cap = match self.input_ring_depth {
Some(d) => async_inflight_cap().min(d.max(1)),
None => async_inflight_cap(),
};
while self.async_rt.is_some() && self.pending.len() >= cap {
let done = { let done = {
let rt = self.async_rt.as_mut().expect("checked in loop condition"); let rt = self.async_rt.as_mut().expect("checked in loop condition");
rt.done_rx rt.done_rx
@@ -1353,17 +1336,6 @@ impl Encoder for NvencD3d11Encoder {
self.submit(frame) self.submit(frame)
} }
fn set_input_ring_depth(&mut self, depth: usize) {
// This backend registers and encodes the capturer's textures in place (no CopyResource),
// so the capturer's ring depth is a hard ceiling on how deep async may pipeline.
self.input_ring_depth = Some(depth);
tracing::debug!(
depth,
env_cap = async_inflight_cap(),
"NVENC: capturer input-ring depth reported — async in-flight bounded by the smaller"
);
}
fn request_keyframe(&mut self) { fn request_keyframe(&mut self) {
self.force_kf = true; self.force_kf = true;
} }
+6 -57
View File
@@ -43,9 +43,6 @@ 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
@@ -139,12 +136,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).
/// The capturer ring generation the cached plane imports below belong to. A recreate bumps it, y_images: Vec<(isize, pw::pyrowave_image)>,
/// and every cached import is destroyed — the COM addresses they are keyed on can be recycled cbcr_images: Vec<(isize, pw::pyrowave_image)>,
/// by the allocator after a recreate, so identity cannot rest on the pointer alone.
ring_gen: Option<u32>,
y_images: Vec<(PlaneKey, pw::pyrowave_image)>,
cbcr_images: Vec<(PlaneKey, pw::pyrowave_image)>,
width: u32, width: u32,
height: u32, height: u32,
@@ -275,7 +268,6 @@ impl PyroWaveEncoder {
pw_dev, pw_dev,
pw_enc, pw_enc,
sync: std::ptr::null_mut(), sync: std::ptr::null_mut(),
ring_gen: None,
y_images: Vec::new(), y_images: Vec::new(),
cbcr_images: Vec::new(), cbcr_images: Vec::new(),
width, width,
@@ -359,16 +351,10 @@ 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<(PlaneKey, pw::pyrowave_image)>, cache: &mut Vec<(isize, pw::pyrowave_image)>,
make: impl FnOnce() -> Result<pw::pyrowave_image>, make: impl FnOnce() -> Result<pw::pyrowave_image>,
key: PlaneKey, key: isize,
) -> 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);
@@ -437,21 +423,6 @@ 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)")
}; };
@@ -460,25 +431,6 @@ impl PyroWaveEncoder {
in pyrowave mode (session_plan::output_format must set OutputFormat::pyrowave)", in pyrowave mode (session_plan::output_format must set OutputFormat::pyrowave)",
)?; )?;
// Ring recreate ⇒ every cached plane import belongs to textures that no longer exist. Their
// COM addresses can be handed back out by the allocator, so a pointer-keyed hit could return
// an image bound to freed memory. Flush on the generation change rather than relying on the
// address (or the FIFO cap) to notice.
if self.ring_gen != Some(share.ring_gen) {
if self.ring_gen.is_some() {
tracing::info!(
from = ?self.ring_gen,
to = share.ring_gen,
cached = self.y_images.len() + self.cbcr_images.len(),
"pyrowave: capturer recreated its ring — flushing stale plane imports"
);
}
for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) {
pw::pyrowave_image_destroy(img);
}
self.ring_gen = Some(share.ring_gen);
}
// Import the fence whenever this encoder has no timeline yet — the first frame, OR a fresh // Import the fence whenever this encoder has no timeline yet — the first frame, OR a fresh
// encoder after a client mode-switch rebuild (the capturer passes the persistent handle on // encoder after a client mode-switch rebuild (the capturer passes the persistent handle on
// every frame precisely so a rebuilt encoder can re-import it). // every frame precisely so a rebuilt encoder can re-import it).
@@ -513,7 +465,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, w, h); let key = d3d.texture.as_raw() as isize;
let tex = &d3d.texture; let tex = &d3d.texture;
Self::cached_plane( Self::cached_plane(
&mut self.y_images, &mut self.y_images,
@@ -522,7 +474,7 @@ impl PyroWaveEncoder {
)? )?
}; };
let cbcr_img = { let cbcr_img = {
let key = (share.cbcr.as_raw() as isize, cw, ch); let key = share.cbcr.as_raw() as isize;
let tex = &share.cbcr; let tex = &share.cbcr;
Self::cached_plane( Self::cached_plane(
&mut self.cbcr_images, &mut self.cbcr_images,
@@ -1024,9 +976,6 @@ mod tests {
cbcr: cbcr_tex, cbcr: cbcr_tex,
fence_handle: Some(fence_handle.0 as isize), fence_handle: Some(fence_handle.0 as isize),
fence_value: 1, fence_value: 1,
// One synthetic ring for the whole case: a constant generation exercises the
// steady-state cache-hit path (a changing one would flush every frame).
ring_gen: 1,
}), }),
}), }),
cursor: None, cursor: None,
+3 -31
View File
@@ -714,16 +714,7 @@ pub struct QsvEncoder {
/// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below. /// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below.
ltr_active: bool, ltr_active: bool,
/// The wire frame index stored in each LTR slot (`None` = never marked). /// The wire frame index stored in each LTR slot (`None` = never marked).
///
/// This mirrors the HARDWARE DPB, so an entry must not be cleared merely because we distrust
/// it: nulling issues no VPL call, and the encoder keeps the frame marked long-term until that
/// `LongTermIdx` is re-marked or an IDR flushes it. Distrust is recorded in `ltr_tainted`
/// instead, so the rejection list can still NAME the entry the hardware is holding.
ltr_slots: [Option<i64>; NUM_LTR_SLOTS], ltr_slots: [Option<i64>; NUM_LTR_SLOTS],
/// Per-slot taint from `invalidate_ref_frames`' sweep: the mark is still live in the hardware
/// DPB but was encoded inside the client's corrupt window, so it may not anchor a recovery —
/// it must be REJECTED instead. Cleared wherever the slot is re-marked or the DPB is flushed.
ltr_tainted: [bool; NUM_LTR_SLOTS],
next_ltr_slot: usize, next_ltr_slot: usize,
ltr_mark_interval: i64, ltr_mark_interval: i64,
/// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references. /// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references.
@@ -798,7 +789,6 @@ impl QsvEncoder {
ir_active: false, ir_active: false,
ltr_active: false, ltr_active: false,
ltr_slots: [None; NUM_LTR_SLOTS], ltr_slots: [None; NUM_LTR_SLOTS],
ltr_tainted: [false; NUM_LTR_SLOTS],
next_ltr_slot: 0, next_ltr_slot: 0,
ltr_mark_interval: ltr_mark_interval(fps), ltr_mark_interval: ltr_mark_interval(fps),
pending_force: None, pending_force: None,
@@ -923,7 +913,6 @@ impl QsvEncoder {
self.ltr_active = ltr_active; self.ltr_active = ltr_active;
self.ir_active = ir_active; self.ir_active = ir_active;
self.ltr_slots = [None; NUM_LTR_SLOTS]; self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS];
self.next_ltr_slot = 0; self.next_ltr_slot = 0;
self.pending_force = None; self.pending_force = None;
self.hdr_applied = self.hdr_meta; self.hdr_applied = self.hdr_meta;
@@ -1049,7 +1038,6 @@ impl Encoder for QsvEncoder {
// An IDR voids the decoder's reference buffers — drop stale slots and any // An IDR voids the decoder's reference buffers — drop stale slots and any
// queued force; the mark cadence below re-anchors on the IDR itself. // queued force; the mark cadence below re-anchors on the IDR itself.
self.ltr_slots = [None; NUM_LTR_SLOTS]; self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS]; // the IDR flushed the DPB with them
self.next_ltr_slot = 0; self.next_ltr_slot = 0;
self.pending_force = None; self.pending_force = None;
} else if self.ltr_test_force_at == Some(cur_idx) { } else if self.ltr_test_force_at == Some(cur_idx) {
@@ -1065,9 +1053,7 @@ impl Encoder for QsvEncoder {
// emptied the slot since the force was queued. An empty slot means there is // emptied the slot since the force was queued. An empty slot means there is
// nothing clean to re-reference — the frame must ship as a plain P WITHOUT the // nothing clean to re-reference — the frame must ship as a plain P WITHOUT the
// `recovery_anchor` tag (the client lifts its post-loss freeze on that tag). // `recovery_anchor` tag (the client lifts its post-loss freeze on that tag).
// The slot is no longer emptied by the sweep, so test the taint flag too — a if let Some(idx) = self.ltr_slots[slot] {
// tainted slot is exactly the "nothing clean to re-reference" case.
if let Some(idx) = self.ltr_slots[slot].filter(|_| !self.ltr_tainted[slot]) {
force_ltr = Some((slot, idx)); force_ltr = Some((slot, idx));
recovery_anchor = true; recovery_anchor = true;
} }
@@ -1075,9 +1061,6 @@ impl Encoder for QsvEncoder {
if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) { if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
let slot = self.next_ltr_slot; let slot = self.next_ltr_slot;
self.ltr_slots[slot] = Some(cur_idx); self.ltr_slots[slot] = Some(cur_idx);
// Re-marking replaces the hardware's LongTermIdx: the tainted frame is gone from
// the DPB and this slot is clean again.
self.ltr_tainted[slot] = false;
self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS; self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
mark_slot = Some(slot); mark_slot = Some(slot);
} }
@@ -1340,23 +1323,13 @@ impl Encoder for QsvEncoder {
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples // loss ships corruption as the recovery anchor, and every subsequent mark re-samples
// the soup — the sustained-loss field failure where the picture never healed. Dropped // the soup — the sustained-loss field failure where the picture never healed. Dropped
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s. // slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
// for marked in self.ltr_slots.iter_mut() {
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it
// made the rejection list below (which iterates the post-sweep mirror and only names `Some`
// slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame
// could still predict from it. With two slots the "exactly one swept" case is the modal
// one, and it was the broken one.
for (slot, marked) in self.ltr_slots.iter().enumerate() {
if marked.is_some_and(|idx| idx >= first) { if marked.is_some_and(|idx| idx >= first) {
self.ltr_tainted[slot] = true; *marked = None;
} }
} }
let mut best: Option<(usize, i64)> = None; let mut best: Option<(usize, i64)> = None;
for (slot, marked) in self.ltr_slots.iter().enumerate() { for (slot, marked) in self.ltr_slots.iter().enumerate() {
if self.ltr_tainted[slot] {
continue; // still in the DPB, but encoded inside the corrupt window
}
if let Some(idx) = *marked { if let Some(idx) = *marked {
if idx < first && best.is_none_or(|(_, b)| idx > b) { if idx < first && best.is_none_or(|(_, b)| idx > b) {
best = Some((slot, idx)); best = Some((slot, idx));
@@ -1481,7 +1454,6 @@ impl Encoder for QsvEncoder {
self.ltr_active = ltr; self.ltr_active = ltr;
self.ir_active = ir; self.ir_active = ir;
self.ltr_slots = [None; NUM_LTR_SLOTS]; self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS];
self.next_ltr_slot = 0; self.next_ltr_slot = 0;
self.pending_force = None; self.pending_force = None;
if let Some(inner) = self.inner.as_mut() { if let Some(inner) = self.inner.as_mut() {
-5
View File
@@ -209,11 +209,6 @@ impl Encoder for TrackedEncoder {
fn set_wire_chunking(&mut self, shard_payload: usize) { fn set_wire_chunking(&mut self, shard_payload: usize) {
self.inner.set_wire_chunking(shard_payload) self.inner.set_wire_chunking(shard_payload)
} }
// Forwarded for the same reason as `set_wire_chunking` above — an unforwarded default here
// would silently leave the in-place backends pipelining past the capturer's ring.
fn set_input_ring_depth(&mut self, depth: usize) {
self.inner.set_input_ring_depth(depth)
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> { fn poll(&mut self) -> Result<Option<EncodedFrame>> {
self.inner.poll() self.inner.poll()
} }
-6
View File
@@ -52,12 +52,6 @@ pub struct PyroFrameShare {
/// The fence value the capturer signalled after THIS frame's convert. The encoder's Vulkan /// The fence value the capturer signalled after THIS frame's convert. The encoder's Vulkan
/// acquire waits on it, so the wavelet read is ordered after the D3D11 CSC. /// acquire waits on it, so the wavelet read is ordered after the D3D11 CSC.
pub fence_value: u64, pub fence_value: u64,
/// The capturer's ring generation, bumped every time it recreates its texture ring. The
/// PyroWave encoder caches its plane imports keyed on the texture's COM address, which carries
/// no reference — after a recreate those addresses can be recycled by the allocator, so a
/// cached import may describe a texture that no longer exists. The encoder flushes its import
/// cache whenever this changes, making cache identity independent of allocator behaviour.
pub ring_gen: u32,
} }
/// A GPU-resident captured texture (the Windows zero-copy path: NVENC/AMF/QSV encode it in place; /// A GPU-resident captured texture (the Windows zero-copy path: NVENC/AMF/QSV encode it in place;
@@ -17,7 +17,7 @@
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::mem::size_of; use std::mem::size_of;
use std::os::fd::RawFd; use std::os::fd::RawFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread::JoinHandle; use std::thread::JoinHandle;
@@ -196,41 +196,6 @@ impl Drop for GadgetFd {
} }
} }
/// The signal used to break a worker thread out of a blocking raw_gadget ioctl at teardown.
/// `EVENT_FETCH`/`EP_WRITE` are `wait_event_interruptible` in the kernel with no timeout and no
/// `O_NONBLOCK` honouring, and closing the fd cannot wake a thread already inside the ioctl (the
/// in-flight syscall holds a reference to the struct file). A signal is the only reliable lever:
/// delivered with a no-op, non-`SA_RESTART` handler it forces the ioctl to return `EINTR`, after
/// which the loop's top-of-iteration `running` check exits. `SIGUSR1` is unused elsewhere in this
/// process; the handler is a no-op, so a stray `SIGUSR1` becomes harmless rather than fatal.
const WAKE_SIGNAL: libc::c_int = libc::SIGUSR1;
/// Install the no-op `WAKE_SIGNAL` handler exactly once. Crucially `sa_flags = 0` (no `SA_RESTART`)
/// so a delivered signal makes the interruptible ioctl return `EINTR` instead of auto-restarting.
fn install_wake_handler() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
extern "C" fn noop(_: libc::c_int) {}
// SAFETY: installing a well-formed `sigaction` with an empty mask and a valid no-op handler
// for a single signal; touches only this process's disposition for `WAKE_SIGNAL`.
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = noop as usize;
libc::sigemptyset(&mut sa.sa_mask);
sa.sa_flags = 0;
libc::sigaction(WAKE_SIGNAL, &sa, std::ptr::null_mut());
}
});
}
/// Lets `Drop` wake a specific worker thread parked in a blocking ioctl. `tid` is the thread's
/// `pthread_self()` (0 until it starts); `done` is set right before the thread returns, so `Drop`
/// stops signalling a thread that has already exited.
struct Waker {
tid: Arc<AtomicU64>,
done: Arc<AtomicBool>,
}
/// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and /// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and
/// closes the gadget (the kernel tears down the device). /// closes the gadget (the kernel tears down the device).
pub struct SteamDeckGadget { pub struct SteamDeckGadget {
@@ -238,7 +203,6 @@ pub struct SteamDeckGadget {
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>, feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
threads: Vec<JoinHandle<()>>, threads: Vec<JoinHandle<()>>,
wakers: Vec<Waker>,
_fd: Arc<GadgetFd>, _fd: Arc<GadgetFd>,
seq: u32, seq: u32,
} }
@@ -279,18 +243,6 @@ impl SteamDeckGadget {
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1)); let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
let configured = Arc::new(AtomicBool::new(false)); let configured = Arc::new(AtomicBool::new(false));
// The teardown wake path (see `WAKE_SIGNAL`) needs the handler installed before any thread
// can park in a blocking ioctl.
install_wake_handler();
let ctrl_waker = Waker {
tid: Arc::new(AtomicU64::new(0)),
done: Arc::new(AtomicBool::new(false)),
};
let stream_waker = Waker {
tid: Arc::new(AtomicU64::new(0)),
done: Arc::new(AtomicBool::new(false)),
};
// Control thread: enumerate + answer every control transfer. // Control thread: enumerate + answer every control transfer.
let control = { let control = {
let fd = fd.clone(); let fd = fd.clone();
@@ -298,15 +250,10 @@ impl SteamDeckGadget {
let ctrl_ep = ctrl_ep.clone(); let ctrl_ep = ctrl_ep.clone();
let configured = configured.clone(); let configured = configured.clone();
let feedback = feedback.clone(); let feedback = feedback.clone();
let tid = ctrl_waker.tid.clone();
let done = ctrl_waker.done.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("pf-deck-gadget-ctrl".into()) .name("pf-deck-gadget-ctrl".into())
.spawn(move || { .spawn(move || {
// SAFETY: `pthread_self` is always valid on the calling thread. control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
tid.store(unsafe { libc::pthread_self() } as u64, Ordering::SeqCst);
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id);
done.store(true, Ordering::SeqCst);
}) })
.context("spawn gadget control thread")? .context("spawn gadget control thread")?
}; };
@@ -317,16 +264,9 @@ impl SteamDeckGadget {
let ctrl_ep = ctrl_ep.clone(); let ctrl_ep = ctrl_ep.clone();
let configured = configured.clone(); let configured = configured.clone();
let report = report.clone(); let report = report.clone();
let tid = stream_waker.tid.clone();
let done = stream_waker.done.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("pf-deck-gadget-stream".into()) .name("pf-deck-gadget-stream".into())
.spawn(move || { .spawn(move || stream_loop(fd, running, ctrl_ep, configured, report))
// SAFETY: `pthread_self` is always valid on the calling thread.
tid.store(unsafe { libc::pthread_self() } as u64, Ordering::SeqCst);
stream_loop(fd, running, ctrl_ep, configured, report);
done.store(true, Ordering::SeqCst);
})
.context("spawn gadget stream thread")? .context("spawn gadget stream thread")?
}; };
@@ -335,7 +275,6 @@ impl SteamDeckGadget {
feedback, feedback,
running, running,
threads: vec![control, stream], threads: vec![control, stream],
wakers: vec![ctrl_waker, stream_waker],
_fd: fd, _fd: fd,
seq: 0, seq: 0,
}) })
@@ -363,32 +302,6 @@ impl SteamDeckGadget {
impl Drop for SteamDeckGadget { impl Drop for SteamDeckGadget {
fn drop(&mut self) { fn drop(&mut self) {
self.running.store(false, Ordering::SeqCst); self.running.store(false, Ordering::SeqCst);
// The control thread spends steady state parked in a blocking `EVENT_FETCH` ioctl that only
// tests `running` at the top of its loop, so clearing the flag is not enough — it must be
// signalled out of the syscall (see `WAKE_SIGNAL`). Without this the join below can hang the
// caller (the session input thread, via `PadSlots::sweep`) indefinitely. Retry until each
// thread reports done, to cover the race where the signal lands just before the thread
// re-enters the ioctl; bounded (~1 s) so a genuinely stuck thread can't wedge teardown either.
for _ in 0..200 {
let mut all_done = true;
for w in &self.wakers {
if w.done.load(Ordering::SeqCst) {
continue;
}
all_done = false;
let tid = w.tid.load(Ordering::SeqCst);
if tid != 0 {
// SAFETY: the thread is joinable and not yet joined (join runs after this loop),
// so `tid` names a live pthread; `pthread_kill` on a finished-but-unjoined thread
// is defined (returns ESRCH), never UB.
unsafe { libc::pthread_kill(tid as libc::pthread_t, WAKE_SIGNAL) };
}
}
if all_done {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
for t in self.threads.drain(..) { for t in self.threads.drain(..) {
let _ = t.join(); let _ = t.join();
} }
@@ -299,20 +299,13 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? }; let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT // `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
// would read as success and mask the failure (found by the 2026-07 driver-health audit). // would read as success and mask the failure (found by the 2026-07 driver-health audit).
// HEAP-allocated, deliberately: `sw_create_cb` writes `result` + up to 127 u16 of instance id let mut ctx = SwCreateCtx {
// through this pointer and then `SetEvent`s. The wait below is bounded (10 s), so on a wedged-PnP
// timeout the callback may still be PENDING — a stack context would be popped and a late callback
// would corrupt whatever the input thread put there next, and SetEvent a closed/recycled handle.
// On the timeout path we therefore LEAK the box and leave the event open (a one-off ~264 B + one
// HANDLE, only on that rare path) so a late callback always writes to live memory.
let ctx = Box::into_raw(Box::new(SwCreateCtx {
event, event,
result: E_FAIL, result: E_FAIL,
instance_id: [0; 128], instance_id: [0; 128],
})); };
// SAFETY: info + the buffers outlive the call; `ctx` is a live heap allocation that outlives every // SAFETY: info + the buffers + ctx outlive the call (we wait on the event before returning);
// path below (reclaimed only where the callback provably ran). windows-rs returns the HSWDEVICE // windows-rs returns the HSWDEVICE (the C out-param) as the Result value.
// (the C out-param) as the Result value.
let hsw = match unsafe { let hsw = match unsafe {
SwDeviceCreate( SwDeviceCreate(
w!("punktfunk"), w!("punktfunk"),
@@ -320,15 +313,13 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
&info, &info,
None, None,
Some(sw_create_cb), Some(sw_create_cb),
Some(ctx as *const c_void), Some(&mut ctx as *mut SwCreateCtx as *const c_void),
) )
} { } {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
// SAFETY: the call failed, so no callback was registered and `ctx` is ours to reclaim; // SAFETY: event is valid.
// `event` is valid and unreferenced.
unsafe { unsafe {
drop(Box::from_raw(ctx));
let _ = CloseHandle(event); let _ = CloseHandle(event);
} }
return Err(anyhow!("SwDeviceCreate failed: {e}")); return Err(anyhow!("SwDeviceCreate failed: {e}"));
@@ -337,22 +328,17 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
// Block until PnP finishes enumerating (the callback signals), then check its result. // Block until PnP finishes enumerating (the callback signals), then check its result.
// SAFETY: event is valid. // SAFETY: event is valid.
let wait = unsafe { WaitForSingleObject(event, 10_000) }; let wait = unsafe { WaitForSingleObject(event, 10_000) };
// SAFETY: event is valid.
unsafe {
let _ = CloseHandle(event);
}
if wait != WAIT_OBJECT_0 { if wait != WAIT_OBJECT_0 {
// Timed out: the callback may still fire. Intentionally leak `ctx` AND leave `event` open so
// its eventual write + SetEvent target live memory/handle rather than freed ones.
// SAFETY: hsw is the handle SwDeviceCreate returned. // SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) }; unsafe { SwDeviceClose(hsw) };
return Err(anyhow!( return Err(anyhow!(
"SwDeviceCreate enumeration callback never fired (10s) — PnP may be wedged" "SwDeviceCreate enumeration callback never fired (10s) — PnP may be wedged"
)); ));
} }
// The callback ran (it is what signalled the event), so nothing else will touch `ctx`/`event`.
// SAFETY: `ctx` came from `Box::into_raw` above and is reclaimed exactly once here; `event` is
// valid and no longer referenced by a pending callback.
let ctx = unsafe {
let _ = CloseHandle(event);
Box::from_raw(ctx)
};
if ctx.result.is_err() { if ctx.result.is_err() {
// SAFETY: hsw is the handle SwDeviceCreate returned. // SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) }; unsafe { SwDeviceClose(hsw) };
@@ -62,7 +62,7 @@ impl Ds4WinPad {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
let inst = format!("pf_ds4_{index}"); let inst = format!("pf_ds4_{index}");
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile { let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst, instance: &inst,
container_tag: 0x5046_4453, // "PFDS" container_tag: 0x5046_4453, // "PFDS"
container_index: index, container_index: index,
@@ -70,13 +70,13 @@ impl Ds4WinPad {
usb_vid_pid: "VID_054C&PID_09CC", usb_vid_pid: "VID_054C&PID_09CC",
usb_mi: None, usb_mi: None,
description: "punktfunk Virtual DualShock 4", description: "punktfunk Virtual DualShock 4",
})?; // Propagate, do NOT swallow — see below. }) {
let (hsw, instance_id) = (Some(hsw), instance_id); Ok((h, id)) => (Some(h), id),
// Swallowing a create failure here (the previous behaviour) latched the pad slot to Err(e) => {
// `Some(pad)` with no live devnode: `PadSlots::ensure` short-circuits on `is_some()` and tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
// `gate.on_success()` cleared the backoff, so the create-gate that exists precisely to (None, None)
// self-heal a transient PnP failure never retried. The game saw no controller for the whole }
// session unless the client unplugged the pad. Matches the XUSB sibling, which propagates. };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver // Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for // must read `device_type = 1` from the delivered DATA section before hidclass asks it for
@@ -82,16 +82,12 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? }; let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT // `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
// would read as success and mask the failure (found by the 2026-07 driver-health audit). // would read as success and mask the failure (found by the 2026-07 driver-health audit).
// HEAP-allocated for the same reason as the DualSense sibling: the callback writes through this let mut ctx = SwCreateCtx {
// pointer and SetEvents, and the wait below is bounded — a stack context would be popped while a
// late callback still holds it. On the timeout path the box is deliberately leaked and the event
// left open so a late write/SetEvent always targets live memory/handle.
let ctx = Box::into_raw(Box::new(SwCreateCtx {
event, event,
result: E_FAIL, result: E_FAIL,
instance_id: [0; 128], instance_id: [0; 128],
})); };
// SAFETY: info + buffers outlive the call; `ctx` is a live heap allocation outliving every path. // SAFETY: info + buffers + ctx outlive the call (we wait on the event before returning).
let hsw = match unsafe { let hsw = match unsafe {
SwDeviceCreate( SwDeviceCreate(
w!("punktfunk"), w!("punktfunk"),
@@ -99,14 +95,13 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
&info, &info,
None, None,
Some(sw_create_cb), Some(sw_create_cb),
Some(ctx as *const c_void), Some(&mut ctx as *mut SwCreateCtx as *const c_void),
) )
} { } {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
// SAFETY: the call failed, so no callback is pending and `ctx` is ours to reclaim. // SAFETY: event is valid.
unsafe { unsafe {
drop(Box::from_raw(ctx));
let _ = CloseHandle(event); let _ = CloseHandle(event);
} }
return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}")); return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}"));
@@ -114,20 +109,17 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
}; };
// SAFETY: event valid; block until PnP finishes enumerating, then check the callback result. // SAFETY: event valid; block until PnP finishes enumerating, then check the callback result.
let wait = unsafe { WaitForSingleObject(event, 10_000) }; let wait = unsafe { WaitForSingleObject(event, 10_000) };
// SAFETY: event is valid.
unsafe {
let _ = CloseHandle(event);
}
if wait != WAIT_OBJECT_0 { if wait != WAIT_OBJECT_0 {
// Timed out — intentionally leak `ctx` and leave `event` open (see above).
// SAFETY: hsw is the handle SwDeviceCreate returned. // SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) }; unsafe { SwDeviceClose(hsw) };
return Err(anyhow!( return Err(anyhow!(
"SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged" "SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged"
)); ));
} }
// The callback ran (it signalled the event), so nothing else will touch `ctx`/`event`.
// SAFETY: `ctx` came from `Box::into_raw` and is reclaimed exactly once here.
let ctx = unsafe {
let _ = CloseHandle(event);
Box::from_raw(ctx)
};
if ctx.result.is_err() { if ctx.result.is_err() {
// SAFETY: hsw is the handle SwDeviceCreate returned. // SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) }; unsafe { SwDeviceClose(hsw) };
@@ -66,7 +66,7 @@ impl DeckWinPad {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
let inst = format!("pf_deck_{index}"); let inst = format!("pf_deck_{index}");
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile { let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst, instance: &inst,
container_tag: 0x5046_4453, // "PFDS" container_tag: 0x5046_4453, // "PFDS"
container_index: index, container_index: index,
@@ -77,8 +77,13 @@ impl DeckWinPad {
// spike's run-1 failure). // spike's run-1 failure).
usb_mi: Some(2), usb_mi: Some(2),
description: "punktfunk Virtual Steam Deck", description: "punktfunk Virtual Steam Deck",
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin). }) {
let (hsw, instance_id) = (Some(hsw), instance_id); Ok((h, i)) => (Some(h), i),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable");
(None, None)
}
};
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks // Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks
// it for descriptors, or the pad would enumerate with the default DualSense identity. // it for descriptors, or the pad would enumerate with the default DualSense identity.
+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"] }
+4 -20
View File
@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};
use std::process::{Child, Command}; use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant}; use std::time::Duration;
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds. /// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
@@ -64,27 +64,11 @@ impl Drop for Shared {
/// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket /// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket
/// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so /// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so
/// workers don't linger as zombies for more than one capture generation. /// workers don't linger as zombies for more than one capture generation.
static REAPER: Mutex<Vec<(Child, Instant)>> = Mutex::new(Vec::new()); static REAPER: Mutex<Vec<Child>> = Mutex::new(Vec::new());
/// How long past `REPLY_TIMEOUT` a parked worker may linger before it is force-killed. A worker
/// wedged INSIDE a driver call never observes socket EOF, so `try_wait` alone would keep it (and
/// its CUcontext + BufferPool — order hundreds of MB of VRAM) forever.
const REAPER_KILL_DEADLINE: Duration = Duration::from_secs(20);
fn sweep_reaper() { fn sweep_reaper() {
let mut list = REAPER.lock().unwrap(); let mut list = REAPER.lock().unwrap();
let now = Instant::now(); list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
list.retain_mut(|(c, parked)| {
if matches!(c.try_wait(), Ok(Some(_))) {
return false; // exited on its own → reaped
}
if now.duration_since(*parked) > REAPER_KILL_DEADLINE {
let _ = c.kill();
let _ = c.wait();
return false; // wedged past the deadline → force-killed + reaped
}
true
});
} }
/// Fd pinned to this process's own executable image, opened (once, lazily) via the /// Fd pinned to this process's own executable image, opened (once, lazily) via the
@@ -471,7 +455,7 @@ impl Drop for RemoteImporter {
// gone; park the rest for the next sweep. // gone; park the rest for the next sweep.
if let Some(mut child) = self.child.take() { if let Some(mut child) = self.child.take() {
if !matches!(child.try_wait(), Ok(Some(_))) { if !matches!(child.try_wait(), Ok(Some(_))) {
REAPER.lock().unwrap().push((child, Instant::now())); REAPER.lock().unwrap().push(child);
} }
} }
sweep_reaper(); sweep_reaper();
+7 -13
View File
@@ -1003,13 +1003,7 @@ impl RegisteredTexture {
// SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl` // SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl`
// (its only constructor), so the wrappers forward to the live table; the caller holds the // (its only constructor), so the wrappers forward to the live table; the caller holds the
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps // GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
// `count == 1` resource via `&mut self.resource` (a live field). It is issued on // `count == 1` resource via `&mut self.resource` (a live field) on the default stream;
// `copy_stream()` — NOT the NULL stream — because map's only ordering guarantee is that
// prior GL work completes before subsequent CUDA work issued IN THE STREAM PASSED TO IT;
// the copy below runs on `copy_stream()` (a `CU_STREAM_NON_BLOCKING` stream, exempt from
// implicit NULL-stream ordering), so mapping on NULL left the copy free to race the GL
// de-tile/CSC that produced this texture (glFlush only, no fence) — intermittent torn or
// stale frames under GPU load. Map, copy, and unmap now all share `copy_stream()`.
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local // `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `&copy` is a live // `array` (index 0, mip 0). On failure we unmap and bail (balanced). `&copy` is a live
// local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid // local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid
@@ -1018,12 +1012,12 @@ impl RegisteredTexture {
// we always unmap afterward (even on error), keeping the map/unmap pair balanced. // we always unmap afterward (even on error), keeping the map/unmap pair balanced.
unsafe { unsafe {
ck( ck(
cuGraphicsMapResources(1, &mut self.resource, copy_stream()), cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
"cuGraphicsMapResources", "cuGraphicsMapResources",
)?; )?;
let mut array: CUarray = std::ptr::null_mut(); let mut array: CUarray = std::ptr::null_mut();
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 { if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream()); let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
bail!("cuGraphicsSubResourceGetMappedArray failed"); bail!("cuGraphicsSubResourceGetMappedArray failed");
} }
let copy = CUDA_MEMCPY2D { let copy = CUDA_MEMCPY2D {
@@ -1037,7 +1031,7 @@ impl RegisteredTexture {
..Default::default() ..Default::default()
}; };
let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2"); let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2");
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream()); let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
res res
} }
} }
@@ -1064,12 +1058,12 @@ impl RegisteredTexture {
// so the map/unmap pair stays balanced and the array outlives the copy. // so the map/unmap pair stays balanced and the array outlives the copy.
unsafe { unsafe {
ck( ck(
cuGraphicsMapResources(1, &mut self.resource, copy_stream()), cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
"cuGraphicsMapResources", "cuGraphicsMapResources",
)?; )?;
let mut array: CUarray = std::ptr::null_mut(); let mut array: CUarray = std::ptr::null_mut();
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 { if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream()); let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
bail!("cuGraphicsSubResourceGetMappedArray failed"); bail!("cuGraphicsSubResourceGetMappedArray failed");
} }
let copy = CUDA_MEMCPY2D { let copy = CUDA_MEMCPY2D {
@@ -1083,7 +1077,7 @@ impl RegisteredTexture {
..Default::default() ..Default::default()
}; };
let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2(plane)"); let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2(plane)");
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream()); let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
res res
} }
} }
-9
View File
@@ -691,15 +691,6 @@ impl EglImporter {
width: u32, width: u32,
height: u32, height: u32,
) -> Result<DeviceBuffer> { ) -> Result<DeviceBuffer> {
// Even dimensions only: the UV copy walks `height.div_ceil(2)` chroma rows (the correct NV12
// count), but the pooled UV plane is sized at `height/2` rows — for an odd height those
// disagree by one row and the copy writes a full `uv_pitch` past the allocation (OOB device
// write / CUDA_ERROR_ILLEGAL_ADDRESS that poisons the shared context). Reject here, matching
// the guards `Nv12Blit::new`/`Yuv444Blit::new` already carry.
anyhow::ensure!(
width % 2 == 0 && height % 2 == 0,
"LINEAR NV12 needs even dimensions (got {width}x{height})"
);
cuda::make_current()?; cuda::make_current()?;
if self if self
.linear_nv12_pool .linear_nv12_pool
+62 -131
View File
@@ -193,17 +193,10 @@ impl VkBridge {
/// Import `fd` (dup'd internally; Vulkan owns the dup) as a transfer-src buffer of `size`. /// Import `fd` (dup'd internally; Vulkan owns the dup) as a transfer-src buffer of `size`.
unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> { unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> {
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd};
let dup = libc::dup(fd); let dup = libc::dup(fd);
if dup < 0 { if dup < 0 {
bail!("dup(dmabuf fd)"); bail!("dup(dmabuf fd)");
} }
// Own the dup so every early return BEFORE Vulkan consumes it (at `allocate_memory` success)
// closes it. `SrcBuf` holds raw handles with no Drop and is only populated on the success
// path, so each fallible step below must also destroy the buffer it created — otherwise a
// failed import (which the worker survives and the caller retries every frame) leaks a
// VkBuffer + VkDeviceMemory + fd per frame for the worker's whole lifetime.
let dup = OwnedFd::from_raw_fd(dup);
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default() let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
let buffer = self let buffer = self
@@ -219,55 +212,41 @@ impl VkBridge {
.push_next(&mut ext_info), .push_next(&mut ext_info),
None, None,
) )
.context("create import buffer")?; // `dup` drops → closes on failure .context("create import buffer")?;
let mut fd_props = vk::MemoryFdPropertiesKHR::default(); let mut fd_props = vk::MemoryFdPropertiesKHR::default();
if let Err(e) = self.ext_fd.get_memory_fd_properties( self.ext_fd
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT, .get_memory_fd_properties(
dup.as_raw_fd(), vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
&mut fd_props, dup,
) { &mut fd_props,
self.device.destroy_buffer(buffer, None); )
return Err(e).context("vkGetMemoryFdPropertiesKHR"); .context("vkGetMemoryFdPropertiesKHR")?;
}
let reqs = self.device.get_buffer_memory_requirements(buffer); let reqs = self.device.get_buffer_memory_requirements(buffer);
let mem_type = match self.memory_type( let mem_type = self.memory_type(
reqs.memory_type_bits & fd_props.memory_type_bits, reqs.memory_type_bits & fd_props.memory_type_bits,
vk::MemoryPropertyFlags::empty(), vk::MemoryPropertyFlags::empty(),
) { )?;
Ok(t) => t,
Err(e) => {
self.device.destroy_buffer(buffer, None);
return Err(e);
}
};
// Vulkan takes ownership of the fd on a SUCCESSFUL import: hand over the raw fd now, and on
// failure close it ourselves (matching the original contract) plus destroy the buffer.
let raw = dup.into_raw_fd();
let mut import = vk::ImportMemoryFdInfoKHR::default() let mut import = vk::ImportMemoryFdInfoKHR::default()
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT) .handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
.fd(raw); .fd(dup); // Vulkan takes ownership of `dup` on success
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer); let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
let memory = match self.device.allocate_memory( let memory = self
&vk::MemoryAllocateInfo::default() .device
.allocation_size(reqs.size.max(size)) .allocate_memory(
.memory_type_index(mem_type) &vk::MemoryAllocateInfo::default()
.push_next(&mut import) .allocation_size(reqs.size.max(size))
.push_next(&mut dedicated), .memory_type_index(mem_type)
None, .push_next(&mut import)
) { .push_next(&mut dedicated),
Ok(m) => m, None,
Err(e) => { )
libc::close(raw); // failed import does not consume the fd .map_err(|e| {
self.device.destroy_buffer(buffer, None); libc::close(dup); // failed import does not consume the fd
return Err(anyhow!("import dmabuf memory: {e}")); anyhow!("import dmabuf memory: {e}")
} })?;
}; self.device
if let Err(e) = self.device.bind_buffer_memory(buffer, memory, 0) { .bind_buffer_memory(buffer, memory, 0)
// `memory` owns the imported fd — freeing it releases the fd too. .context("bind import memory")?;
self.device.free_memory(memory, None);
self.device.destroy_buffer(buffer, None);
return Err(e).context("bind import memory");
}
self.src_cache.insert( self.src_cache.insert(
fd, fd,
SrcBuf { SrcBuf {
@@ -284,11 +263,11 @@ impl VkBridge {
if self.dst.as_ref().is_some_and(|d| d.size >= size) { if self.dst.as_ref().is_some_and(|d| d.size >= size) {
return Ok(()); return Ok(());
} }
// Build the replacement FULLY before retiring the old one. Previously the old dst was if let Some(old) = self.dst.take() {
// destroyed and `self.dst` nulled up front, so a failed rebuild both dropped the working self.device.destroy_buffer(old.buffer, None);
// buffer AND leaked every object the partial rebuild created (`buffer`/`memory` are raw ash self.device.free_memory(old.memory, None);
// handles with no Drop, and `VkBridge::drop` only frees the live `self.dst`). Now every // old.cuda drops its mapping with it
// fallible step unwinds locally, and the swap happens only on full success. }
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default() let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD); .handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
let buffer = self let buffer = self
@@ -306,63 +285,35 @@ impl VkBridge {
.context("create export buffer")?; .context("create export buffer")?;
let reqs = self.device.get_buffer_memory_requirements(buffer); let reqs = self.device.get_buffer_memory_requirements(buffer);
let mem_type = let mem_type =
match self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL) { self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)?;
Ok(t) => t,
Err(e) => {
self.device.destroy_buffer(buffer, None);
return Err(e);
}
};
let mut export = vk::ExportMemoryAllocateInfo::default() let mut export = vk::ExportMemoryAllocateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD); .handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer); let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
let memory = match self.device.allocate_memory( let memory = self
&vk::MemoryAllocateInfo::default() .device
.allocation_size(reqs.size) .allocate_memory(
.memory_type_index(mem_type) &vk::MemoryAllocateInfo::default()
.push_next(&mut export) .allocation_size(reqs.size)
.push_next(&mut dedicated), .memory_type_index(mem_type)
None, .push_next(&mut export)
) { .push_next(&mut dedicated),
Ok(m) => m, None,
Err(e) => { )
self.device.destroy_buffer(buffer, None); .context("allocate exportable memory")?;
return Err(e).context("allocate exportable memory"); self.device
} .bind_buffer_memory(buffer, memory, 0)
}; .context("bind export memory")?;
if let Err(e) = self.device.bind_buffer_memory(buffer, memory, 0) { let opaque_fd = self
self.device.free_memory(memory, None); .ext_fd
self.device.destroy_buffer(buffer, None); .get_memory_fd(
return Err(e).context("bind export memory"); &vk::MemoryGetFdInfoKHR::default()
} .memory(memory)
let opaque_fd = match self.ext_fd.get_memory_fd( .handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD),
&vk::MemoryGetFdInfoKHR::default() )
.memory(memory) .context("vkGetMemoryFdKHR")?;
.handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD),
) {
Ok(f) => f,
Err(e) => {
self.device.free_memory(memory, None);
self.device.destroy_buffer(buffer, None);
return Err(e).context("vkGetMemoryFdKHR");
}
};
// CUDA imports (and on success owns) the exported fd. Size must match the allocation. // CUDA imports (and on success owns) the exported fd. Size must match the allocation.
// `import_owned_fd` closes `opaque_fd` on its own failure, so only the Vulkan objects unwind. let cuda = cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size)
let cuda = match cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size) { .context("cuImportExternalMemory(OPAQUE_FD from Vulkan)")?;
Ok(c) => c,
Err(e) => {
self.device.free_memory(memory, None);
self.device.destroy_buffer(buffer, None);
return Err(e).context("cuImportExternalMemory(OPAQUE_FD from Vulkan)");
}
};
// Full success: retire the previous buffer now, then publish the new one.
if let Some(old) = self.dst.take() {
self.device.destroy_buffer(old.buffer, None);
self.device.free_memory(old.memory, None);
// old.cuda drops its mapping with it
}
tracing::info!(size, "Vulkan→CUDA exportable staging buffer ready"); tracing::info!(size, "Vulkan→CUDA exportable staging buffer ready");
self.dst = Some(DstBuf { self.dst = Some(DstBuf {
buffer, buffer,
@@ -593,19 +544,9 @@ impl VkBridge {
self.device self.device
.queue_submit(self.queue, &[submit], self.fence) .queue_submit(self.queue, &[submit], self.fence)
.context("queue submit")?; .context("queue submit")?;
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still self.device
// executing — `self.cmd` and `self.fence` are reused every frame, and the caller retries
// on the SAME bridge (and `ensure_dst` later destroys `dst.buffer` assuming no in-flight
// work references it). Drain the GPU and reset the fence before propagating so the shared
// cmd/fence return clean.
if let Err(e) = self
.device
.wait_for_fences(&[self.fence], true, 1_000_000_000) .wait_for_fences(&[self.fence], true, 1_000_000_000)
{ .context("fence wait")?;
let _ = self.device.device_wait_idle();
let _ = self.device.reset_fences(&[self.fence]);
return Err(e).context("fence wait");
}
self.device self.device
.reset_fences(&[self.fence]) .reset_fences(&[self.fence])
.context("reset fence")?; .context("reset fence")?;
@@ -698,19 +639,9 @@ impl VkBridge {
self.device self.device
.queue_submit(self.queue, &[submit], self.fence) .queue_submit(self.queue, &[submit], self.fence)
.context("queue submit")?; .context("queue submit")?;
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still self.device
// executing — `self.cmd` and `self.fence` are reused every frame, and the caller retries
// on the SAME bridge (and `ensure_dst` later destroys `dst.buffer` assuming no in-flight
// work references it). Drain the GPU and reset the fence before propagating so the shared
// cmd/fence return clean.
if let Err(e) = self
.device
.wait_for_fences(&[self.fence], true, 1_000_000_000) .wait_for_fences(&[self.fence], true, 1_000_000_000)
{ .context("fence wait")?;
let _ = self.device.device_wait_idle();
let _ = self.device.reset_fences(&[self.fence]);
return Err(e).context("fence wait");
}
self.device self.device
.reset_fences(&[self.fence]) .reset_fences(&[self.fence])
.context("reset fence")?; .context("reset fence")?;
-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 { .. }
));
}
}
+5 -19
View File
@@ -456,14 +456,9 @@ impl NativeClient {
hot_tids, hot_tids,
clock_offset, clock_offset,
decode_lat, decode_lat,
// The controller arms exactly when the pump does — all three terms, not two: Automatic // The controller arms exactly when the pump does (see `abr::BitrateController::new`
// (the user asked for bitrate 0), not a rate-pinned PyroWave stream, AND the host // below): Automatic (the user asked for bitrate 0) and not a rate-pinned PyroWave stream.
// echoed the rate it actually configured. Dropping the last term made this wants_decode: bitrate_kbps == 0 && negotiated.codec != crate::quic::CODEC_PYROWAVE,
// over-advertise against an old host that reports no rate, so an embedder fed decode
// latency to a controller that never runs.
wants_decode: bitrate_kbps == 0
&& negotiated.codec != crate::quic::CODEC_PYROWAVE
&& negotiated.bitrate_kbps > 0,
mode: mode_slot, mode: mode_slot,
host_fingerprint: negotiated.host_fingerprint, host_fingerprint: negotiated.host_fingerprint,
resolved_compositor: negotiated.compositor, resolved_compositor: negotiated.compositor,
@@ -708,23 +703,14 @@ impl NativeClient {
// Reset the accumulator so a fresh run doesn't blend into the previous one. // Reset the accumulator so a fresh run doesn't blend into the previous one.
*self.probe.lock().unwrap() = ProbeState { *self.probe.lock().unwrap() = ProbeState {
active: true, active: true,
duration_ms,
..Default::default() ..Default::default()
}; };
let sent = self self.ctrl_tx
.ctrl_tx
.try_send(CtrlRequest::Probe(ProbeRequest { .try_send(CtrlRequest::Probe(ProbeRequest {
target_kbps, target_kbps,
duration_ms, duration_ms,
})) }))
.map_err(|_| PunktfunkError::Closed); .map_err(|_| PunktfunkError::Closed)
if sent.is_err() {
// Nothing was asked of the host, so nothing will ever answer. Leaving `active` latched
// would suppress the pump's entire report tick for the rest of the session (the pump
// mirrors the startup path's rollback at the same point).
self.probe.lock().unwrap().active = false;
}
sent
} }
/// Read the current speed-test measurement (partial until `done`, final once the host's /// Read the current speed-test measurement (partial until `done`, final once the host's
@@ -32,11 +32,6 @@ pub(crate) struct ProbeState {
pub(crate) host_duration_ms: u32, pub(crate) host_duration_ms: u32,
/// The host's `ProbeResult` arrived → the measurement is final. /// The host's `ProbeResult` arrived → the measurement is final.
pub(crate) done: bool, pub(crate) done: bool,
/// The requested burst length, so the pump can arm a watchdog for a host that never answers.
/// Without one, an ignored `ProbeRequest` latches `active` forever and the pump's whole report
/// tick — loss reports, the ABR window feed, the standing-latency ladder and pending clock
/// re-syncs — stays suppressed for the rest of the session.
pub(crate) duration_ms: u32,
} }
/// A finished/partial speed-test measurement, returned by [`NativeClient::probe_result`]. /// A finished/partial speed-test measurement, returned by [`NativeClient::probe_result`].
+32 -148
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::*;
@@ -94,14 +93,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// as `PunktfunkError::Rejected` instead of the generic transport error the failed // as `PunktfunkError::Rejected` instead of the generic transport error the failed
// read produces — the difference between "not accepted" and the actual cause. // read produces — the difference between "not accepted" and the actual cause.
let handshake = async { let handshake = async {
let (mut send, recv) = conn let (mut send, mut recv) = conn
.open_bi() .open_bi()
.await .await
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?; .map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
// Frame every read on this stream through the resumable reader: the control loop
// below drives it from a `select!` arm and `clock_sync` wraps it in a timeout, and a
// partial frame lost to either would misalign the stream for the whole session.
let mut recv = io::MsgReader::new(recv);
io::write_msg( io::write_msg(
&mut send, &mut send,
@@ -140,7 +135,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
.encode(), .encode(),
) )
.await?; .await?;
let welcome = Welcome::decode(&recv.read_msg().await?)?; let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?;
if welcome.compositor != CompositorPref::Auto { if welcome.compositor != CompositorPref::Auto {
tracing::info!( tracing::info!(
compositor = welcome.compositor.as_str(), compositor = welcome.compositor.as_str(),
@@ -466,7 +461,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
break; break;
} }
} }
msg = ctrl_recv.read_msg() => { msg = io::read_msg(&mut ctrl_recv) => {
let Ok(msg) = msg else { break }; // stream closed let Ok(msg) = msg else { break }; // stream closed
if let Ok(ack) = Reconfigured::decode(&msg) { if let Ok(ack) = Reconfigured::decode(&msg) {
if ack.accepted { if ack.accepted {
@@ -519,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)"
@@ -718,12 +709,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
&& std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0")) && std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0"))
.then(|| Instant::now() + CAPACITY_PROBE_DELAY); .then(|| Instant::now() + CAPACITY_PROBE_DELAY);
let mut capacity_probe_deadline: Option<Instant> = None; let mut capacity_probe_deadline: Option<Instant> = None;
// Edge detector + watchdog for a probe of EITHER origin (the startup capacity probe or an
// embedder speed test via `NativeClient::request_probe`). The startup path had both built
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
// finished one left the ABR window anchored before the burst.
let mut was_probing = false;
let mut probe_watchdog: Option<Instant> = None;
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32); let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
let mut flush_in_window = false; let mut flush_in_window = false;
// Jump-to-live state (see the guard in the loop below): when the clock-based over-bound // Jump-to-live state (see the guard in the loop below): when the clock-based over-bound
@@ -744,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.
@@ -761,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!(
@@ -789,70 +764,31 @@ pub(super) async fn run_pump(args: WorkerArgs) {
} }
p.active && !p.done p.active && !p.done
}; };
// A probe just ended (either kind): rebase EVERY window anchor past the burst. Its
// FLAG_PROBE filler landed in `bytes_received`/`packets_received` (session.rs counts
// every accepted datagram) but never reached the decoder, and the report tick was
// suppressed for the whole burst, so `last_*` still points before it. Without this the
// first post-burst window reads the burst rate as `actual_kbps` and poisons the ABR's
// monotone proven-throughput high-water mark — which never decays — and divides the
// window's loss by a packet count inflated with filler.
if was_probing && !probe_active {
last_recovered = st.fec_recovered_shards;
last_late = st.fec_late_shards;
last_received = st.packets_received;
last_dropped = st.frames_dropped;
last_bytes = st.bytes_received;
last_report = Instant::now();
}
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
// cannot latch `active` forever and suppress the report tick for the whole session.
if !was_probing && probe_active {
let burst = Duration::from_millis(pump_probe.lock().unwrap().duration_ms as u64);
probe_watchdog = Some(Instant::now() + burst + CAPACITY_PROBE_TIMEOUT);
}
if !probe_active {
probe_watchdog = None;
} else if let Some(deadline) = probe_watchdog {
if Instant::now() >= deadline {
probe_watchdog = None;
pump_probe.lock().unwrap().active = false;
tracing::warn!(
"speed-test probe unanswered — clearing it so loss reports and ABR resume"
);
}
}
was_probing = probe_active;
// Fire the startup link-capacity probe once the stream has settled (see the constants // Fire the startup link-capacity probe once the stream has settled (see the constants
// above), and fold its measurement into the ABR ceiling when the result lands. // above), and fold its measurement into the ABR ceiling when the result lands.
// Never steal the slot from an embedder speed test in flight: there is one `ProbeState` if let Some(at) = capacity_probe_at {
// and no correlation id, so a clobber both wrecks the user's "Test connection" figure if Instant::now() >= at {
// (its base counters get re-snapshotted mid-burst against the full-burst denominator) capacity_probe_at = None;
// and mis-scales our own ceiling. Retry once it finishes. *pump_probe.lock().unwrap() = ProbeState {
if capacity_probe_at.is_some_and(|at| Instant::now() >= at) && probe_active { active: true,
capacity_probe_at = Some(Instant::now() + CAPACITY_PROBE_DELAY); ..Default::default()
} else if capacity_probe_at.is_some_and(|at| Instant::now() >= at) { };
capacity_probe_at = None; if ctrl_tx
*pump_probe.lock().unwrap() = ProbeState { .try_send(CtrlRequest::Probe(ProbeRequest {
active: true, target_kbps: CAPACITY_PROBE_KBPS,
duration_ms: CAPACITY_PROBE_MS, duration_ms: CAPACITY_PROBE_MS,
..Default::default() }))
}; .is_ok()
if ctrl_tx {
.try_send(CtrlRequest::Probe(ProbeRequest { capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
target_kbps: CAPACITY_PROBE_KBPS, tracing::info!(
duration_ms: CAPACITY_PROBE_MS, target_kbps = CAPACITY_PROBE_KBPS,
})) duration_ms = CAPACITY_PROBE_MS,
.is_ok() "adaptive bitrate: startup link-capacity probe"
{ );
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT); } else {
tracing::info!( pump_probe.lock().unwrap().active = false; // ctrl queue full — skip
target_kbps = CAPACITY_PROBE_KBPS, }
duration_ms = CAPACITY_PROBE_MS,
"adaptive bitrate: startup link-capacity probe"
);
} else {
pump_probe.lock().unwrap().active = false; // ctrl queue full — skip
} }
} }
if let Some(deadline) = capacity_probe_deadline { if let Some(deadline) = capacity_probe_deadline {
@@ -907,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.
@@ -1090,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**
+4 -25
View File
@@ -39,19 +39,10 @@ pub struct Packetizer {
/// DATA shards before any block's parity — all blocks' parity must stay alive until the /// DATA shards before any block's parity — all blocks' parity must stay alive until the
/// frame's second emission pass. /// frame's second emission pass.
recovery: Vec<Vec<Vec<u8>>>, recovery: Vec<Vec<Vec<u8>>>,
/// The peer's per-block `data + recovery` acceptance ceiling, frozen from the **negotiated**
/// config exactly as the far side derives it in [`ReassemblerLimits::from_config`]. Adaptive
/// FEC moves `fec.fec_percent` live ([`set_fec_percent`](Self::set_fec_percent)) but the
/// receiver's ceiling is computed once at session construction and never re-derived, so parity
/// must be clamped against this or a raised percentage puts blocks over the far side's bound —
/// where every packet of the block is dropped wholesale, the frame never completes, and the
/// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`.
max_total_shards: usize,
} }
impl Packetizer { impl Packetizer {
pub fn new(config: &Config) -> Self { pub fn new(config: &Config) -> Self {
let max_data = config.fec.max_data_per_block as usize;
Packetizer { Packetizer {
next_frame_index: 0, next_frame_index: 0,
next_probe_index: 0, next_probe_index: 0,
@@ -61,9 +52,6 @@ impl Packetizer {
version: config.phase as u8, version: config.phase as u8,
tail: Vec::new(), tail: Vec::new(),
recovery: Vec::new(), recovery: Vec::new(),
// Mirrors `ReassemblerLimits::from_config` — keep the two in step.
max_total_shards: (max_data + config.fec.recovery_for(max_data))
.min(config.fec.scheme.max_total_shards()),
} }
} }
@@ -185,15 +173,6 @@ impl Packetizer {
}; };
// Per-block shard geometry (deterministic — recomputed in both passes). // Per-block shard geometry (deterministic — recomputed in both passes).
let block_data_count = |b: usize| ((b + 1) * max_block).min(total_data) - b * max_block; let block_data_count = |b: usize| ((b + 1) * max_block).min(total_data) - b * max_block;
// Parity for a `k`-shard block: the configured percentage, clamped so the block's wire
// total never exceeds what the peer will accept (see `max_total_shards`). The clamp only
// binds on blocks near `max_data_per_block`; smaller blocks keep the full adaptive range,
// so raising FEC still buys real protection wherever there is headroom. Bound as locals,
// not as a `&self` method: `emit_one` below would otherwise capture all of `self` and
// collide with the `&mut self.recovery[b]` parity borrow.
let (fec, max_total_shards) = (self.fec, self.max_total_shards);
let recovery_for =
move |k: usize| fec.recovery_for(k).min(max_total_shards.saturating_sub(k));
// One parity pool per block, reused across frames (steady-state zero-alloc). // One parity pool per block, reused across frames (steady-state zero-alloc).
if self.recovery.len() < block_count { if self.recovery.len() < block_count {
@@ -204,7 +183,7 @@ impl Packetizer {
let mut total_recovery = 0usize; let mut total_recovery = 0usize;
for b in 0..block_count { for b in 0..block_count {
let k = block_data_count(b); let k = block_data_count(b);
let m = recovery_for(k); let m = self.fec.recovery_for(k);
if k + m > u16::MAX as usize { if k + m > u16::MAX as usize {
return Err(PunktfunkError::Unsupported("block shard count exceeds u16")); return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
} }
@@ -225,7 +204,7 @@ impl Packetizer {
block_index: b as u16, block_index: b as u16,
block_count: block_count as u16, block_count: block_count as u16,
data_shards: k as u16, data_shards: k as u16,
recovery_shards: recovery_for(k) as u16, recovery_shards: self.fec.recovery_for(k) as u16,
shard_index: shard_index as u16, shard_index: shard_index as u16,
shard_bytes: payload as u16, shard_bytes: payload as u16,
magic: PUNKTFUNK_MAGIC, magic: PUNKTFUNK_MAGIC,
@@ -244,7 +223,7 @@ impl Packetizer {
// This block's data shards: references into `frame` (plus the staged tail). // This block's data shards: references into `frame` (plus the staged tail).
let data_shards: Vec<&[u8]> = (first..first + k).map(shard_at).collect(); let data_shards: Vec<&[u8]> = (first..first + k).map(shard_at).collect();
let recovery_count = recovery_for(k); let recovery_count = self.fec.recovery_for(k);
coder.encode_into(&data_shards, recovery_count, &mut self.recovery[b])?; coder.encode_into(&data_shards, recovery_count, &mut self.recovery[b])?;
for (shard_index, body) in data_shards.iter().enumerate() { for (shard_index, body) in data_shards.iter().enumerate() {
@@ -263,7 +242,7 @@ impl Packetizer {
let mut parity_left = total_recovery; let mut parity_left = total_recovery;
for b in 0..block_count { for b in 0..block_count {
let k = block_data_count(b); let k = block_data_count(b);
let recovery_count = recovery_for(k); let recovery_count = self.fec.recovery_for(k);
for r in 0..recovery_count { for r in 0..recovery_count {
parity_left -= 1; parity_left -= 1;
let mut flags = FLAG_PIC; let mut flags = FLAG_PIC;
+1 -14
View File
@@ -106,19 +106,8 @@ pub struct ReassemblerLimits {
impl ReassemblerLimits { impl ReassemblerLimits {
pub fn from_config(c: &Config) -> Self { pub fn from_config(c: &Config) -> Self {
let max_data = c.fec.max_data_per_block as usize; let max_data = c.fec.max_data_per_block as usize;
// Size the ceiling from the whole range adaptive FEC may reach, NOT from the percentage
// negotiated at session start: the sender moves `fec_percent` live (`Packetizer::
// set_fec_percent`, clamped to ≤ 90) and the wire is self-describing, so it never
// renegotiates. Deriving this from the start value made every packet of a large block
// fail the `total > max_total_shards` check once FEC ramped up — the block never
// accumulated a shard, the frame aged out, and the resulting loss drove FEC *higher*,
// wedging large frames at 100% loss exactly when FEC was meant to rescue the link. A
// current sender also clamps its side (`Packetizer::recovery_for`); this keeps an
// already-deployed sender that doesn't from wedging a current receiver. Still a hard
// pre-allocation bound against hostile headers — just the sender's clamp, not a stale
// snapshot of it.
let max_total = let max_total =
(max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards()); (max_data + c.fec.recovery_for(max_data)).min(c.fec.scheme.max_total_shards());
let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1); let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1);
ReassemblerLimits { ReassemblerLimits {
shard_bytes: c.shard_payload, shard_bytes: c.shard_payload,
@@ -500,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)
@@ -604,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
}); });
} }
} }
-61
View File
@@ -579,64 +579,3 @@ fn rejects_wrong_shard_bytes_and_oversized_frame() {
.is_none()); .is_none());
assert_eq!(stats.snapshot().packets_dropped, 1); assert_eq!(stats.snapshot().packets_dropped, 1);
} }
/// Adaptive FEC raises `fec_percent` mid-session while the receiver's per-block acceptance
/// ceiling is frozen at session construction and never renegotiated. A maximal block must
/// therefore still land: the sender clamps its parity to the ceiling
/// (`Packetizer::recovery_for`), and the receiver sizes that ceiling from the whole clamp range
/// rather than the start percentage. Regression guard for the wedge this caused — every packet
/// of a large block failing `total > max_total_shards`, so the frame never completed and the
/// resulting loss drove adaptive FEC higher still.
#[test]
fn adaptive_fec_ramp_keeps_maximal_blocks_within_the_peers_ceiling() {
let cfg = e2e_config(FecScheme::Gf16, 10);
let coder = coder_for(FecScheme::Gf16);
let lim = ReassemblerLimits::from_config(&cfg);
let mut pk = Packetizer::new(&cfg);
// Ramp far past the negotiated 10% — exactly what `apply_fec_target` does under loss.
pk.set_fec_percent(50);
// A frame of full `max_data_per_block` blocks: where the ceiling actually binds.
let frame_len = cfg.shard_payload * cfg.fec.max_data_per_block as usize * 2;
let src: Vec<u8> = (0..frame_len).map(|i| (i * 131 + 7) as u8).collect();
let pkts = pk.packetize(&src, 1, 0, coder.as_ref()).unwrap();
let k = cfg.fec.max_data_per_block as usize;
let mut clamped = false;
for p in &pkts {
let hdr = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
let total = hdr.data_shards as usize + hdr.recovery_shards as usize;
assert!(
total <= lim.max_total_shards,
"block total {total} exceeds the peer's ceiling {} — every packet of this block \
would be dropped",
lim.max_total_shards
);
// The unclamped 50% would put 2 parity on a full block; the negotiated 10% ceiling
// leaves room for 1. Proves the clamp actually bound rather than passing vacuously.
if hdr.data_shards as usize == k {
assert!(
(hdr.recovery_shards as usize) < cfg.fec.recovery_for(k).max(1) + 1,
"parity must be clamped to the peer's ceiling"
);
clamped = true;
}
}
assert!(clamped, "test must exercise a maximal block");
// And the frame still reassembles byte-identically.
let mut r = Reassembler::new(lim);
let stats = StatsCounters::default();
let mut got = None;
for p in &pkts {
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
got = Some(f);
}
}
assert_eq!(
got.expect("frame must complete after an adaptive-FEC ramp")
.data,
src
);
}
+2 -2
View File
@@ -38,7 +38,7 @@ pub struct ClockSkew {
/// with, so the offset aligns a client receive instant to the host's capture clock. /// with, so the offset aligns a client receive instant to the host's capture clock.
pub async fn clock_sync( pub async fn clock_sync(
send: &mut quinn::SendStream, send: &mut quinn::SendStream,
recv: &mut io::MsgReader, recv: &mut quinn::RecvStream,
) -> Option<ClockSkew> { ) -> Option<ClockSkew> {
use std::time::Duration; use std::time::Duration;
const ROUNDS: usize = 8; const ROUNDS: usize = 8;
@@ -50,7 +50,7 @@ pub async fn clock_sync(
if io::write_msg(send, &probe).await.is_err() { if io::write_msg(send, &probe).await.is_err() {
break; break;
} }
let read = tokio::time::timeout(read_timeout, recv.read_msg()).await; let read = tokio::time::timeout(read_timeout, io::read_msg(recv)).await;
let echo = match read { let echo = match read {
Ok(Ok(b)) => match ClockEcho::decode(&b) { Ok(Ok(b)) => match ClockEcho::decode(&b) {
Ok(e) => e, Ok(e) => e,
-76
View File
@@ -1,14 +1,6 @@
//! Length-prefixed framing for QUIC control-stream messages: a `u16` length header followed by the //! Length-prefixed framing for QUIC control-stream messages: a `u16` length header followed by the
//! payload, bounded at 64 KiB (control messages are tiny). //! payload, bounded at 64 KiB (control messages are tiny).
/// Read one framed message (bounded at 64 KiB — control messages are tiny). /// Read one framed message (bounded at 64 KiB — control messages are tiny).
///
/// **Not cancel-safe**: it frames with two `quinn::RecvStream::read_exact` calls, and quinn
/// documents `read_exact` as not cancel-safe (the bytes it has already taken out of the stream
/// live only in the future's own buffer, and nothing puts them back on drop). Dropping a
/// partially-progressed future therefore destroys the bytes it consumed and misaligns every
/// subsequent read on that stream. Use it only where the read runs to completion — the sequential
/// handshake/pairing exchanges. Anything driving a read from a `select!` arm or a
/// `tokio::time::timeout` must use [`MsgReader`] instead.
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> { pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 2]; let mut len = [0u8; 2];
recv.read_exact(&mut len) recv.read_exact(&mut len)
@@ -22,74 +14,6 @@ pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>>
Ok(buf) Ok(buf)
} }
/// Cancel-safe framed reader for a long-lived control stream.
///
/// Keeps the frame in progress in `buf` rather than inside the read future, so dropping the future
/// — which both control loops do on every iteration where a sibling `select!` arm wins, and which
/// [`clock_sync`](super::clock_sync) does on a read timeout — resumes instead of losing bytes.
/// With the plain [`read_msg`] a control frame that straddles two wakeups (a ~2 KB `ClipOffer`
/// exceeds one QUIC packet; so does any frame whose second half is lost or reordered) left the
/// stream permanently misaligned: the next read took two payload bytes as a length, every later
/// message decoded as garbage and was silently ignored, and a bogus 64 KiB length parked the read
/// forever — killing mode switches, adaptive bitrate, clock re-sync and clipboard for the rest of
/// the session with nothing but a `warn!` in the log.
pub struct MsgReader {
recv: quinn::RecvStream,
/// The frame in progress, length prefix included.
buf: Vec<u8>,
/// Bytes `buf` must reach: 2 while reading the prefix, then `2 + payload length`.
need: usize,
}
impl MsgReader {
pub fn new(recv: quinn::RecvStream) -> Self {
MsgReader {
recv,
buf: Vec::new(),
need: 2,
}
}
/// Read one framed message. Cancel-safe: dropping the future keeps the partial frame, so the
/// next call resumes where this one stopped.
pub async fn read_msg(&mut self) -> std::io::Result<Vec<u8>> {
loop {
while self.buf.len() < self.need {
let mut chunk = [0u8; 2048];
let want = (self.need - self.buf.len()).min(chunk.len());
// `read` IS cancel-safe: it only reports bytes it hands back, and they are
// committed to `self.buf` before the next await point.
match self
.recv
.read(&mut chunk[..want])
.await
.map_err(std::io::Error::other)?
{
Some(n) => self.buf.extend_from_slice(&chunk[..n]),
None => {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"control stream finished mid-frame",
))
}
}
}
if self.need == 2 {
self.need = 2 + u16::from_le_bytes([self.buf[0], self.buf[1]]) as usize;
if self.need == 2 {
self.buf.clear();
return Ok(Vec::new()); // zero-length frame
}
} else {
let msg = self.buf.split_off(2);
self.buf.clear();
self.need = 2;
return Ok(msg);
}
}
}
}
/// Write one framed message. /// Write one framed message.
pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io::Result<()> { pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io::Result<()> {
send.write_all(&super::frame(payload)) send.write_all(&super::frame(payload))
+1 -82
View File
@@ -1585,7 +1585,7 @@ mod clip_loopback {
/// Stand up two loopback quinn endpoints, connect, and return /// Stand up two loopback quinn endpoints, connect, and return
/// `(server_ep, client_ep, host_conn, client_conn)`. Both endpoints are returned so the caller /// `(server_ep, client_ep, host_conn, client_conn)`. Both endpoints are returned so the caller
/// keeps them in scope — dropping a `quinn::Endpoint` tears down its connections. /// keeps them in scope — dropping a `quinn::Endpoint` tears down its connections.
pub(super) async fn connect_pair() -> ( async fn connect_pair() -> (
quinn::Endpoint, quinn::Endpoint,
quinn::Endpoint, quinn::Endpoint,
quinn::Connection, quinn::Connection,
@@ -1731,84 +1731,3 @@ mod clip_loopback {
let _host_conn = holder.await.unwrap(); let _host_conn = holder.await.unwrap();
} }
} }
/// The control stream is read from a `select!` arm on both peers, so the read future is dropped
/// routinely — and quinn documents `read_exact` (what `io::read_msg` uses) as NOT cancel-safe.
/// [`io::MsgReader`] must survive that: the partial frame lives in the reader, not the future.
mod ctrl_framing {
use super::clip_loopback::connect_pair;
use super::*;
use crate::quic::io;
/// A frame whose halves land in different wakeups, with the read cancelled in between, must
/// still be delivered whole — and the NEXT frame must decode correctly too. Without a
/// resumable reader the consumed length prefix is lost, the following read takes two payload
/// bytes as a length, and every later control message is garbage for the rest of the session.
#[tokio::test]
async fn cancelled_mid_frame_read_resumes_without_desync() {
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
let first = b"the-frame-that-straddles-two-wakeups".to_vec();
let second = b"the-frame-after-it".to_vec();
let (f1, f2) = (first.clone(), second.clone());
let writer = tokio::spawn(async move {
let (mut send, _recv) = host_conn.open_bi().await.expect("open bi");
let framed = crate::quic::frame(&f1);
// Length prefix + only part of the payload, then a real pause: this is the ClipOffer
// -sized frame split across two QUIC packets that made the bug reachable.
let split = 2 + f1.len() / 3;
send.write_all(&framed[..split]).await.expect("write head");
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
send.write_all(&framed[split..]).await.expect("write tail");
send.write_all(&crate::quic::frame(&f2))
.await
.expect("write second");
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
host_conn
});
let (_send, recv) = client_conn.accept_bi().await.expect("accept bi");
let mut reader = io::MsgReader::new(recv);
// Cancel mid-frame — exactly what a sibling `select!` arm does.
let cancelled =
tokio::time::timeout(std::time::Duration::from_millis(30), reader.read_msg()).await;
assert!(
cancelled.is_err(),
"the head-only frame must not complete yet (test setup)"
);
let got = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read_msg())
.await
.expect("first frame must arrive after resuming")
.expect("first frame reads cleanly");
assert_eq!(got, first, "the cancelled read must resume, not lose bytes");
let got2 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read_msg())
.await
.expect("second frame must arrive")
.expect("second frame reads cleanly");
assert_eq!(got2, second, "stream must still be framed correctly");
let _host_conn = writer.await.unwrap();
}
/// A zero-length frame is a legal encoding and must not stall the reader or eat the next one.
#[tokio::test]
async fn zero_length_frame_round_trips() {
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
let writer = tokio::spawn(async move {
let (mut send, _recv) = host_conn.open_bi().await.expect("open bi");
send.write_all(&crate::quic::frame(&[])).await.unwrap();
send.write_all(&crate::quic::frame(b"after")).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
host_conn
});
let (_send, recv) = client_conn.accept_bi().await.expect("accept bi");
let mut reader = io::MsgReader::new(recv);
assert!(reader.read_msg().await.unwrap().is_empty());
assert_eq!(reader.read_msg().await.unwrap(), b"after");
let _host_conn = writer.await.unwrap();
}
}
+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);
} }
} }
} }
+9 -132
View File
@@ -110,18 +110,8 @@ pub fn spawn_data_punch(sock: UdpSocket, stop: std::sync::Arc<std::sync::atomic:
.spawn(move || { .spawn(move || {
let mut i = 0u32; let mut i = 0u32;
while !stop.load(std::sync::atomic::Ordering::Relaxed) { while !stop.load(std::sync::atomic::Ordering::Relaxed) {
match sock.send(PUNCH_MAGIC) { if sock.send(PUNCH_MAGIC).is_err() {
Ok(_) => {} break;
// Same contract as `Transport::send`: a momentarily full tx queue, a stale
// ICMP or a network-path blip is a lossy drop, not a reason to stop holding
// the NAT/firewall path open. Breaking here is silent and permanent — the
// path recovers, video keeps flowing, and the stream dies later when the
// idle timer expires the mapping during a static scene.
Err(e) if is_transient_io(&e) => {}
Err(e) => {
tracing::debug!(error = %e, "data-plane punch send failed — stopping keepalive");
break;
}
} }
let delay_ms = if i < 15 { 200 } else { 2000 }; let delay_ms = if i < 15 { 200 } else { 2000 };
i = i.saturating_add(1); i = i.saturating_add(1);
@@ -170,65 +160,34 @@ impl UdpTransport {
/// NAT-translated one, which can differ from the client-reported `fallback_peer`). If no punch /// NAT-translated one, which can differ from the client-reported `fallback_peer`). If no punch
/// arrives (a client that doesn't hole-punch), fall back to `fallback_peer` — the same flat-LAN /// arrives (a client that doesn't hole-punch), fall back to `fallback_peer` — the same flat-LAN
/// behaviour as [`connect`](Self::connect). Returns `(transport, punched)`. /// behaviour as [`connect`](Self::connect). Returns `(transport, punched)`.
///
/// `expect_ip` is the *authenticated* peer address (the QUIC connection's remote IP) — see
/// [`from_socket_punch`](Self::from_socket_punch) for why only punches from it are honoured.
pub fn connect_via_punch( pub fn connect_via_punch(
local: &str, local: &str,
fallback_peer: &str, fallback_peer: &str,
expect_ip: std::net::IpAddr,
punch_timeout: std::time::Duration, punch_timeout: std::time::Duration,
) -> std::io::Result<(Self, bool)> { ) -> std::io::Result<(Self, bool)> {
Self::from_socket_punch( Self::from_socket_punch(UdpSocket::bind(local)?, fallback_peer, punch_timeout)
UdpSocket::bind(local)?,
fallback_peer,
expect_ip,
punch_timeout,
)
} }
/// [`connect_via_punch`](Self::connect_via_punch) on an already-bound socket — see /// [`connect_via_punch`](Self::connect_via_punch) on an already-bound socket — see
/// [`from_socket`](Self::from_socket) for why the host binds the data port up front. /// [`from_socket`](Self::from_socket) for why the host binds the data port up front.
///
/// `expect_ip` binds the data plane to the peer the control plane already authenticated.
/// [`PUNCH_MAGIC`] is a fixed public constant carrying no key, nonce or session id, so without
/// this check *any* source that lands an 8-byte datagram on the (ephemeral, sprayable) data
/// port during the punch wait becomes the video destination — the legitimate client is then
/// filtered out by the `connect` below and receives nothing, while QUIC stays healthy so no
/// reconnect is triggered. Only the *port* is in question here (that is what a NAT remaps, and
/// what the punch exists to discover); the IP is known, because the client binds `0.0.0.0:0`
/// and dials the same host IP as its QUIC connection, so the kernel picks the same source IP
/// for both planes and any NAT on the path presents one source IP for both.
pub fn from_socket_punch( pub fn from_socket_punch(
socket: UdpSocket, socket: UdpSocket,
fallback_peer: &str, fallback_peer: &str,
expect_ip: std::net::IpAddr,
punch_timeout: std::time::Duration, punch_timeout: std::time::Duration,
) -> std::io::Result<(Self, bool)> { ) -> std::io::Result<(Self, bool)> {
socket.set_read_timeout(Some(punch_timeout))?;
let deadline = std::time::Instant::now() + punch_timeout; let deadline = std::time::Instant::now() + punch_timeout;
let mut buf = [0u8; 64]; let mut buf = [0u8; 64];
let mut observed: Option<std::net::SocketAddr> = None; let mut observed: Option<std::net::SocketAddr> = None;
loop { loop {
// Budget the read from what's LEFT, not the full window: off-peer datagrams are
// discarded below, and a full-window timeout per read would let a stray flood stretch
// the punch wait far past `punch_timeout`.
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
break;
}
socket.set_read_timeout(Some(remaining))?;
match socket.recv_from(&mut buf) { match socket.recv_from(&mut buf) {
Ok((n, src)) Ok((n, src))
if src.ip() == expect_ip if n >= PUNCH_MAGIC.len() && &buf[..PUNCH_MAGIC.len()] == PUNCH_MAGIC =>
&& n >= PUNCH_MAGIC.len()
&& &buf[..PUNCH_MAGIC.len()] == PUNCH_MAGIC =>
{ {
observed = Some(src); observed = Some(src);
break; break;
} }
// Stray, or a well-formed punch from someone who isn't the authenticated peer — Ok(_) => {} // stray datagram — keep waiting for a real punch
// keep waiting for a real one.
Ok(_) => {}
Err(e) Err(e)
if matches!( if matches!(
e.kind(), e.kind(),
@@ -239,6 +198,9 @@ impl UdpTransport {
} }
Err(e) => return Err(e), Err(e) => return Err(e),
} }
if std::time::Instant::now() >= deadline {
break;
}
} }
let punched = observed.is_some(); let punched = observed.is_some();
let target = observed.map(|s| s.to_string()); let target = observed.map(|s| s.to_string());
@@ -520,89 +482,4 @@ mod tests {
"every datagram should be drained via recv_batch" "every datagram should be drained via recv_batch"
); );
} }
/// The punch discovers the peer's NAT-remapped *port*, so a punch from the authenticated IP on
/// a port that differs from the client-reported one must still be adopted — that is the whole
/// reason hole-punching exists, and the source-IP check must not break it.
#[test]
fn punch_adopts_remapped_port_from_the_authenticated_peer() {
// Stands in for the client's post-NAT data socket: same IP as the "QUIC peer", new port.
let puncher = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
puncher
.set_read_timeout(Some(std::time::Duration::from_millis(500)))
.unwrap();
// The client-*reported* address, which the NAT remapped — video must NOT go here.
let reported = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
reported
.set_read_timeout(Some(std::time::Duration::from_millis(200)))
.unwrap();
let host_sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let host_addr = host_sock.local_addr().unwrap();
puncher.send_to(PUNCH_MAGIC, host_addr).unwrap();
let (transport, punched) = UdpTransport::from_socket_punch(
host_sock,
&reported.local_addr().unwrap().to_string(),
std::net::IpAddr::from([127, 0, 0, 1]),
std::time::Duration::from_millis(500),
)
.unwrap();
assert!(punched, "a punch from the authenticated IP must be adopted");
transport.send(b"video").unwrap();
let mut buf = [0u8; 64];
let n = puncher
.recv(&mut buf)
.expect("video must follow the punched (NAT-remapped) port");
assert_eq!(&buf[..n], b"video");
assert!(
reported.recv(&mut buf).is_err(),
"video must not go to the stale reported port"
);
}
/// A punch from any source other than the QUIC-authenticated peer must be ignored: `PUNCH_MAGIC`
/// is a fixed public constant with no key or session id, so honouring an off-peer punch lets
/// anyone who lands an 8-byte datagram on the ephemeral data port steal (or redirect) the video
/// plane while the control plane stays healthy. Falling back to the reported address is correct.
#[test]
fn punch_from_an_unauthenticated_source_is_ignored() {
let attacker = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
attacker
.set_read_timeout(Some(std::time::Duration::from_millis(200)))
.unwrap();
let legit = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
legit
.set_read_timeout(Some(std::time::Duration::from_millis(500)))
.unwrap();
let host_sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let host_addr = host_sock.local_addr().unwrap();
attacker.send_to(PUNCH_MAGIC, host_addr).unwrap();
// The authenticated peer is TEST-NET-1, so nothing arriving over loopback is the peer.
let (transport, punched) = UdpTransport::from_socket_punch(
host_sock,
&legit.local_addr().unwrap().to_string(),
std::net::IpAddr::from([192, 0, 2, 1]),
std::time::Duration::from_millis(300),
)
.unwrap();
assert!(
!punched,
"an off-peer punch must not be adopted as the video destination"
);
transport.send(b"video").unwrap();
let mut buf = [0u8; 64];
assert!(
attacker.recv(&mut buf).is_err(),
"video must never be redirected to the punch source"
);
let n = legit
.recv(&mut buf)
.expect("video falls back to the reported peer address");
assert_eq!(&buf[..n], b"video");
}
} }
+6 -42
View File
@@ -168,29 +168,6 @@ fn main() {
} }
} }
/// A lightweight management/CLI subcommand — package/service/driver ops, spec/library dumps — as
/// opposed to a streaming/capture command. These never touch DXGI or run the host, so they skip the
/// startup banner and (on Windows) the GPU-preference hook, whose DPI-awareness probe otherwise
/// prints an alarming `SetProcessDpiAwarenessContext … "access denied"` WARN on a plain
/// `plugins add`. `service run` is the SCM-launched host itself, so it is explicitly NOT lightweight
/// (it must keep the hook — the hybrid-GPU ACCESS_LOST fix depends on it).
fn is_management_cli(args: &[String]) -> bool {
match args.first().map(String::as_str) {
Some("plugins")
| Some("driver")
| Some("web")
| Some("openapi")
| Some("library")
| Some("detect-conflicts")
| Some("-h")
| Some("--help")
| Some("help")
| None => true,
Some("service") => args.get(1).map(String::as_str) != Some("run"),
_ => false,
}
}
fn real_main() -> Result<()> { fn real_main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
@@ -203,16 +180,11 @@ fn real_main() -> Result<()> {
return Ok(()); return Ok(());
} }
// Lightweight CLI commands (e.g. `plugins add`) get none of the host-startup noise below. tracing::info!(
let management_cli = is_management_cli(&args); "punktfunk-host {} (punktfunk_core ABI v{})",
env!("PUNKTFUNK_VERSION"),
if !management_cli { punktfunk_core::ABI_VERSION
tracing::info!( );
"punktfunk-host {} (punktfunk_core ABI v{})",
env!("PUNKTFUNK_VERSION"),
punktfunk_core::ABI_VERSION
);
}
// Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a // Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a
// neutral DisplayEvent; the orchestrator owns the bus type — plan §W6). Set once, ignore re-set. // neutral DisplayEvent; the orchestrator owns the bus type — plan §W6). Set once, ignore re-set.
@@ -236,12 +208,8 @@ fn real_main() -> Result<()> {
// render-adapter selection creates a DXGI factory during virtual-display setup, well before // render-adapter selection creates a DXGI factory during virtual-display setup, well before
// capture). On a hybrid-GPU box this stops DXGI from reparenting the virtual output off the // capture). On a hybrid-GPU box this stops DXGI from reparenting the virtual output off the
// capture GPU — the ACCESS_LOST churn fix. Idempotent (Once); harmless on non-hybrid boxes. // capture GPU — the ACCESS_LOST churn fix. Idempotent (Once); harmless on non-hybrid boxes.
// Skipped for lightweight CLI commands (`plugins`, `openapi`, …): they never touch DXGI, and the
// hook's DPI-awareness probe prints a misleading "access denied" WARN that looks like a failure.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if !management_cli { crate::capture::dxgi::install_gpu_pref_hook();
crate::capture::dxgi::install_gpu_pref_hook();
}
// NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile and, // NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile and,
// under PUNKTFUNK_PIN_CLOCKS, hold the NVML core-clock floor for the host lifetime (reset on // under PUNKTFUNK_PIN_CLOCKS, hold the NVML core-clock floor for the host lifetime (reset on
@@ -530,10 +498,6 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
if opts.token.is_none() { if opts.token.is_none() {
opts.token = Some(crate::mgmt_token::load_or_generate()?); opts.token = Some(crate::mgmt_token::load_or_generate()?);
} }
// The scripting runner's scoped credential: minted + persisted (plugin-token) alongside the
// admin token so a plugin's zero-config `connect()` picks it up — it authorizes the plugin
// surface but not hook registration or pairing administration (mgmt::auth::plugin_may_access).
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can // Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
// fetch the game library over mTLS with no operator step — the whole point of "browse works by // fetch the game library over mTLS with no operator step — the whole point of "browse works by
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface // default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
-11
View File
@@ -56,10 +56,6 @@ pub struct Options {
/// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated, /// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated,
/// which [`run`] only permits on loopback binds. /// which [`run`] only permits on loopback binds.
pub token: Option<String>, pub token: Option<String>,
/// The scripting runner's capability-limited bearer token (`plugin-token`): authorizes the
/// plugin surface only, never hook registration or pairing administration
/// (`auth::plugin_may_access`). Optional — `None` simply disables the lane.
pub plugin_token: Option<String>,
} }
impl Default for Options { impl Default for Options {
@@ -67,7 +63,6 @@ impl Default for Options {
Options { Options {
bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)), bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
token: None, token: None,
plugin_token: None,
} }
} }
} }
@@ -86,9 +81,6 @@ pub(crate) struct MgmtState {
/// (native-only) host, where a Moonlight PIN can never arrive. /// (native-only) host, where a Moonlight PIN can never arrive.
gamestream_enabled: bool, gamestream_enabled: bool,
token: Option<String>, token: Option<String>,
/// The plugin lane's token (see [`Options::plugin_token`]). Checked only after the admin token
/// mismatches, and gated by `auth::plugin_may_access` per route.
plugin_token: Option<String>,
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map. /// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
port: u16, port: u16,
} }
@@ -127,7 +119,6 @@ pub async fn run(
let app = app( let app = app(
state, state,
Some(token), Some(token),
opts.plugin_token.filter(|t| !t.trim().is_empty()),
opts.bind.port(), opts.bind.port(),
native, native,
stats, stats,
@@ -140,7 +131,6 @@ pub async fn run(
fn app( fn app(
state: Arc<AppState>, state: Arc<AppState>,
token: Option<String>, token: Option<String>,
plugin_token: Option<String>,
port: u16, port: u16,
native: Option<Arc<crate::native_pairing::NativePairing>>, native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>, stats: Arc<crate::stats_recorder::StatsRecorder>,
@@ -152,7 +142,6 @@ fn app(
stats, stats,
gamestream_enabled, gamestream_enabled,
token, token,
plugin_token,
port, port,
}); });
let (api_routes, api) = api_router_parts(); let (api_routes, api) = api_router_parts();
+1 -54
View File
@@ -1,12 +1,5 @@
//! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere) //! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere)
//! or a bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5). //! or the bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5).
//!
//! Three lanes, three authorities:
//! - **paired streaming cert** (mTLS, LAN) — the read-only [`cert_may_access`] allowlist.
//! - **plugin token** (bearer, loopback) — the scripting runner's capability-limited credential:
//! the admin surface MINUS hook registration and pairing administration
//! ([`plugin_may_access`]).
//! - **admin token** (bearer, loopback) — everything.
use super::shared::*; use super::shared::*;
use crate::gamestream::tls::PeerAddr; use crate::gamestream::tls::PeerAddr;
@@ -93,27 +86,6 @@ pub(crate) async fn require_auth(
.and_then(|v| v.strip_prefix("Bearer ")); .and_then(|v| v.strip_prefix("Bearer "));
match presented { match presented {
Some(token) if token_eq(token, expected) => next.run(req).await, Some(token) if token_eq(token, expected) => next.run(req).await,
// The scripting runner's scoped lane: same loopback confinement as the admin token, but
// routes that would let a plugin escalate — registering hooks (arbitrary command
// execution as the host user) or administering pairing (admitting/ejecting devices,
// reading the PIN) — need the operator's admin token. Checked AFTER the admin token so
// equal tokens (operator misconfiguration) degrade to full access, never to a lockout.
Some(token)
if st
.plugin_token
.as_deref()
.is_some_and(|pt| token_eq(token, pt)) =>
{
if plugin_may_access(req.method(), req.uri().path()) {
next.run(req).await
} else {
api_error(
StatusCode::FORBIDDEN,
"this route is not authorized for the plugin token — it requires the \
operator's admin token",
)
}
}
_ => api_error( _ => api_error(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"missing or invalid credentials (a paired client cert, or a bearer token)", "missing or invalid credentials (a paired client cert, or a bearer token)",
@@ -121,31 +93,6 @@ pub(crate) async fn require_auth(
} }
} }
/// Which routes the scripting runner's **plugin token** may reach: the admin surface minus the
/// escalation routes. Exclusion-based (a plugin legitimately reads status/library/events, drives
/// sessions, and registers its UI lease), with these carve-outs:
/// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary
/// command execution as the host user, and reading it can expose webhook credentials.
/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide
/// *which devices may stream*; a plugin defect must not be able to admit an attacker's device
/// or eject the operator's.
/// - **UI proxy credentials** — a plugin has no business reading another plugin's per-boot UI
/// secret; only the console proxy (admin token) needs it.
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
let denied = path == "/api/v1/hooks"
|| path == "/api/v1/pair"
|| path.starts_with("/api/v1/pair/")
|| path == "/api/v1/native/pair"
|| path.starts_with("/api/v1/native/pair/")
|| path == "/api/v1/native/pending"
|| path.starts_with("/api/v1/native/pending/")
|| (method == Method::DELETE
&& (path.starts_with("/api/v1/clients/")
|| path.starts_with("/api/v1/native/clients/")))
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"));
!denied
}
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of /// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
/// safe, read-only status routes only. Deny-by-default — every state-changing route and every route /// safe, read-only status routes only. Deny-by-default — every state-changing route and every route
/// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so /// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so
-131
View File
@@ -42,8 +42,6 @@ fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
app( app(
state, state,
Some(token.unwrap_or("test-secret").to_string()), Some(token.unwrap_or("test-secret").to_string()),
// The scoped plugin lane, exercised by the `plugin_token_*` tests below.
Some("plugin-secret".to_string()),
DEFAULT_PORT, DEFAULT_PORT,
None, None,
stats, stats,
@@ -59,7 +57,6 @@ fn test_app_native(state: Arc<AppState>, np: Arc<crate::native_pairing::NativePa
app( app(
state, state,
Some("test-secret".to_string()), Some("test-secret".to_string()),
Some("plugin-secret".to_string()),
DEFAULT_PORT, DEFAULT_PORT,
Some(np), Some(np),
stats, stats,
@@ -377,133 +374,6 @@ async fn bearer_token_is_enforced() {
); );
} }
/// The pure route gate for the plugin lane: exclusion-based, so spot-check both sides — the
/// surface a plugin legitimately uses, and every escalation carve-out.
#[test]
fn plugin_allowlist_excludes_escalation_routes() {
use axum::http::Method;
// The legitimate plugin surface stays open (including mutations — sessions, library, leases).
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status"));
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/library"));
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/clients"));
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/plugins"));
assert!(auth::plugin_may_access(
&Method::PUT,
"/api/v1/plugins/rom-manager"
));
assert!(auth::plugin_may_access(
&Method::DELETE,
"/api/v1/plugins/rom-manager"
));
// Hooks: registration is command execution; even the read can expose webhook credentials.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/hooks"));
assert!(!auth::plugin_may_access(&Method::PUT, "/api/v1/hooks"));
// Pairing administration + PIN visibility.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/pair"));
assert!(!auth::plugin_may_access(&Method::POST, "/api/v1/pair/pin"));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/native/pair"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/native/pair/arm"
));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/native/pending"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/native/pending/1/approve"
));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/clients/aabbcc"
));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/native/clients/aabbcc"
));
// Another plugin's UI proxy secret.
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/plugins/x/ui-credential"
));
}
/// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface,
/// and the same loopback confinement as the admin token.
#[tokio::test]
async fn plugin_token_lane_is_scoped_and_loopback_only() {
use axum::http::Method;
let app = test_app(test_state(), None); // admin "test-secret", plugin "plugin-secret"
let plugin_req = |method: Method, path: &str| {
axum::http::Request::builder()
.method(method)
.uri(path)
.header("authorization", "Bearer plugin-secret")
.body(Body::empty())
.unwrap()
};
// The plugin surface authenticates: status + the plugin directory (list and lease removal).
assert_eq!(
send(&app, plugin_req(Method::GET, "/api/v1/status"))
.await
.0,
StatusCode::OK
);
assert_eq!(
send(&app, plugin_req(Method::GET, "/api/v1/plugins"))
.await
.0,
StatusCode::OK
);
assert_eq!(
send(
&app,
plugin_req(Method::DELETE, "/api/v1/plugins/no-such-plugin")
)
.await
.0,
StatusCode::NO_CONTENT
);
// The carve-outs answer 403 (authenticated but not authorized), not 401.
for (method, path) in [
(Method::GET, "/api/v1/hooks"),
(Method::PUT, "/api/v1/hooks"),
(Method::GET, "/api/v1/pair"),
(Method::POST, "/api/v1/native/pair/arm"),
(Method::GET, "/api/v1/native/pending"),
(Method::DELETE, "/api/v1/clients/aabbcc"),
(Method::GET, "/api/v1/plugins/x/ui-credential"),
] {
let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
assert!(body["error"].as_str().unwrap().contains("plugin token"));
}
// A wrong token never reaches the lane.
let wrong = axum::http::Request::get("/api/v1/status")
.header("authorization", "Bearer plugin-wrong")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, wrong).await.0, StatusCode::UNAUTHORIZED);
// Loopback-only, exactly like the admin token: a LAN peer is refused before token compare.
let mut lan = plugin_req(Method::GET, "/api/v1/status");
lan.extensions_mut()
.insert(PeerAddr("192.168.1.50:40000".parse().unwrap()));
assert_eq!(send(&app, lan).await.0, StatusCode::UNAUTHORIZED);
}
#[tokio::test] #[tokio::test]
async fn host_info_reports_identity_and_ports() { async fn host_info_reports_identity_and_ports() {
let app = test_app(test_state(), None); let app = test_app(test_state(), None);
@@ -674,7 +544,6 @@ async fn blank_token_rejected() {
let opts = Options { let opts = Options {
bind: "127.0.0.1:0".parse().unwrap(), bind: "127.0.0.1:0".parse().unwrap(),
token: Some(" ".into()), token: Some(" ".into()),
plugin_token: None,
}; };
let err = run(test_state(), opts, None, test_stats(), false) let err = run(test_state(), opts, None, test_stats(), false)
.await .await
+29 -53
View File
@@ -1,19 +1,12 @@
//! Management-API bearer token resolution. //! Management-API bearer token resolution.
//! //!
//! The mgmt API always serves HTTPS (the host's identity cert) and now always requires auth — even //! The mgmt API always serves HTTPS (the host's identity cert) and now always requires auth — even
//! on a loopback bind. This module guarantees the tokens always exist: an explicit env var wins //! on a loopback bind. This module guarantees a token always exists: an explicit
//! (operator override, not persisted); otherwise the persisted file under the config dir is used; //! `PUNKTFUNK_MGMT_TOKEN` env wins (operator override, not persisted); otherwise the persisted
//! otherwise a fresh 32-byte hex token is generated and persisted. Files are written in //! `~/.config/punktfunk/mgmt-token` is used; otherwise a fresh 32-byte hex token is generated and
//! `KEY=<hex>` form (0600) so the bundled web console can source them directly as a systemd //! persisted. The file is written in `PUNKTFUNK_MGMT_TOKEN=<hex>` form (0600) so the bundled web
//! `EnvironmentFile` — a single source of truth shared between the host and its consumers. //! console can source it directly as a systemd `EnvironmentFile` — a single source of truth shared
//! //! between the host and the console with no copying.
//! Two tokens, two authorities:
//! - **`mgmt-token`** (`PUNKTFUNK_MGMT_TOKEN`) — the operator/console token; authorizes the full
//! admin surface.
//! - **`plugin-token`** (`PUNKTFUNK_PLUGIN_TOKEN`) — the scripting runner's capability-limited
//! credential (`mgmt::auth::plugin_may_access`): everything a plugin legitimately needs, but not
//! hook registration or pairing administration. The SDK's `connect()` prefers this file, so a
//! defect in an operator plugin can't rewrite `hooks.json` or admit new devices.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use rand::RngCore; use rand::RngCore;
@@ -22,32 +15,19 @@ use std::path::Path;
const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN"; const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN";
const FILE: &str = "mgmt-token"; const FILE: &str = "mgmt-token";
const PLUGIN_ENV_VAR: &str = "PUNKTFUNK_PLUGIN_TOKEN";
const PLUGIN_FILE: &str = "plugin-token";
/// Resolve the mgmt (full-admin) token (env > persisted file > generate+persist). Hex (not base64) /// Resolve the mgmt token (env > persisted file > generate+persist). Hex (not base64) so the
/// so the persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`. /// persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
pub fn load_or_generate() -> Result<String> { pub fn load_or_generate() -> Result<String> {
load_or_generate_impl(ENV_VAR, FILE) if let Ok(v) = std::env::var(ENV_VAR) {
}
/// Resolve the scripting runner's scoped plugin token, same precedence as [`load_or_generate`].
/// Persisted to `plugin-token` next to `mgmt-token`; on Windows `plugins enable` grants the
/// runner's LocalService principal read on exactly this file (and `cert.pem`) — never `mgmt-token`.
pub fn load_or_generate_plugin() -> Result<String> {
load_or_generate_impl(PLUGIN_ENV_VAR, PLUGIN_FILE)
}
fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
if let Ok(v) = std::env::var(env_var) {
let v = v.trim(); let v = v.trim();
if !v.is_empty() { if !v.is_empty() {
return Ok(v.to_string()); return Ok(v.to_string());
} }
} }
let path = pf_paths::config_dir().join(file); let path = pf_paths::config_dir().join(FILE);
if let Ok(contents) = fs::read_to_string(&path) { if let Ok(contents) = fs::read_to_string(&path) {
if let Some(tok) = parse_token(&contents, env_var) { if let Some(tok) = parse_token(&contents) {
return Ok(tok); return Ok(tok);
} }
} }
@@ -57,29 +37,29 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
let dir = pf_paths::config_dir(); let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path. // Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?; pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
write_token(&path, env_var, &token)?; write_token(&path, &token)?;
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)"); tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
Ok(token) Ok(token)
} }
/// Parse the token from the persisted file: accept either a bare token line or a /// Parse the token from the persisted file: accept either a bare token line or a
/// `<KEY>=<token>` line (the form we write, also valid as an EnvironmentFile). /// `PUNKTFUNK_MGMT_TOKEN=<token>` line (the form we write, also valid as an EnvironmentFile).
fn parse_token(contents: &str, env_var: &str) -> Option<String> { fn parse_token(contents: &str) -> Option<String> {
let line = contents.lines().find(|l| !l.trim().is_empty())?.trim(); let line = contents.lines().find(|l| !l.trim().is_empty())?.trim();
let tok = line let tok = line
.strip_prefix(env_var) .strip_prefix("PUNKTFUNK_MGMT_TOKEN=")
.and_then(|rest| rest.strip_prefix('='))
.unwrap_or(line) .unwrap_or(line)
.trim(); .trim();
(!tok.is_empty()).then(|| tok.to_string()) (!tok.is_empty()).then(|| tok.to_string())
} }
/// Write `<KEY>=<token>` to `path` as an owner-only secret — 0600 on Unix AND DACL-locked to /// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path` as an owner-only secret — 0600 on Unix AND
/// SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so both bearer /// DACL-locked to SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so
/// tokens get the SAME Windows lockdown as the host key; the bespoke `cfg(unix)`-only writer used /// the mgmt bearer token (full admin authority) gets the SAME Windows lockdown as the host key; the
/// to leave the mgmt token readable by any local user (security-review 2026-06-28 #2). /// bespoke `cfg(unix)`-only writer used to leave it readable by any local user (security-review
fn write_token(path: &Path, env_var: &str, token: &str) -> Result<()> { /// 2026-06-28 #2).
let line = format!("{env_var}={token}\n"); fn write_token(path: &Path, token: &str) -> Result<()> {
let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n");
pf_paths::write_secret_file(path, line.as_bytes()) pf_paths::write_secret_file(path, line.as_bytes())
.with_context(|| format!("write {}", path.display())) .with_context(|| format!("write {}", path.display()))
} }
@@ -90,17 +70,13 @@ mod tests {
#[test] #[test]
fn parses_bare_and_keyvalue_forms() { fn parses_bare_and_keyvalue_forms() {
assert_eq!(parse_token("abc123\n", ENV_VAR).as_deref(), Some("abc123")); assert_eq!(parse_token("abc123\n").as_deref(), Some("abc123"));
assert_eq!( assert_eq!(
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n", ENV_VAR).as_deref(), parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n").as_deref(),
Some("deadbeef") Some("deadbeef")
); );
assert_eq!( assert_eq!(parse_token("\n \n"), None);
parse_token("PUNKTFUNK_PLUGIN_TOKEN=deadbeef\n", PLUGIN_ENV_VAR).as_deref(), assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n"), None);
Some("deadbeef")
);
assert_eq!(parse_token("\n \n", ENV_VAR), None);
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n", ENV_VAR), None);
} }
#[test] #[test]
@@ -108,9 +84,9 @@ mod tests {
let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id()));
let _ = fs::create_dir_all(&dir); let _ = fs::create_dir_all(&dir);
let path = dir.join(FILE); let path = dir.join(FILE);
write_token(&path, ENV_VAR, "cafef00d").unwrap(); write_token(&path, "cafef00d").unwrap();
let read = fs::read_to_string(&path).unwrap(); let read = fs::read_to_string(&path).unwrap();
assert_eq!(parse_token(&read, ENV_VAR).as_deref(), Some("cafef00d")); assert_eq!(parse_token(&read).as_deref(), Some("cafef00d"));
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
-4
View File
@@ -1265,10 +1265,6 @@ async fn serve_session(
UdpTransport::from_socket_punch( UdpTransport::from_socket_punch(
data_sock, data_sock,
&client_udp.to_string(), &client_udp.to_string(),
// Only honour a punch from the peer QUIC already authenticated: the punch is
// there to discover the NAT-remapped *port*, and `client_udp`'s IP is the
// host-observed QUIC remote (only its port is client-reported).
client_udp.ip(),
std::time::Duration::from_millis(2500), std::time::Duration::from_millis(2500),
) )
}; };
+2 -6
View File
@@ -16,7 +16,7 @@ use punktfunk_core::quic::{ClipControl, ClipOffer, ClipState};
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) async fn run( pub(super) async fn run(
mut ctrl_send: quinn::SendStream, mut ctrl_send: quinn::SendStream,
ctrl_recv: quinn::RecvStream, mut ctrl_recv: quinn::RecvStream,
initial_mode: punktfunk_core::Mode, initial_mode: punktfunk_core::Mode,
codec: crate::encode::Codec, codec: crate::encode::Codec,
live_reconfig_ok: bool, live_reconfig_ok: bool,
@@ -47,13 +47,9 @@ pub(super) async fn run(
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s). // coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
let mut last_accepted_switch: Option<std::time::Instant> = None; let mut last_accepted_switch: Option<std::time::Instant> = None;
// Resumable framing: this read is one arm of a `select!` whose siblings fire on every probe
// result / reconfigure / clip offer, so the read future is dropped routinely. `io::read_msg`
// would lose the partial frame and misalign the stream for the rest of the session.
let mut ctrl_reader = io::MsgReader::new(ctrl_recv);
loop { loop {
tokio::select! { tokio::select! {
msg = ctrl_reader.read_msg() => { msg = io::read_msg(&mut ctrl_recv) => {
let Ok(msg) = msg else { break }; // stream closed let Ok(msg) = msg else { break }; // stream closed
if let Ok(req) = Reconfigure::decode(&msg) { if let Ok(req) = Reconfigure::decode(&msg) {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
@@ -1477,9 +1477,6 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
if let Some(c) = plan.wire_chunk { if let Some(c) = plan.wire_chunk {
new_enc.set_wire_chunking(c); new_enc.set_wire_chunking(c);
} }
// (`max_depth` is computed later in the iteration — read the capturer
// directly so an ABR rebuild re-establishes the bound immediately.)
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
enc = new_enc; enc = new_enc;
bitrate_kbps = new_kbps; bitrate_kbps = new_kbps;
live_bitrate.store(new_kbps, Ordering::Relaxed); live_bitrate.store(new_kbps, Ordering::Relaxed);
@@ -2268,9 +2265,6 @@ fn try_inplace_resize(
if let Some(c) = plan.wire_chunk { if let Some(c) = plan.wire_chunk {
new_enc.set_wire_chunking(c); new_enc.set_wire_chunking(c);
} }
// Re-report the capturer's ring depth: in-place backends bound async pipelining by it, and a
// rebuilt encoder starts with it unset.
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
*enc = new_enc; *enc = new_enc;
*frame = new_frame; *frame = new_frame;
*interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64); *interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
@@ -2585,10 +2579,6 @@ fn build_pipeline(
if let Some(c) = plan.wire_chunk { if let Some(c) = plan.wire_chunk {
enc.set_wire_chunking(c); enc.set_wire_chunking(c);
} }
// Tell in-place backends (Windows direct-NVENC) how deep they may pipeline against the
// capturer's texture ring — without it they use only the env/pool cap and can encode a texture
// the capturer has already rotated and overwritten.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
// Post-open cross-check: the Welcome already committed `chroma_format` from the pre-open probe, so // Post-open cross-check: the Welcome already committed `chroma_format` from the pre-open probe, so
// warn loudly if the encoder actually opened a different chroma than negotiated (the in-band SPS is // warn loudly if the encoder actually opened a different chroma than negotiated (the in-band SPS is
// authoritative for the decoder, but a mismatch means the probe and the live open disagreed). // authoritative for the decoder, but a mismatch means the probe and the live open disagreed).
+9 -230
View File
@@ -14,16 +14,9 @@
//! package present. //! package present.
//! //!
//! Windows needs elevation for both halves: the plugins dir lives under the ACL'd //! Windows needs elevation for both halves: the plugins dir lives under the ACL'd
//! `%ProgramData%\punktfunk` (see `pf_paths::create_private_dir`) and the task is admin-owned. We //! `%ProgramData%\punktfunk` (see `pf_paths::create_private_dir`) and the task runs as SYSTEM. We
//! check up front and print one actionable line instead of letting `bun add` fail with a bare //! check up front and print one actionable line instead of letting `bun add` fail with a bare
//! EACCES. //! EACCES.
//!
//! The task itself runs as **`NT AUTHORITY\LocalService`**, not SYSTEM: plugins are
//! operator-installed code, and a plugin defect must cost a throwaway service account, not the
//! most privileged principal on the box. `enable` converges the principal (migrating tasks an
//! older installer registered as SYSTEM) and grants LocalService read on exactly the two files
//! the runner's `connect()` needs — the scoped `plugin-token` and the TLS pin `cert.pem` — never
//! the full-admin `mgmt-token`.
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::process::Command; use std::process::Command;
@@ -78,15 +71,13 @@ USAGE:
NAMES: NAMES:
A bare first-party name resolves into the @punktfunk scope: `playnite` installs A bare first-party name resolves into the @punktfunk scope: `playnite` installs
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager @punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager.
always from Punktfunk's own package registry. Any other name (`punktfunk-plugin-*`, A scoped (@scope/pkg) or `punktfunk-plugin-*` name is used verbatim.
a foreign @scope) installs from the PUBLIC npm registry and is refused unless you
pass --allow-public-registry.
NOTES: NOTES:
Plugins run under the runner, which is OPT-IN `plugins add` installs, `plugins enable` Plugins run under the runner, which is OPT-IN `plugins add` installs, `plugins enable`
turns the runner on. Plugins are operator-installed code that runs with operator turns the runner on. Plugins are operator-installed code that runs as the host user;
privileges; install only plugins you trust. install only plugins you trust.
" "
); );
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -221,66 +212,13 @@ fn systemctl_output(args: &[&str]) -> Option<String> {
} }
} }
/// `NT AUTHORITY\LocalService` — the runner task's principal — in icacls SID form.
#[cfg(target_os = "windows")]
const LOCAL_SERVICE_SID: &str = "*S-1-5-19";
/// The two (and only two) secrets the runner needs to read to reach the mgmt API: the scoped
/// plugin token and the host identity cert it pins TLS against. `mgmt-token` (full admin) is
/// deliberately NOT here.
#[cfg(target_os = "windows")]
const RUNNER_SECRET_FILES: [&str; 2] = ["plugin-token", "cert.pem"];
/// The unit directories the runner imports code from. LocalService gets an inheritable
/// read+execute+write-attributes grant on these: bun's module loader opens unit files
/// requesting FILE_WRITE_ATTRIBUTES on top of read (plain `(RX)` makes every import die with
/// EPERM — found on-glass), and WA can only touch timestamps/readonly bits, never content —
/// the runner's own integrity check (`windowsSddlUnsafeReason`) treats it as harmless.
#[cfg(target_os = "windows")]
const RUNNER_UNIT_DIRS: [&str; 2] = ["plugins", "scripts"];
/// The runner's writable state root: `<config_dir>\plugin-state`. A plugin persists its config +
/// cache under `plugin-state\<name>` (`@punktfunk/host`'s `pluginStateDir`), so LocalService needs
/// real **Modify** here — unlike the code dirs (RX,WA) and the secrets (R). This keeps the
/// three-way split crisp: code is read-only (a plugin can't rewrite itself), secrets are
/// read-only, only this one dir is writable. Inheritable so per-plugin subdirs the runner creates
/// carry the grant. Users stay read-only (config-dir default), so another non-admin still can't
/// tamper with a plugin's launch templates.
#[cfg(target_os = "windows")]
const RUNNER_STATE_DIRS: [&str; 1] = ["plugin-state"];
/// The plugin **ingest** inbox: `<config_dir>\ingest`. The INVERSE grant of `plugin-state` —
/// `BUILTIN\Users` gets **Modify**, so an app running as the interactive user (e.g. the Playnite
/// exporter, a Playnite extension) can drop data (`ingest\<plugin>\…`) that the de-privileged
/// LocalService runner then READS (LocalService is a member of Users, so it inherits read here).
/// This is the one place a plugin can receive data produced by *another* account — the runner can
/// no longer traverse the interactive user's profile the way the old SYSTEM runner could. Scoped
/// to this one inbox: the rest of the config tree stays Users-read-only, so the widening is a
/// well-defined drop box, not a general write hole. (Accepted tradeoff: any local user can drop a
/// file here — trusted-single-user model, and the runner it feeds is only LocalService.)
#[cfg(target_os = "windows")]
const RUNNER_INGEST_DIRS: [&str; 1] = ["ingest"];
/// `BUILTIN\Users` (S-1-5-32-545) in icacls SID form — the ingest inbox's writer.
#[cfg(target_os = "windows")]
const USERS_SID: &str = "*S-1-5-32-545";
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn enable() -> Result<()> { fn enable() -> Result<()> {
// Converge the task principal BEFORE starting it: the installer registers it as LocalService,
// but a task from an older install (or a hand-registered dev box) still runs as SYSTEM, and
// enabling that unmigrated would hand operator plugins the highest privilege on the box.
// Idempotent; -LogonType ServiceAccount needs no stored password.
powershell(&format!(
"$p = New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; \
Set-ScheduledTask -TaskName {TASK} -Principal $p -ErrorAction Stop | Out-Null"
))?;
grant_runner_secret_reads();
powershell(&format!( powershell(&format!(
"Enable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null; \ "Enable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null; \
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop" Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
))?; ))?;
println!("Plugin runner enabled and started ({TASK}, runs as LocalService)."); println!("Plugin runner enabled and started ({TASK}).");
Ok(()) Ok(())
} }
@@ -290,173 +228,17 @@ fn disable() -> Result<()> {
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \ "Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
Disable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null" Disable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null"
))?; ))?;
revoke_runner_secret_reads();
println!("Plugin runner stopped and disabled ({TASK})."); println!("Plugin runner stopped and disabled ({TASK}).");
Ok(()) Ok(())
} }
/// Grant LocalService **read** on the runner's two secret files. Both are written by the host's
/// `serve` with a SYSTEM/Administrators-only DACL (`pf_paths::write_secret_file`), which the
/// de-privileged runner cannot read — this is the one, narrow widening it needs. `/grant:r`
/// replaces only LocalService's ACE, leaving the lockdown otherwise intact. Files the host hasn't
/// minted yet get an actionable note instead of a failed icacls: the grant re-runs on the next
/// `plugins enable`. NOTE: the host re-locks a secret's DACL whenever it rewrites the file (e.g.
/// a regenerated identity cert) — re-running `plugins enable` restores the grant.
#[cfg(target_os = "windows")]
fn grant_runner_secret_reads() {
let cfg = pf_paths::config_dir();
for name in RUNNER_SECRET_FILES {
let path = cfg.join(name);
if !path.exists() {
println!(
"note: {} does not exist yet (the host writes it on first serve). Start the \
host once, then run `punktfunk-host plugins enable` again so the runner can \
authenticate.",
path.display()
);
continue;
}
let ok = Command::new(icacls_path())
.arg(&path)
.args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(R)")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success());
if !ok {
eprintln!(
"warning: could not grant LocalService read on {} - the plugin runner may fail \
to authenticate to the management API",
path.display()
);
}
}
// The unit dirs: inheritable (RX,WA) so the runner can import what lives there (see
// RUNNER_UNIT_DIRS). Created here if absent — an elevated create inherits the config dir's
// protected DACL, and granting now means files the operator adds later are covered by
// inheritance rather than needing another `plugins enable`.
for name in RUNNER_UNIT_DIRS {
let dir = cfg.join(name);
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("warning: could not create {}: {e}", dir.display());
continue;
}
let ok = Command::new(icacls_path())
.arg(&dir)
.args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(OI)(CI)(RX,WA)")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success());
if !ok {
eprintln!(
"warning: could not grant LocalService read on {} - the runner may fail to \
import plugins/scripts from it",
dir.display()
);
}
}
// The state root: inheritable Modify so plugins can persist config/cache under
// `plugin-state\<name>` (see RUNNER_STATE_DIRS). This is the ONLY writable grant.
for name in RUNNER_STATE_DIRS {
let dir = cfg.join(name);
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("warning: could not create {}: {e}", dir.display());
continue;
}
let ok = Command::new(icacls_path())
.arg(&dir)
.args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(OI)(CI)(M)")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success());
if !ok {
eprintln!(
"warning: could not grant LocalService write on {} - state-writing plugins \
(config/cache) may fail to persist",
dir.display()
);
}
}
// The ingest inbox: inheritable Modify for BUILTIN\Users, so an interactive-user app (the
// Playnite exporter) can drop `ingest\<plugin>\…` for the LocalService runner to read (see
// RUNNER_INGEST_DIRS). The one Users-writable carve-out in the otherwise Users-read-only tree.
for name in RUNNER_INGEST_DIRS {
let dir = cfg.join(name);
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("warning: could not create {}: {e}", dir.display());
continue;
}
let ok = Command::new(icacls_path())
.arg(&dir)
.args(["/grant:r", &format!("{USERS_SID}:(OI)(CI)(M)")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success());
if !ok {
eprintln!(
"warning: could not open the ingest inbox {} for writes - a plugin fed by an \
interactive-user app (e.g. playnite) may see no data",
dir.display()
);
}
}
}
/// Best-effort removal of the LocalService read grants when the runner is switched off — the
/// mirror of [`grant_runner_secret_reads`]; `enable` re-grants.
#[cfg(target_os = "windows")]
fn revoke_runner_secret_reads() {
let cfg = pf_paths::config_dir();
for name in RUNNER_SECRET_FILES
.iter()
.chain(RUNNER_UNIT_DIRS.iter())
.chain(RUNNER_STATE_DIRS.iter())
{
let path = cfg.join(name);
if !path.exists() {
continue;
}
let _ = Command::new(icacls_path())
.arg(&path)
.args(["/remove:g", LOCAL_SERVICE_SID])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
// The ingest inbox was opened to Users, not LocalService — remove that explicit grant (the
// inherited Users:RX from the config dir remains, so it reverts to read-only, not orphaned).
for name in RUNNER_INGEST_DIRS {
let path = cfg.join(name);
if !path.exists() {
continue;
}
let _ = Command::new(icacls_path())
.arg(&path)
.args(["/remove:g", USERS_SID])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
/// Resolve icacls by full System32 path rather than PATH — same planted-binary reasoning as
/// [`powershell_path`]; matches `pf_paths`.
#[cfg(target_os = "windows")]
fn icacls_path() -> String {
std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string())
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn status() -> Result<()> { fn status() -> Result<()> {
let out = powershell_output(&format!( let out = powershell_output(&format!(
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \ "$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
if ($null -eq $t) {{ 'missing' }} else {{ \ if ($null -eq $t) {{ 'missing' }} else {{ \
\"$($t.State)|$($t.Principal.UserId)\" }}" $i = Get-ScheduledTaskInfo -TaskName {TASK} -ErrorAction SilentlyContinue; \
\"$($t.State)\" }}"
)); ));
match out.as_deref().map(str::trim) { match out.as_deref().map(str::trim) {
Some("missing") | None => { Some("missing") | None => {
@@ -466,10 +248,7 @@ fn status() -> Result<()> {
); );
} }
Some(state) => { Some(state) => {
// "State|Principal" — the principal line makes the SYSTEM→LocalService migration println!("runner: {TASK}\nstate: {state}");
// verifiable at a glance (`plugins enable` converges a legacy SYSTEM task).
let (state, principal) = state.split_once('|').unwrap_or((state, "?"));
println!("runner: {TASK}\nstate: {state}\nruns as: {principal}");
if state.eq_ignore_ascii_case("Disabled") { if state.eq_ignore_ascii_case("Disabled") {
println!("\nEnable it with: punktfunk-host plugins enable"); println!("\nEnable it with: punktfunk-host plugins enable");
} }
-7
View File
@@ -157,13 +157,6 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
`POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at `POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at
[`/api/docs`](/api) on your host. [`/api/docs`](/api) on your host.
> A unit under the runner auto-connects with the host's **scoped plugin token**, which covers
> the everyday surface (status, library, sessions, events) but deliberately not **hook
> registration** or **pairing administration** — so a plugin defect can't admit new devices or
> install commands. A script that should administer pairing (like the approval pattern above)
> opts into the full-admin credential explicitly: set `PUNKTFUNK_MGMT_TOKEN` on the unit (e.g.
> a `systemctl --user edit punktfunk-scripting` drop-in) or pass `{ token }` to `connect()`.
## Recipe: full controller passthrough (VirtualHere) ## Recipe: full controller passthrough (VirtualHere)
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
+6 -9
View File
@@ -31,9 +31,8 @@ punktfunk-host plugins enable # turn the runner on (once)
<Tab value="Windows"> <Tab value="Windows">
Run these from an **elevated** PowerShell — right-click **PowerShell** → **Run as administrator**. Run these from an **elevated** PowerShell — right-click **PowerShell** → **Run as administrator**.
The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned. The runner task The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned, and the runner
itself runs as the low-privilege `NT AUTHORITY\LocalService` account — `plugins enable` sets that task runs as SYSTEM.
up (including read access to the runner's scoped API token).
```powershell ```powershell
punktfunk-host plugins add playnite # or: rom-manager punktfunk-host plugins add playnite # or: rom-manager
@@ -63,13 +62,11 @@ The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on.
| `punktfunk-host plugins disable` | Stop + disable the runner. | | `punktfunk-host plugins disable` | Stop + disable the runner. |
| `punktfunk-host plugins status` | Is the runner enabled and running? | | `punktfunk-host plugins status` | Is the runner enabled and running? |
A bare name resolves to the first-party package — `playnite` installs `@punktfunk/plugin-playnite`, A bare name resolves to the first-party package — `playnite` installs `@punktfunk/plugin-playnite`.
always from Punktfunk's own package registry. Any other name (`punktfunk-plugin-*`, a foreign A scoped (`@scope/pkg`) or `punktfunk-plugin-*` name is used verbatim, so third-party plugins work
`@scope/pkg`) would install from the **public npm registry** and is refused unless you add the same way.
`--allow-public-registry` — a guard against typos and look-alike packages pulling untrusted code
onto your host.
> Plugins are operator-installed code with operator privileges — they can launch games and run > Plugins are operator-installed code that runs as the host user — they can launch games and run
> commands. Install only plugins you trust, from a registry you control. > commands. Install only plugins you trust, from a registry you control.
## ROM Manager ## ROM Manager
+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
+3 -7
View File
@@ -291,17 +291,13 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
#endif #endif
#ifdef WithScripting #ifdef WithScripting
; Register the plugin/script runner's scheduled task (boot, restart-on-failure) but leave it ; Register the plugin/script runner's scheduled task (boot, SYSTEM, restart-on-failure) but leave it
; DISABLED - the runner is OPT-IN (inert until you add scripts/plugins). Enable it when ready: ; DISABLED - the runner is OPT-IN (inert until you add scripts/plugins). Enable it when ready:
; punktfunk-host plugins enable ; Enable-ScheduledTask -TaskName PunktfunkScripting
; Principal: NT AUTHORITY\LocalService, NOT SYSTEM - plugins are operator-installed code; a plugin
; defect must cost a throwaway service account, not the box's highest privilege. `plugins enable`
; grants LocalService read on the two secrets the runner needs (plugin-token, cert.pem) and
; converges tasks an older installer registered as SYSTEM.
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces ; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
; in the command, so no Inno {{ }} escaping needed. ; in the command, so no Inno {{ }} escaping needed.
Filename: "powershell.exe"; \ Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
StatusMsg: "Registering the punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated StatusMsg: "Registering the punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
#endif #endif
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the ; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
+2 -17
View File
@@ -10,10 +10,8 @@
# you add scripts or install plugins. Turn it on once you have automation to run: # you add scripts or install plugins. Turn it on once you have automation to run:
# systemctl --user enable --now punktfunk-scripting # systemctl --user enable --now punktfunk-scripting
# #
# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity # Auto-wired like the console: a plugin's connect() reads the host's mgmt token + identity cert from
# cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env # ~/.config/punktfunk/{mgmt-token,cert.pem} (written by the host's `serve`) — no env editing.
# editing. The plugin token authorizes the plugin surface but not hook registration or pairing
# administration; a script that needs the admin surface sets PUNKTFUNK_MGMT_TOKEN explicitly.
[Unit] [Unit]
Description=punktfunk plugin/script runner Description=punktfunk plugin/script runner
Documentation=https://git.unom.io/unom/punktfunk Documentation=https://git.unom.io/unom/punktfunk
@@ -31,19 +29,6 @@ RestartSec=2
KillMode=mixed KillMode=mixed
KillSignal=SIGTERM KillSignal=SIGTERM
TimeoutStopSec=30 TimeoutStopSec=30
# Sandbox: free hardening for well-behaved plugins. The filesystem is read-only outside the home
# directory (ReadWritePaths keeps plugin state, download dirs, and ~/.config/punktfunk writable);
# /tmp is private; no setuid re-escalation; sockets limited to what automation actually uses
# (loopback mgmt API, LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME
# (e.g. a library on another mount) gets a drop-in:
# systemctl --user edit punktfunk-scripting → [Service]\nReadWritePaths=/mnt/games
# NOTE: the mount-namespace options (ProtectSystem/PrivateTmp) need unprivileged user namespaces
# for a *user* unit; on kernels/distros that restrict those, drop them via the same drop-in.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=%h
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+3 -28
View File
@@ -55,40 +55,15 @@ powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1
this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a
fresh install) is what creates the task in the first place. fresh install) is what creates the task in the first place.
## Plugin/script runner
```powershell
powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1
powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1 -EnableTask
```
`bun install && bun build src/runner-cli.ts --target=bun` in `sdk\` -> one self-contained
`runner-cli.js` (effect + the SDK inlined; the operator's plugin `import()` stays a runtime import,
gated on the same `attempt=` check CI and the `.deb` builder use), then lays it out as
`<exe-dir>\scripting\runner-cli.js` + `scripting-run.cmd` with the bun runtime at `<exe-dir>\bun\bun.exe`.
**That layout is load-bearing.** `punktfunk-host plugins add/remove/list` forwards package ops to the
runner, and on Windows it resolves the runner *relative to the running exe* (`crates\punktfunk-host\src\plugins.rs`).
Since `deploy-host.ps1` runs the service out of `target\release`, a bundle sitting only in the
installed `{app}` leaves the freshly built exe reporting *"the plugin runner isn't installed"*. The
script deploys next to **every** host exe it finds - the built one and whatever the `PunktfunkHost`
service actually runs.
The `PunktfunkScripting` task is registered **disabled** (opt-in) by the installer, so the script
stages the bundle but does not silently enable it. Pass `-EnableTask` on a box you are validating
plugins on (equivalent to `punktfunk-host plugins enable`).
## Rebuild + redeploy everything ## Rebuild + redeploy everything
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1 powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1 -EnableScriptingTask
``` ```
Thin wrapper: runs `deploy-host.ps1`, `build-web.ps1` then `build-scripting.ps1` in sequence — the Thin wrapper: runs `deploy-host.ps1` then `build-web.ps1` in sequence. If the host build/start
web console and plugin runner are **always** included, so the host binary and the runner bundle fails, `deploy-host.ps1` rolls itself back and throws, which stops this script before the web
never drift apart. If the host build/start fails, `deploy-host.ps1` rolls itself back and throws, console step runs.
which stops this script before the later steps run.
## Typical flow after pulling new code ## Typical flow after pulling new code
-191
View File
@@ -1,191 +0,0 @@
<#
Rebuild the plugin/script runner bundle from the CURRENT sdk/ source and lay it out next to the
host exe, so `punktfunk-host plugins ...` works and the PunktfunkScripting task runs new code.
powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1
powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1 -EnableTask
WHY the layout matters: the plugins CLI forwards package ops (add/remove/list) to the runner, and
on Windows it resolves the runner RELATIVE TO THE RUNNING EXE - <exe-dir>\bun\bun.exe and
<exe-dir>\scripting\runner-cli.js (crates\punktfunk-host\src\plugins.rs). deploy-host.ps1 runs the
service out of target\release, so a bundle sitting only in the installed {app} leaves the freshly
built exe reporting "the plugin runner isn't installed". We therefore deploy next to EVERY host exe
we can find: the built one, and whatever the PunktfunkHost service actually runs.
The PunktfunkScripting task ships DISABLED (opt-in). We do not silently enable it - pass
-EnableTask on a dev box you are validating plugins on.
#>
param(
[switch]$EnableTask # enable + restart PunktfunkScripting (opt-in)
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path (Split-Path $PSScriptRoot) # scripts\windows -> repo root
$sdk = Join-Path $repo 'sdk'
$task = 'PunktfunkScripting'
# bun is both the bundler AND the runtime the runner ships on. Honor $env:BUN_EXE (what CI sets)
# before falling back to the dev box's portable copy - the same one build-web.ps1 uses.
$bun = $env:BUN_EXE
if (-not ($bun -and (Test-Path $bun))) { $bun = 'C:\Users\Public\bun\bin\bun.exe' }
if (-not (Test-Path $bun)) { throw "bun not found at $bun (set `$env:BUN_EXE to override)" }
Write-Host "== punktfunk plugin/script runner deploy =="
Write-Host "bun : $bun"
# --- 1. build the self-contained bundle ------------------------------------------------------
# --target=bun inlines effect + the SDK into ONE js; the operator's plugin import stays a runtime
# import. --ignore-scripts skips the `prepare` codegen (it wants ../api/openapi.json, not needed).
$stage = Join-Path $repo 'target\scripting'
New-Item -ItemType Directory -Force -Path $stage | Out-Null
$bundle = Join-Path $stage 'runner-cli.js'
Push-Location $sdk
try {
Write-Host "bun install ..."
& $bun install --frozen-lockfile --ignore-scripts
if ($LASTEXITCODE -ne 0) { throw "sdk bun install failed (exit $LASTEXITCODE)" }
Write-Host "bun build src/runner-cli.ts -> $bundle"
& $bun build src/runner-cli.ts --target=bun "--outfile=$bundle"
if ($LASTEXITCODE -ne 0) { throw "runner bundle build failed (exit $LASTEXITCODE)" }
} finally { Pop-Location }
# Same gate CI and the .deb builder use: 'attempt=' only survives when the dynamic plugin import was
# bundled as a RUNTIME import. If it is missing the bundle would load but never run a plugin.
if (-not (Select-String -Path $bundle -Pattern 'attempt=' -Quiet)) {
throw "runner bundle missing the dynamic plugin import - wrong build"
}
Write-Host "bundle : OK ($([math]::Round((Get-Item $bundle).Length / 1KB)) KB)"
# --- 2. work out every host-exe dir that needs the payload ------------------------------------
$targets = New-Object System.Collections.Generic.List[string]
function Add-Target([string]$exe) {
if (-not $exe) { return }
$dir = Split-Path $exe
if ($dir -and (Test-Path $dir) -and -not ($targets -contains $dir)) { $targets.Add($dir) | Out-Null }
}
# a) the binary deploy-host.ps1 just built - what you invoke by hand to test the new CLI.
Add-Target (Join-Path $repo 'target\release\punktfunk-host.exe')
# b) whatever the service actually runs (an installed {app} on a box set up via setup.exe). The
# binPath carries args and may be quoted, so pull the .exe out with a regex.
$qc = & sc.exe qc 'PunktfunkHost' 2>$null
if ($qc) {
$line = $qc | Select-String 'BINARY_PATH_NAME' | Select-Object -First 1
if ($line -and "$line" -match '([A-Za-z]:\\[^"]*?punktfunk-host\.exe)') { Add-Target $Matches[1] }
}
if ($targets.Count -eq 0) { throw "no punktfunk-host.exe found - run deploy-host.ps1 first." }
# --- 3. lay out <exe-dir>\scripting\{runner-cli.js,scripting-run.cmd} + <exe-dir>\bun\bun.exe ---
# Stop a LIVE runner first: it holds bun.exe (and the bundle) open, so copying over them fails with a
# sharing violation. Step 4 restarts it. Reap stragglers by command line the way build-web.ps1 does -
# schtasks /end does not always take the child bun with it.
$existing = Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
if ($existing -and $existing.State -eq 'Running') {
Write-Host "stopping $task (holds bun.exe open) ..."
& schtasks /end /tn $task 2>$null | Out-Null
Get-CimInstance Win32_Process -Filter "Name='bun.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -match 'runner-cli\.js' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2
}
foreach ($dir in $targets) {
Write-Host ""
Write-Host "deploying -> $dir"
$scrDir = Join-Path $dir 'scripting'
$bunDir = Join-Path $dir 'bun'
New-Item -ItemType Directory -Force -Path $scrDir, $bunDir | Out-Null
Copy-Item $bundle (Join-Path $scrDir 'runner-cli.js') -Force
Copy-Item (Join-Path $PSScriptRoot 'scripting-run.cmd') (Join-Path $scrDir 'scripting-run.cmd') -Force
# The runner import()s the operator's .ts plugins, so bun ships WITH it rather than being assumed
# on PATH - exactly what the installer does. Skip the copy when it is already the same build.
$bunDst = Join-Path $bunDir 'bun.exe'
if (-not (Test-Path $bunDst) -or (Get-Item $bunDst).Length -ne (Get-Item $bun).Length) {
Copy-Item $bun $bunDst -Force
}
Write-Host " scripting\runner-cli.js, scripting\scripting-run.cmd, bun\bun.exe"
}
# --- 3.5 de-privilege: LocalService principal + secret read grants ----------------------------
# The runner task runs as NT AUTHORITY\LocalService (NOT SYSTEM - a plugin defect must cost a
# throwaway account). Converge a task registered as SYSTEM by an older installer or by hand, and
# grant LocalService read on the two files the runner's connect() needs: the scoped plugin-token
# and the TLS-pin cert.pem. NEVER mgmt-token (full admin). Mirrors `punktfunk-host plugins enable`.
if ($existing) {
$principal = New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount
Set-ScheduledTask -TaskName $task -Principal $principal | Out-Null
Write-Host ""
Write-Host "task : $task principal -> NT AUTHORITY\LocalService"
$cfg = Join-Path $env:ProgramData 'punktfunk'
foreach ($secret in @('plugin-token', 'cert.pem')) {
$file = Join-Path $cfg $secret
if (Test-Path $file) {
& "$env:SystemRoot\System32\icacls.exe" $file /grant:r '*S-1-5-19:(R)' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $file" }
} else {
Write-Host "note : $file not found - start the host once, then re-run (the runner"
Write-Host " needs LocalService read on it to authenticate)."
}
}
# Unit dirs get inheritable (RX,WA): bun's module loader opens unit files requesting
# FILE_WRITE_ATTRIBUTES on top of read - plain (RX) makes every import EPERM (found
# on-glass). WA can only touch timestamps/attribute bits, never content.
foreach ($unitDir in @('plugins', 'scripts')) {
$dirPath = Join-Path $cfg $unitDir
New-Item -ItemType Directory -Force -Path $dirPath | Out-Null
& "$env:SystemRoot\System32\icacls.exe" $dirPath /grant:r '*S-1-5-19:(OI)(CI)(RX,WA)' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $dirPath" }
}
# State root gets inheritable Modify - the ONE writable grant, so a plugin can persist its
# config/cache under plugin-state\<name> (@punktfunk/host's pluginStateDir). Code dirs stay
# RX+WA, secrets stay R; only this dir is writable.
$stateDir = Join-Path $cfg 'plugin-state'
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
& "$env:SystemRoot\System32\icacls.exe" $stateDir /grant:r '*S-1-5-19:(OI)(CI)(M)' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $stateDir" }
# Ingest inbox gets inheritable Modify for BUILTIN\Users - the INVERSE grant, so an
# interactive-user app (the Playnite exporter) can drop ingest\<plugin>\ data the LocalService
# runner reads. The one Users-writable carve-out in the otherwise Users-read-only tree.
$ingestDir = Join-Path $cfg 'ingest'
New-Item -ItemType Directory -Force -Path $ingestDir | Out-Null
& "$env:SystemRoot\System32\icacls.exe" $ingestDir /grant:r '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $ingestDir" }
}
# --- 4. the opt-in scheduled task -------------------------------------------------------------
# Registered DISABLED by the installer; the runner is inert until there is automation to run. We only
# bounce it when it already exists, and only enable it when explicitly asked. ($existing was captured
# in step 3, BEFORE we stopped a running instance - so State still reflects how we found the box.)
Write-Host ""
if (-not $existing) {
Write-Host "note : the $task task is not registered on this box (installer registers it)."
Write-Host " The plugins CLI still works - it runs the runner directly."
}
elseif ($EnableTask) {
if ($existing.State -eq 'Disabled') {
Enable-ScheduledTask -TaskName $task | Out-Null
Write-Host "task : $task ENABLED (-EnableTask)"
}
& schtasks /end /tn $task 2>$null | Out-Null
Start-Sleep 2
& schtasks /run /tn $task | Out-Null
Write-Host "task : $task restarted on the new bundle"
}
elseif ($existing.State -eq 'Disabled') {
Write-Host "note : $task is DISABLED (opt-in, as shipped) - the new bundle is staged but no"
Write-Host " runner is supervising plugins. Enable it for on-glass validation with:"
Write-Host " powershell -File scripts\windows\build-scripting.ps1 -EnableTask"
Write-Host " (or: punktfunk-host plugins enable)"
}
else {
& schtasks /end /tn $task 2>$null | Out-Null
Start-Sleep 2
& schtasks /run /tn $task | Out-Null
Write-Host "task : $task restarted on the new bundle"
}
Write-Host ""
Write-Host "DONE - plugin/script runner deployed."
+7 -21
View File
@@ -1,40 +1,26 @@
<# <#
Rebuild + redeploy everything: the Windows host service, the web management console AND the Rebuild + redeploy everything: the Windows host service AND the web management console.
plugin/script runner. Thin wrapper around deploy-host.ps1 + build-web.ps1 + build-scripting.ps1 - Thin wrapper around deploy-host.ps1 + build-web.ps1 - see scripts\windows\README.md for what
see scripts\windows\README.md for what each one does on its own (rollback behavior, build env, etc). each one does on its own (rollback behavior, build env, etc).
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1 powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1
powershell -ExecutionPolicy Bypass -File scripts\windows\deploy-all.ps1 -EnableScriptingTask
All three modules are ALWAYS deployed - a host binary whose runner bundle is stale (or missing)
fails `punktfunk-host plugins add` outright, since the CLI forwards package ops to the runner it
finds next to the exe.
Run from an elevated PowerShell. deploy-host.ps1 throws (and rolls itself back) on a failed Run from an elevated PowerShell. deploy-host.ps1 throws (and rolls itself back) on a failed
build/start, which stops this script before the later steps run. build/start, which stops this script before the web console step runs.
#> #>
param(
[switch]$EnableScriptingTask # forwarded to build-scripting.ps1 (opt-in task)
)
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$here = $PSScriptRoot $here = $PSScriptRoot
Write-Host "==========================================" Write-Host "=========================================="
Write-Host " 1/3 host service" Write-Host " 1/2 host service"
Write-Host "==========================================" Write-Host "=========================================="
& (Join-Path $here 'deploy-host.ps1') & (Join-Path $here 'deploy-host.ps1')
Write-Host "" Write-Host ""
Write-Host "==========================================" Write-Host "=========================================="
Write-Host " 2/3 web console" Write-Host " 2/2 web console"
Write-Host "==========================================" Write-Host "=========================================="
& (Join-Path $here 'build-web.ps1') & (Join-Path $here 'build-web.ps1')
Write-Host "" Write-Host ""
Write-Host "==========================================" Write-Host "DONE - host + web console redeployed."
Write-Host " 3/3 plugin/script runner"
Write-Host "=========================================="
& (Join-Path $here 'build-scripting.ps1') -EnableTask:$EnableScriptingTask
Write-Host ""
Write-Host "DONE - host + web console + plugin runner redeployed."
+2 -4
View File
@@ -6,10 +6,8 @@ rem Enable it once you have scripts/plugins: Enable-ScheduledTask -TaskName Pun
rem rem
rem Lays out next to the installed payload: {app}\scripting\scripting-run.cmd + runner-cli.js and rem Lays out next to the installed payload: {app}\scripting\scripting-run.cmd + runner-cli.js and
rem {app}\bun\bun.exe (so %~dp0 = {app}\scripting\). The runner discovers the operator's units under rem {app}\bun\bun.exe (so %~dp0 = {app}\scripting\). The runner discovers the operator's units under
rem %ProgramData%\punktfunk\{scripts,plugins}; a plugin's connect() auto-wires to the host's SCOPED rem %ProgramData%\punktfunk\{scripts,plugins}; a plugin's connect() auto-wires to the host's mgmt
rem plugin-token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). The rem token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). No env editing.
rem task runs as NT AUTHORITY\LocalService - `plugins enable` grants it read on exactly those two
rem files. No env editing.
setlocal EnableExtensions setlocal EnableExtensions
set "BUN=%~dp0..\bun\bun.exe" set "BUN=%~dp0..\bun\bun.exe"
+4 -47
View File
@@ -106,7 +106,7 @@ Plus a real-world recipe:
| What | Source | | What | Source |
|---|---| |---|---|
| URL | `{ url }``PUNKTFUNK_MGMT_URL``https://127.0.0.1:47990` | | URL | `{ url }``PUNKTFUNK_MGMT_URL``https://127.0.0.1:47990` |
| Token | `{ token }``PUNKTFUNK_MGMT_TOKEN` `PUNKTFUNK_PLUGIN_TOKEN``<config_dir>/plugin-token` `<config_dir>/mgmt-token` | | Token | `{ token }``PUNKTFUNK_MGMT_TOKEN``<config_dir>/mgmt-token` |
| TLS pin | `{ ca }``PUNKTFUNK_MGMT_CA` (path) → `<config_dir>/cert.pem` | | TLS pin | `{ ca }``PUNKTFUNK_MGMT_CA` (path) → `<config_dir>/cert.pem` |
`<config_dir>` is `~/.config/punktfunk` (Linux/macOS) or `%ProgramData%\punktfunk` (Windows) — `<config_dir>` is `~/.config/punktfunk` (Linux/macOS) or `%ProgramData%\punktfunk` (Windows) —
@@ -115,13 +115,8 @@ the host's self-signed identity cert (chain-verified; the hostname check is waiv
is deliberately CN-only, native clients pin its fingerprint). Bun and Node are first-class; is deliberately CN-only, native clients pin its fingerprint). Bun and Node are first-class;
other runtimes fall back to system trust (point your runtime's CA option at `cert.pem`). other runtimes fall back to system trust (point your runtime's CA option at `cert.pem`).
The zero-config default is the host's **scoped plugin token** (`plugin-token`): the everyday The bearer token is the host's **admin** credential and is honored from loopback only — run
surface — status, library, sessions, events, the plugin UI lease — but deliberately **not** hook scripts on the host box (or through an SSH tunnel).
registration or pairing administration, so a plugin defect can't install commands or admit
devices. A script that needs the full admin surface opts in explicitly with
`PUNKTFUNK_MGMT_TOKEN` or `{ token }` (`mgmt-token` remains the fallback on hosts that predate
the plugin token). Both tokens are honored from loopback only — run scripts on the host box (or
through an SSH tunnel).
## Events ## Events
@@ -155,43 +150,6 @@ export default definePlugin({
In v1 a plugin is a script you run (see below); the managed runner package is a later step. In v1 a plugin is a script you run (see below); the managed runner package is a later step.
### Persisting state — `pluginStateDir`
A plugin that keeps config or a cache must write it under `pluginStateDir("<your-name>")`, **not**
directly under the config dir:
```ts
import { pluginStateDir } from "@punktfunk/host";
import * as fs from "node:fs";
import * as path from "node:path";
const dir = pluginStateDir("rom-manager"); // <config_dir>/plugin-state/rom-manager
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "cache.json"), data);
```
This matters on Windows: the managed runner is de-privileged (`NT AUTHORITY\LocalService`) and the
config dir is locked read-only, so a write straight under it fails with `EPERM`. `punktfunk-host
plugins enable` grants the runner write on exactly `plugin-state` — the config dir and your plugin's
*code* stay read-only. On Linux the runner owns the whole config dir, so the same path is writable
with no special step.
### Receiving data from an interactive-user app — `pluginIngestDir`
If a plugin needs data produced by a **different account** — e.g. a desktop app running as the
logged-in user, like the Playnite exporter — it can't read it from that user's profile: the
de-privileged Windows runner can't traverse `C:\Users\<you>\…`. `pluginIngestDir("<your-name>")`
resolves an inbox (`<config_dir>/ingest/<name>`) that `plugins enable` makes **user-writable**, so
your app drops a file there and the runner reads it:
```ts
import { pluginIngestDir } from "@punktfunk/host";
const inbox = pluginIngestDir("playnite"); // <config_dir>/ingest/playnite (your app writes here)
```
Treat what you read from it as lower trust than your own state — the inbox is writable by any local
user.
### A plugin UI in the console — `servePluginUi` ### A plugin UI in the console — `servePluginUi`
A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the
@@ -302,8 +260,7 @@ WantedBy=default.target
Windows Task Scheduler: a task triggered *At log on* running Windows Task Scheduler: a task triggered *At log on* running
`bun C:\Users\me\punktfunk-scripts\myscript.ts` (the SDK reads `bun C:\Users\me\punktfunk-scripts\myscript.ts` (the SDK reads
`%ProgramData%\punktfunk\plugin-token` — run the task as an account that can; the managed `%ProgramData%\punktfunk\mgmt-token` — run the task as an account that can).
runner's `plugins enable` grants its LocalService principal exactly that read).
## Compatibility ## Compatibility
+5 -57
View File
@@ -2,17 +2,9 @@
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine // identity cert, from the environment with file fallbacks — so `connect()` on the host machine
// needs zero configuration. // needs zero configuration.
// //
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990) // PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
// PUNKTFUNK_MGMT_TOKEN (admin override), else PUNKTFUNK_PLUGIN_TOKEN, // PUNKTFUNK_MGMT_TOKEN (else <config_dir>/mgmt-token)
// else <config_dir>/plugin-token, else <config_dir>/mgmt-token // PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
//
// Token precedence is deliberate: the host mints a capability-limited `plugin-token` for the
// scripting runner (it cannot register hooks or administer pairing), and that is what a plugin's
// zero-config connect() should hold — the full-admin `mgmt-token` is only a fallback for hosts
// that predate the plugin token (and on Windows the runner's LocalService principal can't read it
// at all). A script that legitimately needs the admin surface sets PUNKTFUNK_MGMT_TOKEN or passes
// { token } explicitly.
// //
// The CA is the host's own identity certificate — trusting exactly it (not the system roots) // The CA is the host's own identity certificate — trusting exactly it (not the system roots)
// IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch, // IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch,
@@ -52,47 +44,6 @@ export const configDir = (): string => {
return path.join(base, "punktfunk"); return path.join(base, "punktfunk");
}; };
/**
* The writable state directory a plugin should persist its config/cache into:
* `<config_dir>/plugin-state[/<name>]`.
*
* WHY this and not `<config_dir>/<name>` directly: on Windows the managed runner is de-privileged
* (runs as `NT AUTHORITY\LocalService`), and the config dir is locked to Users-read so a plugin
* writing straight under it fails with EPERM. `punktfunk-host plugins enable` grants the runner
* **Modify** on exactly `plugin-state` (the config dir and the plugin *code* stay read-only), so
* this is the one place a supervised plugin can write. On Linux the runner is a `systemd --user`
* unit owning the whole config dir, so the path is writable there too same code, no branch.
*
* `name` is a plugin's own kebab-case id; omit it for the shared root. The directory is NOT created
* here (the caller decides permissions/timing) `fs.mkdirSync(pluginStateDir(name), {recursive:
* true})` from the runner inherits the granted ACL on Windows.
*/
export const pluginStateDir = (name?: string): string => {
const root = path.join(configDir(), "plugin-state");
return name ? path.join(root, name) : root;
};
/**
* The ingest inbox a plugin reads data DROPPED BY ANOTHER ACCOUNT from:
* `<config_dir>/ingest[/<name>]`.
*
* The mirror of {@link pluginStateDir}, and the answer to a problem the de-privileging creates on
* Windows: the LocalService runner can no longer traverse the interactive user's profile, so a
* plugin can't read a file an app running as *you* produced (e.g. the Playnite exporter's library
* JSON under your `%APPDATA%`). `punktfunk-host plugins enable` grants `BUILTIN\Users` **write** on
* exactly `ingest` so your app drops `ingest/<plugin>/…` and the runner reads it there. On Linux
* the runner is a `systemd --user` unit owning the config dir, so a same-user producer writes here
* with no special step.
*
* The dir is NOT created here (a producer running as the interactive user creates its own
* `ingest/<name>` subdir under the host-granted `ingest`). Treat anything read from it as
* lower-trust than your own state: the inbox is writable by any local user.
*/
export const pluginIngestDir = (name?: string): string => {
const root = path.join(configDir(), "ingest");
return name ? path.join(root, name) : root;
};
const readIfExists = (p: string): string | undefined => { const readIfExists = (p: string): string | undefined => {
try { try {
return fs.readFileSync(p, "utf8"); return fs.readFileSync(p, "utf8");
@@ -122,14 +73,11 @@ export const resolveConfig = async (
const token = const token =
options?.token ?? options?.token ??
process.env.PUNKTFUNK_MGMT_TOKEN ?? process.env.PUNKTFUNK_MGMT_TOKEN ??
process.env.PUNKTFUNK_PLUGIN_TOKEN ??
parseTokenFile(readIfExists(path.join(configDir(), "plugin-token")) ?? "") ??
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? ""); parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? "");
if (!token) { if (!token) {
throw new Error( throw new Error(
"no management token: set PUNKTFUNK_PLUGIN_TOKEN (or PUNKTFUNK_MGMT_TOKEN), pass " + "no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " +
"{ token }, or run where the host's token files exist " + `the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
`(${path.join(configDir(), "plugin-token")})`,
); );
} }
const caPath = process.env.PUNKTFUNK_MGMT_CA; const caPath = process.env.PUNKTFUNK_MGMT_CA;
-4
View File
@@ -30,10 +30,6 @@ import {
export type { HostApi } from "./api.js"; export type { HostApi } from "./api.js";
export { HttpStatusError } from "./core.js"; export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js"; export type { ConnectOptions } from "./config.js";
// A plugin persists its state here — the one dir the de-privileged Windows runner may write.
export { pluginStateDir } from "./config.js";
// A plugin reads cross-account data (dropped by an interactive-user app) from here.
export { pluginIngestDir } from "./config.js";
export { export {
type PluginUiHandle, type PluginUiHandle,
type PluginUiOptions, type PluginUiOptions,
+13 -62
View File
@@ -13,47 +13,21 @@ export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
/** Where plugin packages install: `<config_dir>/plugins` (matches runner.ts discovery). */ /** Where plugin packages install: `<config_dir>/plugins` (matches runner.ts discovery). */
export const pluginsDirDefault = (): string => path.join(configDir(), "plugins"); export const pluginsDirDefault = (): string => path.join(configDir(), "plugins");
export interface ResolveOptions {
/**
* Allow names that resolve on the PUBLIC npm registry (unscoped `punktfunk-plugin-*`, foreign
* scopes, arbitrary paths). Off by default: only the `@punktfunk` scope pinned to the Gitea
* registry by [`ensureBunfig`] installs without it, so a typo or a squatted look-alike
* package can't silently pull operator-privileged code from npmjs.org (the CLI flag is
* `--allow-public-registry`).
*/
allowPublicRegistry?: boolean;
}
/** /**
* Resolve a friendly plugin name to its npm package. A bare first-party name maps into the * Resolve a friendly plugin name to its npm package. A bare first-party name maps into the
* `@punktfunk` scope (`playnite` `@punktfunk/plugin-playnite`, `rom-manager` * `@punktfunk` scope (`playnite` `@punktfunk/plugin-playnite`, `rom-manager`
* `@punktfunk/plugin-rom-manager`); an `@punktfunk/…` name is used verbatim. Anything else * `@punktfunk/plugin-rom-manager`); a scoped name (`@…/…`), the unscoped plugin convention
* the unscoped `punktfunk-plugin-…` convention, foreign scopes, registry paths resolves on * (`punktfunk-plugin-…`), or any name with a `/` is used verbatim.
* the public registry and is refused unless [`ResolveOptions.allowPublicRegistry`] is set.
*/ */
export const resolvePackage = ( export const resolvePackage = (name: string): string => {
name: string,
opts: ResolveOptions = {},
): string => {
const n = name.trim(); const n = name.trim();
if (!n) throw new Error("empty plugin name"); if (!n) throw new Error("empty plugin name");
if (!n.startsWith("@") && !n.includes("/") && !n.startsWith("punktfunk-plugin-")) { if (n.startsWith("@")) return n; // already scoped, e.g. @punktfunk/plugin-playnite
return `@punktfunk/plugin-${n}`; // bare first-party name if (n.startsWith("punktfunk-plugin-")) return n; // unscoped plugin convention, verbatim
} if (n.includes("/")) return n; // some other registry path — trust it
if (n.startsWith("@punktfunk/")) return n; // first-party scope, pinned to our registry return `@punktfunk/plugin-${n}`; // bare first-party name
if (!opts.allowPublicRegistry) {
throw new Error(
`'${n}' would install from the PUBLIC npm registry, not Punktfunk's. Plugins run ` +
"with operator privileges - install only code you trust. If you mean it, re-run " +
"with --allow-public-registry.",
);
}
return n;
}; };
/** Does this resolved package name install from Punktfunk's own (Gitea) registry? */
const isFirstParty = (pkg: string): boolean => pkg.startsWith("@punktfunk/");
/** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */ /** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */
export const ensurePluginsDir = (dir = pluginsDirDefault()): string => { export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -92,7 +66,7 @@ export const ensureBunfig = (dir = pluginsDirDefault()): void => {
} }
}; };
export interface PkgOpts extends ResolveOptions { export interface PkgOpts {
/** Plugins dir. Default `<config_dir>/plugins`. */ /** Plugins dir. Default `<config_dir>/plugins`. */
dir?: string; dir?: string;
/** Line sink for progress. Default stdout. */ /** Line sink for progress. Default stdout. */
@@ -108,16 +82,7 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`); log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`);
// `process.execPath` is the bun running this file (the vendored one under the package), so a // `process.execPath` is the bun running this file (the vendored one under the package), so a
// system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user. // system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user.
const args = [process.execPath, action, ...pkgs]; const res = Bun.spawnSync([process.execPath, action, ...pkgs], {
// Windows: install file COPIES, never bun's default hardlinks. A hardlinked file's canonical
// path resolves into the installing admin's per-user bun cache
// (C:\Users\<admin>\.bun\install\cache\…), which the de-privileged LocalService runner cannot
// traverse — imports die with EPERM even though the plugins-dir DACL grants read (seen live
// on-glass). copyfile keeps the plugins tree self-contained under %ProgramData%.
if (action === "add" && process.platform === "win32") {
args.push("--backend=copyfile");
}
const res = Bun.spawnSync(args, {
cwd: dir, cwd: dir,
stdio: ["inherit", "inherit", "inherit"], stdio: ["inherit", "inherit", "inherit"],
}); });
@@ -127,26 +92,12 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
}; };
/** Install one or more plugins by friendly name or package. */ /** Install one or more plugins by friendly name or package. */
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => { export const addPlugins = (names: string[], opts: PkgOpts = {}): void =>
const pkgs = names.map((n) => resolvePackage(n, opts)); runBun("add", names.map(resolvePackage), opts);
const log = opts.log ?? ((l: string) => console.log(l));
for (const pkg of pkgs.filter((p) => !isFirstParty(p))) {
log(
`[plugins] WARNING: ${pkg} installs from the public npm registry - it is not ` +
"published by Punktfunk. It will run with operator privileges.",
);
}
runBun("add", pkgs, opts);
};
/** Uninstall one or more plugins by friendly name or package. Removal is always safe a name /** Uninstall one or more plugins by friendly name or package. */
* never gates on the registry it once came from. */
export const removePlugins = (names: string[], opts: PkgOpts = {}): void => export const removePlugins = (names: string[], opts: PkgOpts = {}): void =>
runBun( runBun("remove", names.map(resolvePackage), opts);
"remove",
names.map((n) => resolvePackage(n, { allowPublicRegistry: true })),
opts,
);
export interface InstalledPlugin { export interface InstalledPlugin {
/** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */ /** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */
+2 -9
View File
@@ -8,9 +8,7 @@
// //
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …` // With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
// here): // here):
// add <name…> install first-party plugins (playnite, rom-manager); anything resolving on // add <name…> install first-party (playnite, rom-manager) or punktfunk-plugin-* packages
// the PUBLIC npm registry (punktfunk-plugin-*, foreign scopes) additionally
// needs --allow-public-registry
// remove <name…> uninstall // remove <name…> uninstall
// list list installed plugin packages // list list installed plugin packages
// //
@@ -46,12 +44,7 @@ const positionals = (): string[] => {
return out; return out;
}; };
const pkgOpts = { const pkgOpts = { dir: options.pluginsDir };
dir: options.pluginsDir,
// Opt-in for names that resolve on the public npm registry (supply-chain gate in
// plugins.ts::resolvePackage). Boolean flag, so positionals() skips it on its own.
allowPublicRegistry: process.argv.includes("--allow-public-registry"),
};
const runPkgOp = ( const runPkgOp = (
op: (names: string[], o: typeof pkgOpts) => void, op: (names: string[], o: typeof pkgOpts) => void,
+3 -213
View File
@@ -13,15 +13,13 @@
// background work is invisible to supervision; export a plugin to be supervised). // background work is invisible to supervision; export a plugin to be supervised).
// //
// Trust model (RFC §9.4): a unit is code the operator chose to run — no sandbox is pretended. // Trust model (RFC §9.4): a unit is code the operator chose to run — no sandbox is pretended.
// The same sshd rule as hooks applies on BOTH platforms: a unit file a non-privileged principal // The same sshd rule as hooks applies: a world-writable unit file is refused loudly.
// could have written is refused loudly (mode bits on Unix, owner + DACL on Windows).
import { import {
Cause, Cause,
Duration, Duration,
Effect, Effect,
Schedule, Schedule,
} from "effect"; } from "effect";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
@@ -55,217 +53,9 @@ export interface Unit {
const defaultLog = (line: string) => const defaultLog = (line: string) =>
console.log(`${new Date().toISOString()} ${line}`); console.log(`${new Date().toISOString()} ${line}`);
// ---- unit-file trust (the sshd rule, both halves) --------------------------------------------- /** The sshd rule (RFC §9.1/§9.4): refuse group/world-writable unit files, loudly. */
// SDDL access-mask bits that let a principal change the file's content or its protection:
// write/append data + EAs, DELETE (delete + recreate), WRITE_DAC / WRITE_OWNER (rewrite the
// protection itself), and the generic write/all bits. FILE_WRITE_ATTRIBUTES (0x100) is
// deliberately NOT here: it only toggles timestamps/readonly/hidden — never content — and the
// runner's own service principal legitimately holds it (bun's module loader opens unit files
// requesting RX+WA; `plugins enable` grants exactly that on the plugins/scripts dirs — counting
// WA as tampering would make the runner refuse every unit it is supposed to run).
const SDDL_WRITE_BITS =
0x2 | 0x4 | 0x10 | 0x10000 | 0x40000 | 0x80000 | 0x10000000 | 0x40000000;
// SDDL two-letter rights tokens → access-mask bits (generic, standard, file-specific, and the
// low object-rights aliases hex masks sometimes render as). Anything unrecognized is treated as
// write-capable — fail closed.
const SDDL_RIGHT_TOKENS: Record<string, number> = {
GA: 0x10000000,
GX: 0x20000000,
GW: 0x40000000,
GR: 0x80000000,
RC: 0x20000,
SD: 0x10000,
WD: 0x40000,
WO: 0x80000,
FA: 0x1f01ff,
FR: 0x120089,
FW: 0x120116,
FX: 0x1200a0,
CC: 0x1,
DC: 0x2,
LC: 0x4,
SW: 0x8,
RP: 0x10,
WP: 0x20,
DT: 0x40,
LO: 0x80,
CR: 0x100,
};
// SDDL two-letter account abbreviations we may meet on a unit file, → full SIDs.
const SDDL_SID_ABBREV: Record<string, string> = {
SY: "S-1-5-18", // NT AUTHORITY\SYSTEM
BA: "S-1-5-32-544", // BUILTIN\Administrators
OW: "S-1-3-4", // OWNER RIGHTS
CO: "S-1-3-0", // CREATOR OWNER
LS: "S-1-5-19", // NT AUTHORITY\LOCAL SERVICE
NS: "S-1-5-20", // NT AUTHORITY\NETWORK SERVICE
BU: "S-1-5-32-545", // BUILTIN\Users
AU: "S-1-5-11", // Authenticated Users
IU: "S-1-5-4", // INTERACTIVE
WD: "S-1-1-0", // Everyone
};
const TRUSTED_OWNER_SIDS = new Set([
"S-1-5-18", // SYSTEM
"S-1-5-32-544", // Administrators
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", // TrustedInstaller
]);
/** An SDDL rights field as an access mask; -1 when it can't be fully understood (fail closed). */
const sddlRightsMask = (rights: string): number => {
if (rights.startsWith("0x") || rights.startsWith("0X")) {
const n = Number.parseInt(rights, 16);
return Number.isNaN(n) ? -1 : n;
}
if (rights.length % 2 !== 0) return -1;
let mask = 0;
for (let i = 0; i < rights.length; i += 2) {
const bits = SDDL_RIGHT_TOKENS[rights.slice(i, i + 2)];
if (bits === undefined) return -1;
mask = (mask | bits) >>> 0;
}
return mask;
};
/**
* The Windows half of the sshd rule, as a pure function over the file's SDDL (exported for
* tests). Returns `null` when the descriptor is trustworthy owner is
* SYSTEM/Administrators/TrustedInstaller (or `extraTrustedSid`, the account running the runner:
* the Unix rule's "your own file is fine") and no other principal holds a write-capable allow
* ACE else a human-readable refusal reason. Unknown ACE shapes count as write-capable.
*/
export const windowsSddlUnsafeReason = (
sddl: string,
extraTrustedSid?: string,
): string | null => {
const trusted = new Set(TRUSTED_OWNER_SIDS);
trusted.add("S-1-3-4"); // OWNER RIGHTS — constrained by the owner check below
if (extraTrustedSid) trusted.add(extraTrustedSid);
const owner = /^O:(S-[0-9-]+|[A-Z]{2})/.exec(sddl)?.[1];
const ownerSid =
owner === undefined
? undefined
: owner.startsWith("S-")
? owner
: SDDL_SID_ABBREV[owner];
if (ownerSid === undefined || !trusted.has(ownerSid)) {
return `owner ${owner ?? "unknown"} is not SYSTEM/Administrators/TrustedInstaller`;
}
const daclAt = sddl.indexOf("D:");
if (daclAt < 0) return "no DACL in the security descriptor";
// ACE format: (type;flags;rights;objectGuid;inheritGuid;sid[;condition]). SACL ACEs after
// "S:" match the regex too but are audit types, filtered by the allow-type check.
for (const [, ace] of sddl.slice(daclAt + 2).matchAll(/\(([^)]*)\)/g)) {
const [type, flags = "", rights = "", , , sid = ""] = ace.split(";");
if (type !== "A" && type !== "XA") continue; // deny/audit ACEs only ever tighten
// Inherit-only ACEs are templates for children; they grant nothing on this file. Flags
// come in two-letter tokens — compare exactly, not by substring.
const flagTokens: string[] = flags.match(/.{2}/g) ?? [];
if (flagTokens.includes("IO")) continue;
const mask = sddlRightsMask(rights);
if (mask !== -1 && (mask & SDDL_WRITE_BITS) === 0) continue; // read-only ACE
const resolved = sid.startsWith("S-") ? sid : (SDDL_SID_ABBREV[sid] ?? sid);
if (!trusted.has(resolved)) {
return `${sid} can write it (only SYSTEM/Administrators may)`;
}
}
return null;
};
/** The SID this process runs as, fetched once (`undefined` when it can't be determined). */
let processSidCache: string | undefined | false;
const processSid = (): string | undefined => {
if (processSidCache === undefined) {
const res = spawnSync(
windowsPowershell(),
[
"-NoProfile",
"-NonInteractive",
"-Command",
"[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
],
{
encoding: "utf8",
windowsHide: true,
timeout: 15_000,
env: windowsPowershellEnv(),
},
);
const sid = res.status === 0 ? (res.stdout ?? "").trim() : "";
processSidCache = /^S-[0-9-]+$/.test(sid) ? sid : false;
}
return processSidCache === false ? undefined : processSidCache;
};
// Full System32 path, never PATH — a planted powershell.exe must not run with our privileges
// (mirrors the host CLI's powershell_path, security-review 2026-07-17).
const windowsPowershell = (): string =>
path.join(
process.env.SystemRoot ?? "C:\\Windows",
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
);
// Spawn env for Windows PowerShell 5.1 with PSModulePath STRIPPED (5.1 rebuilds its default).
// An inherited PSModulePath that includes a PowerShell 7 module dir (pwsh adds a machine-scope
// entry) makes 5.1 fail to autoload Microsoft.PowerShell.Security — type-data conflict
// ("AuditToString is already present") — so Get-Acl dies and every unit is refused. Seen live
// on the .173 host box; intermittent per spawn, deterministic once stripped.
const windowsPowershellEnv = (): Record<string, string | undefined> => {
const env: Record<string, string | undefined> = { ...process.env };
delete env.PSModulePath;
return env;
};
/** Read a file's SDDL and apply [`windowsSddlUnsafeReason`]. Unreadable ACL ⇒ refuse. */
const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => {
const escaped = file.replace(/'/g, "''");
const res = spawnSync(
windowsPowershell(),
[
"-NoProfile",
"-NonInteractive",
"-Command",
`(Get-Acl -LiteralPath '${escaped}').Sddl`,
],
{
encoding: "utf8",
windowsHide: true,
timeout: 15_000,
env: windowsPowershellEnv(),
},
);
const sddl = res.status === 0 ? (res.stdout ?? "").trim() : "";
if (!sddl) {
log(`[runner] REFUSING ${file} — could not read its ACL`);
return false;
}
const reason = windowsSddlUnsafeReason(sddl, processSid());
if (reason !== null) {
log(
`[runner] REFUSING ${file}${reason}. Reinstall the plugin with ` +
`\`punktfunk-host plugins add\`, or re-own the file to Administrators and strip ` +
`non-admin write ACEs (icacls).`,
);
return false;
}
return true;
};
/**
* The sshd rule (RFC §9.1/§9.4): refuse a unit file anyone less privileged than the operator
* could have written group/world-writable mode on Unix; on Windows, an owner outside
* SYSTEM/Administrators/TrustedInstaller or a write-capable ACE for a non-admin principal.
*/
const fileIsSafe = (file: string, log: (l: string) => void): boolean => { const fileIsSafe = (file: string, log: (l: string) => void): boolean => {
if (process.platform === "win32") return windowsFileIsSafe(file, log); if (process.platform === "win32") return true; // config dir is DACL'd; ACL check is a follow-up
try { try {
const mode = fs.statSync(file).mode & 0o022; const mode = fs.statSync(file).mode & 0o022;
if (mode !== 0) { if (mode !== 0) {
-50
View File
@@ -1,50 +0,0 @@
// Connection/config resolution helpers. `pluginStateDir` is the writable location a supervised
// plugin persists into — the one dir the de-privileged Windows runner may write.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as path from "node:path";
import { pluginIngestDir, pluginStateDir } from "../src/config.js";
describe("pluginStateDir", () => {
let saved: string | undefined;
beforeEach(() => {
saved = process.env.PUNKTFUNK_CONFIG_DIR;
});
afterEach(() => {
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
});
test("resolves <config_dir>/plugin-state[/name] and honors the config-dir override", () => {
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg");
expect(pluginStateDir()).toBe(path.join("/tmp", "pf-cfg", "plugin-state"));
expect(pluginStateDir("rom-manager")).toBe(
path.join("/tmp", "pf-cfg", "plugin-state", "rom-manager"),
);
});
test("the per-plugin dir is nested under the shared root", () => {
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg2");
expect(pluginStateDir("x").startsWith(pluginStateDir())).toBe(true);
});
});
describe("pluginIngestDir", () => {
let saved: string | undefined;
beforeEach(() => {
saved = process.env.PUNKTFUNK_CONFIG_DIR;
});
afterEach(() => {
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
});
test("resolves <config_dir>/ingest[/name], distinct from plugin-state", () => {
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg3");
expect(pluginIngestDir()).toBe(path.join("/tmp", "pf-cfg3", "ingest"));
expect(pluginIngestDir("playnite")).toBe(
path.join("/tmp", "pf-cfg3", "ingest", "playnite"),
);
// the inbox (Users-write) is a different tree from state (LocalService-write)
expect(pluginIngestDir("playnite")).not.toBe(pluginStateDir("playnite"));
});
});
+3 -19
View File
@@ -37,28 +37,12 @@ describe("resolvePackage", () => {
expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager"); expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager");
}); });
test("passes @punktfunk-scoped names through verbatim (our registry, no gate)", () => { test("passes through scoped, unscoped-convention, and pathed names verbatim", () => {
expect(resolvePackage("@punktfunk/plugin-playnite")).toBe( expect(resolvePackage("@punktfunk/plugin-playnite")).toBe(
"@punktfunk/plugin-playnite", "@punktfunk/plugin-playnite",
); );
}); expect(resolvePackage("@someone/plugin-x")).toBe("@someone/plugin-x");
expect(resolvePackage("punktfunk-plugin-custom")).toBe("punktfunk-plugin-custom");
test("refuses public-registry names without allowPublicRegistry", () => {
expect(() => resolvePackage("punktfunk-plugin-custom")).toThrow(
/public/i,
);
expect(() => resolvePackage("@someone/plugin-x")).toThrow(/public/i);
expect(() => resolvePackage("some/registry-path")).toThrow(/public/i);
});
test("passes public-registry names through with allowPublicRegistry", () => {
const allow = { allowPublicRegistry: true };
expect(resolvePackage("punktfunk-plugin-custom", allow)).toBe(
"punktfunk-plugin-custom",
);
expect(resolvePackage("@someone/plugin-x", allow)).toBe(
"@someone/plugin-x",
);
}); });
test("trims and rejects empty", () => { test("trims and rejects empty", () => {
+1 -86
View File
@@ -5,12 +5,7 @@ import { afterAll, describe, expect, test } from "bun:test";
import { Effect, Fiber } from "effect"; import { Effect, Fiber } from "effect";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { import { discoverUnits, runner, superviseUnit } from "../src/runner.js";
discoverUnits,
runner,
superviseUnit,
windowsSddlUnsafeReason,
} from "../src/runner.js";
const TOKEN = "runner-token"; const TOKEN = "runner-token";
// Fixtures live under sdk/ so the generated plugin files can resolve "effect" and the SDK. // Fixtures live under sdk/ so the generated plugin files can resolve "effect" and the SDK.
@@ -109,86 +104,6 @@ describe("discovery", () => {
}); });
}); });
describe("windowsSddlUnsafeReason (the sshd rule's Windows half, pure)", () => {
// What a file under the host's ACL'd %ProgramData%\punktfunk actually looks like: owned by
// Administrators, protected DACL, admin/SYSTEM/OWNER-RIGHTS full + Users read-execute.
const LOCKED =
"O:BAG:SYD:PAI(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)(A;;0x1200a9;;;BU)";
test("accepts the host's locked-down layout", () => {
expect(windowsSddlUnsafeReason(LOCKED)).toBeNull();
});
test("accepts inherited allow ACEs spelled with token runs", () => {
expect(
windowsSddlUnsafeReason("O:SYG:SYD:(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;FR;;;BU)"),
).toBeNull();
});
test("refuses an untrusted owner", () => {
expect(
windowsSddlUnsafeReason("O:BUG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)"),
).toContain("owner");
expect(
windowsSddlUnsafeReason(
"O:S-1-5-21-1111111111-2222222222-3333333333-1001G:SYD:(A;;FA;;;SY)",
),
).toContain("owner");
});
test("accepts the running account as owner and writer (the Unix 'your own file' rule)", () => {
const me = "S-1-5-21-1111111111-2222222222-3333333333-1001";
const sddl = `O:${me}G:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;${me})`;
expect(windowsSddlUnsafeReason(sddl)).toContain("owner");
expect(windowsSddlUnsafeReason(sddl, me)).toBeNull();
});
test("refuses write-capable ACEs for non-admin principals, by token and by hex", () => {
// BUILTIN\Users with modify (hex, as Windows renders 0x1301bf)
expect(
windowsSddlUnsafeReason(`${LOCKED}(A;;0x1301bf;;;BU)`),
).toContain("BU");
// Everyone with generic write
expect(windowsSddlUnsafeReason(`${LOCKED}(A;;GW;;;WD)`)).toContain("WD");
// Authenticated Users with file-write
expect(windowsSddlUnsafeReason(`${LOCKED}(A;;FW;;;AU)`)).toContain("AU");
// DELETE alone is enough (delete + recreate)
expect(windowsSddlUnsafeReason(`${LOCKED}(A;;SD;;;BU)`)).toContain("BU");
// WRITE_DAC alone is enough (rewrite the protection)
expect(windowsSddlUnsafeReason(`${LOCKED}(A;;WD;;;BU)`)).toContain("BU");
// an explicit user SID
expect(
windowsSddlUnsafeReason(
`${LOCKED}(A;;FA;;;S-1-5-21-1111111111-2222222222-3333333333-1001)`,
),
).toContain("S-1-5-21");
});
test("accepts the runner principal's RX+WA grant, refuses real writes for it", () => {
// `plugins enable` grants LocalService (RX,WA) on the unit dirs — bun's module loader
// opens files requesting FILE_WRITE_ATTRIBUTES on top of read+execute (0x1201a9).
// WA can't alter content, so it must not read as tamper-capable…
expect(
windowsSddlUnsafeReason(`${LOCKED}(A;OICIID;0x1201a9;;;LS)`),
).toBeNull();
// …but actual write-data for the service principal is still refused.
expect(windowsSddlUnsafeReason(`${LOCKED}(A;;FW;;;LS)`)).toContain("LS");
});
test("inherit-only ACEs and deny ACEs don't trip it; unknown shapes fail closed", () => {
// inherit-only: a template for children, grants nothing on this file
expect(
windowsSddlUnsafeReason(`${LOCKED}(A;OICIIO;FA;;;BU)`),
).toBeNull();
// deny ACEs only tighten
expect(windowsSddlUnsafeReason(`${LOCKED}(D;;FA;;;BU)`)).toBeNull();
// unknown rights token → treated as write-capable
expect(windowsSddlUnsafeReason(`${LOCKED}(A;;ZZ;;;BU)`)).toContain("BU");
// no DACL at all → refused
expect(windowsSddlUnsafeReason("O:BAG:SY")).toContain("DACL");
});
});
describe("supervision", () => { describe("supervision", () => {
test("async-fn plugin runs with a facade client; clean return completes", async () => { test("async-fn plugin runs with a facade client; clean return completes", async () => {
const server = mockHost(); const server = mockHost();