Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cde80ce84 | |||
| ab679a56e7 |
Generated
+27
-27
@@ -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
@@ -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
@@ -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 (receipt→pull, 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 vend→glass pipeline depth — an OS property no client can pace under (~2 refresh
|
/// engine's vend→glass 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
|
||||||
// receipt→pull 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 receipt→pull 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 receipt→pull 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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -447,16 +447,8 @@ fn pump(
|
|||||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
// every ~8–16 ms at 60–120 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;
|
||||||
|
|||||||
@@ -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<()>;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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). `©` is a live
|
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `©` 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(©, "cuMemcpy2DAsync_v2");
|
let res = copy_blocking(©, "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(©, "cuMemcpy2DAsync_v2(plane)");
|
let res = copy_blocking(©, "cuMemcpy2DAsync_v2(plane)");
|
||||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")?;
|
||||||
|
|||||||
@@ -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 { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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`].
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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`. */
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user