Compare commits

..

1 Commits

Author SHA1 Message Date
enricobuehler 491a344f23 fix(client/windows): repair the v0.15.0 MSIX build — ARM64 feature leak + illegal XML comment
release / apple (push) Successful in 10m26s
android-screenshots / screenshots (push) Successful in 3m5s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
deb / build-publish (push) Successful in 12m14s
deb / build-publish-host (push) Successful in 12m25s
android / android (push) Successful in 13m56s
arch / build-publish (push) Successful in 13m30s
docker / deploy-docs (push) Successful in 17s
web-screenshots / screenshots (push) Successful in 4m11s
linux-client-screenshots / screenshots (push) Successful in 7m6s
flatpak / build-publish (push) Successful in 6m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m3s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m9s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m53s
windows-host / package (push) Failing after 14m35s
Both MSIX matrix jobs failed at the v0.15.0 tag, for two unrelated reasons, both
introduced by this release. 0.13.0 and 0.14.0 shipped both packages; 0.15.0 shipped
neither.

ARM64 failed to BUILD. The PyroWave Windows un-gating (1ef0229b) moved `pyrowave-sys`
in pf-client-core out of `cfg(target_os = "linux")` into a general optional dependency
that pf-client-core's own `default = ["pyrowave"]` turns on. The ARM64 leg builds
`--no-default-features` precisely to skip it, but that only disables defaults for the
two packages named with `-p` — every internal edge kept inheriting them:
clients/windows -> pf-client-core, clients/session -> pf-client-core, clients/session
-> pf-presenter, and pf-presenter's own `default = ["pyrowave"]` -> pf-client-core.
So the vendored Granite C++ compiled on ARM64 and stopped where it always does:
muglm.cpp reaching for _MM_TRANSPOSE4_PS/_mm_storeu_ps, then
`simd.hpp:103 #error "Implement me."`.

Those three internal edges now pass `default-features = false`; the feature is enabled
only through an explicit chain (clients/session's `pyrowave` -> pf-client-core/pyrowave
+ pf-presenter/pyrowave, and pf-presenter's `pyrowave` -> pf-client-core/pyrowave), so
`--no-default-features` finally means what it says. Verified by feature resolution
rather than assertion — `cargo tree -i pyrowave-sys`:

  aarch64-pc-windows-msvc, --no-default-features -> not in the graph (was: present)
  x86_64-pc-windows-msvc,  default features      -> present
  x86_64-unknown-linux-gnu, default features     -> present

PyroWave is NOT dropped from the Windows client. Decode has always run in the spawned
punktfunk-session binary, whose own default keeps the feature on, and unification turns
it back on for the shared pf-client-core wherever that binary is in the build (x64,
Linux). The shell only ever offered "pyrowave" as a codec preference string — it holds
no decoder and never called one, so linking the C++ into it was dead weight even on x64.

x64 built fine and failed at PACKAGING: `makepri new` rejected the manifest with
PRI191 "Appx manifest not found or is invalid" / malformed comment syntax. The console
tile added in 2508b720 documented its flag inside an XML comment, and XML forbids `--`
anywhere in a comment body, so the literal flag spelling made the whole manifest
unparseable. Reworded, with a note to keep the next person from reintroducing it.
Confirmed by parsing both revisions after template substitution: the tagged manifest
fails at line 63 col 9, this one parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:18:57 +02:00
73 changed files with 543 additions and 3412 deletions
Generated
+27 -27
View File
@@ -2159,7 +2159,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.16.0"
version = "0.15.0"
[[package]]
name = "lazy_static"
@@ -2264,7 +2264,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"bindgen",
"cmake",
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"punktfunk-core",
]
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ash",
@@ -2832,7 +2832,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2850,7 +2850,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ash",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ash",
@@ -2894,7 +2894,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"ash",
"bindgen",
@@ -2903,7 +2903,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"libc",
@@ -2915,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2929,11 +2929,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.16.0"
version = "0.15.0"
[[package]]
name = "pf-inject"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2961,14 +2961,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ash",
@@ -2983,7 +2983,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3013,7 +3013,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3025,7 +3025,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ash",
@@ -3221,7 +3221,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"android_logger",
"jni",
@@ -3237,7 +3237,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3268,7 +3268,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3287,7 +3287,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3318,7 +3318,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3400,7 +3400,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3414,7 +3414,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"anyhow",
"ksni",
@@ -3437,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -48,7 +48,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.16.0"
version = "0.15.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.16.0"
"version": "0.15.0"
},
"paths": {
"/api/v1/clients": {
@@ -404,14 +404,7 @@ fn feeder_loop(
// 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.
if stats.enabled() || measure_decode {
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
// 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 received_ns = now_realtime_ns();
{
let mut g = in_flight
.lock()
@@ -221,13 +221,7 @@ pub(super) fn run_sync(
// 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.
if stats.enabled() || measure_decode {
// Core reassembly-completion stamp (ABI v9), not the pull instant — see
// 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()
};
let received_ns = now_realtime_ns();
in_flight.push_back((frame.pts_ns / 1000, received_ns));
if in_flight.len() > IN_FLIGHT_CAP {
in_flight.pop_front(); // stale — codec never echoed it back
@@ -579,21 +579,13 @@ struct ContentView: View {
model?.disconnect() // the captured-state D combo
},
onFrame: { [meter = model.meter, latency = model.latency,
split = model.latencySplit, queue = model.clientQueue,
offset = conn.clockOffsetNs] au in
split = model.latencySplit, offset = conn.clockOffsetNs] au in
meter.note(byteCount: au.data.count)
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
// host/network split drained by the 1 s stats tick). receivedNs is
// the core's reassembly stamp (ABI v9), so the split's network term no
// longer contains the client-queue wait...
// host/network split drained by the 1 s stats tick).
split.recordReceipt(
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
// ...which is measured as its own term instead (receiptpull, both
// client-local).
queue.record(
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
offsetNs: 0)
},
onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() }
@@ -102,12 +102,6 @@ final class SessionModel: ObservableObject {
@Published var decodeValid = false
@Published var displayP50Ms = 0.0
@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
/// engine's vendglass pipeline depth an OS property no client can pace under (~2 refresh
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
@@ -153,9 +147,6 @@ final class SessionModel: ObservableObject {
let endToEnd = LatencyMeter()
let decodeStage = 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
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
let presentFloor = LatencyMeter()
@@ -498,7 +489,6 @@ final class SessionModel: ObservableObject {
endToEndValid = false
decodeValid = false
displayValid = false
clientQueueValid = false
osFloorValid = false
lostFrames = 0
lostPct = 0
@@ -689,12 +679,6 @@ final class SessionModel: ObservableObject {
} else {
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,
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
// `presents` counts frames that reached glass (the display meter's sample count)
@@ -707,7 +691,7 @@ final class SessionModel: ObservableObject {
let line = String(
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "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,
displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1,
@@ -718,8 +702,7 @@ final class SessionModel: ObservableObject {
lost,
self.osFloorValid ? self.osFloorP50Ms : -1,
self.displayValid ? self.displayAdjP50Ms : -1,
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
self.clientQueueValid ? self.clientQueueP50Ms : -1)
self.endToEndValid ? self.endToEndAdjP50Ms : -1)
statsLog.info("\(line, privacy: .public)")
}
}
@@ -118,16 +118,6 @@ struct StreamHUDView: View {
.font(.system(.caption2, design: .monospaced))
.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 {
// 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 frameIndex: UInt32
public let flags: UInt32
/// Client `CLOCK_REALTIME` instant the AU finished reassembly in the core (post-FEC,
/// decrypted `PunktfunkFrame.received_ns`, ABI v9) the **received** measurement point of
/// design/stats-unification.md. NOT the pull instant: stamping at the pull folded the
/// 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).
/// Client `CLOCK_REALTIME` instant the AU was handed over by the core (post-FEC, decrypted)
/// the **received** measurement point of design/stats-unification.md. The decode stage is
/// `decodedNs - receivedNs`, both client-local (no skew offset applies).
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
@@ -683,16 +662,11 @@ public final class PunktfunkConnection {
let data = Data(bytes: base, count: Int(frame.len)) // copy: ptr valid only until next call
var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts)
let pulledNs = 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
let receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
return AccessUnit(
data: data, ptsNs: frame.pts_ns,
frameIndex: frame.frame_index, flags: frame.flags,
receivedNs: receivedNs, pulledNs: pulledNs)
receivedNs: receivedNs)
case statusNoFrame:
return nil
case statusClosed:
@@ -1213,9 +1213,7 @@ public final class Stage2Pipeline {
let chunkAligned =
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
let ptsNs = au.ptsNs
// Decode stage starts at the PULL (matching the VT path's FrameContext
// receiptpull is the HUD's separate client-queue term, ABI v9 split).
let receivedNs = au.pulledNs
let receivedNs = au.receivedNs
let flags = au.flags
let submitted = decoder.decode(
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
@@ -32,12 +32,9 @@ public enum ReadyImage: @unchecked Sendable {
public struct ReadyFrame: @unchecked Sendable {
/// Host capture clock (the AU's pts), in nanoseconds.
public let ptsNs: UInt64
/// Client `CLOCK_REALTIME` instant the AU left `nextAU` (`AccessUnit.pulledNs`, threaded
/// through the decode via the frame refcon), in nanoseconds the decode stage's start
/// point. (Named for its historical role; since the ABI v9 receipt split the true
/// reassembly receipt lives on `AccessUnit.receivedNs`, and receiptpull is the HUD's own
/// client-queue term.) 0 when unknown (a caller that didn't stamp) the decode-stage meter
/// then drops the sample via its sanity guard.
/// Client `CLOCK_REALTIME` instant the AU was received (`AccessUnit.receivedNs`, threaded
/// through the decode via the frame refcon), in nanoseconds. 0 when unknown (a caller that
/// didn't stamp receipt) the decode-stage meter then drops the sample via its sanity guard.
public let receivedNs: Int64
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
public let decodedNs: Int64
@@ -170,11 +167,7 @@ public final class VideoDecoder: @unchecked Sendable {
var infoOut = VTDecodeInfoFlags()
// 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.
// The decode stage starts at the PULL (the AU leaving nextAU), not the reassembly
// receipt: both consumers the decode-stage meter and the ABR decode signal are
// specified from the pull, and the receiptpull wait is the HUD's separate client-queue
// term (see AccessUnit.pulledNs).
let ctx = FrameContext(receivedNs: au.pulledNs, flags: au.flags)
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
let refcon = Unmanaged.passRetained(ctx).toOpaque()
let status = VTDecompressionSessionDecodeFrame(
session,
+61 -88
View File
@@ -1310,26 +1310,21 @@ mod pipewire {
/// (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) {
// 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_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
// are ALL producer-written, and without a bound against the actual region they drive
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
// catch). Every offset below is validated against `region_size` with checked arithmetic,
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
let meta =
unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor as u32) };
if meta.is_null() {
// `spa_buffer_find_meta_data` scans its metadata array for a `SPA_META_Cursor` of at least
// `size_of::<spa_meta_cursor>()` bytes and returns a pointer into that buffer's metadata
// (or null), valid until requeue. The size argument matches the struct the result is cast to.
let cur = unsafe {
spa::sys::spa_buffer_find_meta_data(
spa_buf,
spa::sys::SPA_META_Cursor,
std::mem::size_of::<spa::sys::spa_meta_cursor>(),
) as *const spa::sys::spa_meta_cursor
};
if cur.is_null() {
return;
}
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
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.
// SAFETY: `cur` is non-null and points to a `spa_meta_cursor` of at least its own size
// inside the held buffer (guaranteed by the size arg above), so every field read is in bounds.
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
(
(*cur).id,
@@ -1352,18 +1347,13 @@ mod pipewire {
// Position-only update — keep the cached bitmap.
return;
}
let bmp_off = bmp_off as usize;
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
Some(end) if end <= region_size => {}
_ => return,
}
// 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.
// SAFETY: `bitmap_offset` is a byte offset from `cur` to a `spa_meta_bitmap`, which the
// producer placed inside the same meta region it sized for this cursor (>= the size we
// requested). The resulting pointer is in bounds and aligned for `spa_meta_bitmap`.
let bmp =
unsafe { (cur as *const u8).add(bmp_off as usize) as *const spa::sys::spa_meta_bitmap };
// 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.
let (vfmt, bw, bh, stride, pix_off) = unsafe {
(
(*bmp).format,
@@ -1379,27 +1369,10 @@ mod pipewire {
}
let row = bw as usize * 4;
let stride = if stride < row { row } else { stride };
// `span` is the exact byte extent the strided loop reads: `stride·(bh-1) + row`. Compute it
// with checked arithmetic (a producer stride near `i32::MAX` would otherwise overflow) and
// require the whole pixel block `[bmp_off + pix_off, +span)` to lie inside the region before
// fabricating the slice — this is the check whose absence made the read go out of bounds.
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 span = stride * (bh as usize - 1) + row;
// SAFETY: the bitmap pixels live at `bmp + pix_off` for `span` bytes, within the
// producer-sized meta region. `span` is the exact extent the strided copy below reads.
let src = unsafe { std::slice::from_raw_parts((bmp as *const u8).add(pix_off), span) };
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
for y in 0..bh as usize {
for x in 0..bw as usize {
@@ -2191,37 +2164,36 @@ mod pipewire {
}
})
.process(|stream, ud| {
// 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. 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.
// PipeWire dispatches this from a C trampoline with no catch_unwind; a
// panic crossing that FFI boundary would abort the whole host. Contain it.
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);
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
// load through a valid pointer — no mutation or aliasing.
@@ -2300,18 +2272,19 @@ mod pipewire {
"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;
}
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() {
// 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
@@ -1341,12 +1341,6 @@ impl IddPushCapturer {
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.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_last = None;
self.out_idx = 0;
@@ -1867,7 +1861,6 @@ impl IddPushCapturer {
cbcr,
fence_handle,
fence_value,
ring_gen: self.generation,
}),
)
} else {
@@ -1926,7 +1919,6 @@ impl IddPushCapturer {
cbcr: dst_cbcr,
fence_handle,
fence_value,
ring_gen: self.generation,
}),
}),
cursor: None,
+2 -10
View File
@@ -447,16 +447,8 @@ fn pump(
// every ~816 ms at 60120 Hz anyway, so this rarely times out mid-stream).
match connector.next_frame(Duration::from_millis(20)) {
Ok(frame) => {
// The `received` point: reassembly COMPLETION, stamped by the core session as
// the AU crossed poll_frame (ABI v9). Stamping here at the hand-off pull instead
// 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()
};
// The `received` point: AU fully reassembled, in hand, before decode.
let received_ns = now_ns();
// fps / goodput count every received AU (spec), decoded or not.
frames_n += 1;
bytes_n += frame.data.len() as u64;
-10
View File
@@ -293,16 +293,6 @@ pub trait Encoder: Send {
/// flagged [`EncodedFrame::chunk_aligned`] and the session marks them on the wire.
/// Default: no-op (the H.26x backends' bitstreams cannot be cut losslessly).
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)
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
fn flush(&mut self) -> Result<()>;
+1 -18
View File
@@ -1176,24 +1176,7 @@ impl Encoder for PyroWaveEncoder {
fn submit(&mut self, frame: &CapturedFrame) -> Result<()> {
// SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this
// struct owns and waits its own fence before touching results.
let r = unsafe { self.encode_frame(frame) };
if r.is_err() {
// `encode_frame` opens the recording window early and has several fallible steps
// inside it (cursor prep, dmabuf import, format mapping, the CPU-RGB staging path,
// an unsupported-payload bail, and the encode call itself). Every one returns with
// `self.cmd` still RECORDING, and nothing downstream repairs it — there is exactly
// one `begin_command_buffer` in this file and `reset()`/`Drop` never touch `cmd` —
// so the NEXT frame would call `begin` on a recording buffer, which is invalid usage.
// Legal here on every path: the pool carries RESET_COMMAND_BUFFER and the buffer is
// not pending (we never reached the submit, or the submit itself failed).
// SAFETY: `self.cmd` is owned by this encoder and, on these paths, not in flight.
unsafe {
let _ = self
.device
.reset_command_buffer(self.cmd, vk::CommandBufferResetFlags::empty());
}
}
r
unsafe { self.encode_frame(frame) }
}
fn caps(&self) -> EncoderCaps {
+5 -22
View File
@@ -183,8 +183,7 @@ pub(crate) unsafe fn make_plain_image(
None,
)?;
let req = device.get_image_memory_requirements(img);
// Unwind on failure: callers (the encoders' open paths) only ever see the completed triple.
let mem = match device.allocate_memory(
let mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(find_mem(
@@ -193,24 +192,8 @@ pub(crate) unsafe fn make_plain_image(
vk::MemoryPropertyFlags::DEVICE_LOCAL,
)),
None,
) {
Ok(m) => m,
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());
}
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)
}
}
)?;
device.bind_image_memory(img, mem, 0)?;
let view = make_view(device, img, fmt, 0)?;
Ok((img, mem, view))
}
+144 -419
View File
@@ -37,12 +37,6 @@ const DPB_SLOTS: u32 = 8;
/// latency — on-glass validated as rock-solid at 1080p@240, so it is the real-time default;
/// backpressure kicks in at the 2nd unread frame. Distinct from `DPB_SLOTS` (reference pool).
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
/// frame; it only matters as the starting point and for the (rate-control-ignored) constant-Q path.
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
/// 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.
///
/// [`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 {
compute_cmd: vk::CommandBuffer, // CSC (compute+transfer)
cmd: vk::CommandBuffer, // encode queue
@@ -294,11 +284,6 @@ impl VulkanVideoEncoder {
None,
)
.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);
@@ -435,8 +420,6 @@ impl VulkanVideoEncoder {
let ext_fd = ash::khr::external_memory_fd::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);
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)
let av1_sci = av1b::VideoEncodeAV1SessionCreateInfoKHR {
@@ -470,13 +453,13 @@ impl VulkanVideoEncoder {
if r != vk::Result::SUCCESS {
bail!("create_video_session: {r:?}");
}
guard.session = session;
// bind session memory
let get_mem = vq_dev.fp().get_video_session_memory_requirements_khr;
let mut n = 0u32;
let _ = get_mem(device.handle(), session, &mut n, std::ptr::null_mut());
let mut reqs = vec![vk::VideoSessionMemoryRequirementsKHR::default(); n as usize];
let _ = get_mem(device.handle(), session, &mut n, reqs.as_mut_ptr());
let mut session_mem = Vec::new();
let mut binds = Vec::new();
for rq in &reqs {
let mr = rq.memory_requirements;
@@ -491,7 +474,7 @@ impl VulkanVideoEncoder {
.memory_type_index(ti),
None,
)?;
guard.session_mem.push(m);
session_mem.push(m);
binds.push(
vk::BindVideoSessionMemoryInfoKHR::default()
.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)?;
(p, hdr, Vec::new())
};
guard.params = params;
// ---- DPB image (NV12 OPTIMAL, ring of slots) — encode queue only ----
let mut profile_list =
@@ -545,13 +527,9 @@ impl VulkanVideoEncoder {
&mut profile_list,
&[],
)?;
guard.dpb_image = dpb_image;
guard.dpb_mem = dpb_mem;
for slot in 0..DPB_SLOTS {
guard
.dpb_views
.push(make_view(&device, dpb_image, NV12, slot)?);
}
let dpb_views: Vec<vk::ImageView> = (0..DPB_SLOTS)
.map(|slot| make_view(&device, dpb_image, NV12, slot))
.collect::<Result<_>>()?;
// 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.
@@ -570,11 +548,9 @@ impl VulkanVideoEncoder {
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE),
None,
)?;
guard.sampler = sampler;
let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?;
let shader =
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
guard.shader = shader;
let sb = |b: u32, t: vk::DescriptorType| {
vk::DescriptorSetLayoutBinding::default()
.binding(b)
@@ -592,7 +568,6 @@ impl VulkanVideoEncoder {
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
None,
)?;
guard.csc_dsl = csc_dsl;
let dsls = [csc_dsl];
// Push constant: cursor {ivec2 origin, ivec2 size} = 16 bytes (size.x<=0 disables the blend).
let pc_ranges = [vk::PushConstantRange::default()
@@ -605,7 +580,6 @@ impl VulkanVideoEncoder {
.push_constant_ranges(&pc_ranges),
None,
)?;
guard.csc_layout = csc_layout;
let stage = vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::COMPUTE)
.module(shader)
@@ -619,10 +593,7 @@ impl VulkanVideoEncoder {
None,
)
.map_err(|(_, e)| e)?[0];
guard.csc_pipe = csc_pipe;
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.
let nframes = ring_depth();
let pool_sizes = [
@@ -640,7 +611,6 @@ impl VulkanVideoEncoder {
.pool_sizes(&pool_sizes),
None,
)?;
guard.csc_pool = csc_pool;
// ---- bitstream size (shared) + shared command pools ----
let bs_size = align_up(
@@ -653,21 +623,17 @@ impl VulkanVideoEncoder {
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER),
None,
)?;
guard.cmd_pool = cmd_pool;
let compute_pool = device.create_command_pool(
&vk::CommandPoolCreateInfo::default()
.queue_family_index(compute_family)
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER),
None,
)?;
guard.compute_pool = compute_pool;
// ---- build the in-flight frame ring ----
let mut frames = Vec::with_capacity(nframes);
for _ in 0..nframes {
// Pre-push a null Frame and build it in place, so a mid-`make_frame` failure leaves
// the partial handles in the guard rather than losing them with the Err.
guard.frames.push(Frame::default());
make_frame(
frames.push(make_frame(
&device,
&mem_props,
w,
@@ -681,17 +647,9 @@ impl VulkanVideoEncoder {
compute_pool,
bs_size,
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 {
_entry: entry,
instance,
@@ -910,15 +868,6 @@ impl VulkanVideoEncoder {
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
// (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 {
let (_, _, oi, om, ov) = self.import_cache.remove(0);
self.device.destroy_image_view(ov, None);
@@ -1900,39 +1849,9 @@ impl VulkanVideoEncoder {
unsafe fn read_slot(&mut self, slot: usize) -> Result<EncodedFrame> {
let dev = self.device.clone();
let f = &self.frames[slot];
// Ask for the operation status alongside the two feedback words: without it a FAILED encode
// is indistinguishable from a successful one, and its offset/bytes-written are read as if
// they described real bitstream. The status rides as a trailing element (signed:
// `VkQueryResultStatusKHR` is >0 COMPLETE, 0 NOT_READY, <0 error).
let mut fb = [[0i32; 3]; 1];
dev.get_query_pool_results(
f.query_pool,
0,
&mut fb,
vk::QueryResultFlags::WAIT | vk::QueryResultFlags::WITH_STATUS_KHR,
)?;
let status = fb[0][2];
if status <= 0 {
anyhow::bail!(
"vulkan-encode: encode feedback for slot {slot} reports status {status} \
(not COMPLETE) dropping the frame rather than shipping its bitstream"
);
}
let fb = [[fb[0][0] as u32, fb[0][1] as u32]];
// The (offset, bytes-written) pair is driver-reported: validate it against the bitstream
// 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 mut fb = [[0u32; 2]; 1];
dev.get_query_pool_results(f.query_pool, 0, &mut fb, vk::QueryResultFlags::WAIT)?;
let (off, len) = (fb[0][0] as usize, fb[0][1] as usize);
let p =
dev.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8;
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.
while self.in_flight.len() >= self.frames.len() {
let slot = self.in_flight.pop_front().unwrap();
// Bounded, not `u64::MAX`: this runs ON the host encode thread, which is also the
// thread the stall watchdog's `reset()` would run on. An infinite wait against a
// wedged GPU/driver therefore parks the one thread that could recover the session —
// it never errors, never resets, and teardown blocks joining it. Surfacing expiry as
// an error hands control back to the existing recovery path (same convention as the
// pyrowave and Windows NVENC backends).
match self.device.wait_for_fences(
&[self.frames[slot].fence],
true,
ENCODE_FENCE_TIMEOUT_NS,
) {
Ok(()) => {}
Err(vk::Result::TIMEOUT) => anyhow::bail!(
"vulkan-encode: fence for slot {slot} did not signal within {} ms — GPU or \
driver wedged; failing the submit so the session can reset",
ENCODE_FENCE_TIMEOUT_NS / 1_000_000
),
Err(e) => return Err(e.into()),
}
self.device
.wait_for_fences(&[self.frames[slot].fence], true, u64::MAX)?;
let done = self.read_slot(slot)?;
self.pending.push_back(done);
}
@@ -2021,28 +1923,7 @@ impl Encoder for VulkanVideoEncoder {
if first_frame < 0 || first_frame > last_frame {
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?
// (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) {
Some(_) => {
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 {
fn drop(&mut self) {
// The whole teardown sequence lives in `VkTeardown` (shared with `open_inner`'s failure
// unwind): rebuild one from our fields and let its Drop run it.
drop(VkTeardown {
instance: Some(self.instance.clone()),
device: Some(self.device.clone()),
vq_dev: Some(self.vq_dev.clone()),
import_cache: std::mem::take(&mut self.import_cache),
frames: std::mem::take(&mut self.frames),
compute_pool: self.compute_pool,
cmd_pool: self.cmd_pool,
shader: vk::ShaderModule::null(),
csc_pipe: self.csc_pipe,
csc_layout: self.csc_layout,
csc_pool: self.csc_pool,
csc_dsl: self.csc_dsl,
sampler: self.sampler,
dpb_views: std::mem::take(&mut self.dpb_views),
dpb_image: self.dpb_image,
dpb_mem: self.dpb_mem,
params: self.params,
session: self.session,
session_mem: std::mem::take(&mut self.session_mem),
});
// 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 drains prevent a double free) and in dependency order (views before images before
// memory, per-frame objects before their shared pools, session params before session).
unsafe {
let _ = self.device.device_wait_idle();
for (_, _, img, mem, view) in std::mem::take(&mut self.import_cache) {
self.device.destroy_image_view(view, None);
self.device.destroy_image(img, None);
self.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) {
self.device.destroy_semaphore(f.csc_sem, None);
self.device.destroy_fence(f.fence, None);
self.device.destroy_query_pool(f.query_pool, None);
self.device.destroy_buffer(f.bs_buf, None);
self.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),
] {
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 req = device.get_image_memory_requirements(img);
// Unwind on failure: callers (the open path) only ever see the completed pair.
let mem = match device.allocate_memory(
let mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(find_mem(
@@ -2355,28 +2134,14 @@ unsafe fn make_video_image(
vk::MemoryPropertyFlags::DEVICE_LOCAL,
)),
None,
) {
Ok(m) => m,
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());
}
)?;
device.bind_image_memory(img, mem, 0)?;
Ok((img, mem))
}
/// 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
/// 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(
device: &ash::Device,
mem_props: &vk::PhysicalDeviceMemoryProperties,
@@ -2391,12 +2156,9 @@ unsafe fn make_frame(
compute_pool: vk::CommandPool,
bs_size: u64,
sampler: vk::Sampler,
f: &mut Frame,
) -> Result<()> {
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
f.cursor_serial = u64::MAX;
) -> Result<Frame> {
// 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,
mem_props,
NV12,
@@ -2407,9 +2169,9 @@ unsafe fn make_frame(
profile_list,
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).
(f.y_img, f.y_mem, f.y_view) = make_plain_image(
let (y_img, y_mem, y_view) = make_plain_image(
device,
mem_props,
vk::Format::R8_UNORM,
@@ -2417,7 +2179,7 @@ unsafe fn make_frame(
h,
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,
mem_props,
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
// view/descriptor is static (bound at binding 3 below); only the image *content* changes, and
// 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,
mem_props,
vk::Format::R8G8B8A8_UNORM,
@@ -2436,14 +2198,14 @@ unsafe fn make_frame(
CURSOR_MAX,
vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
)?;
f.cursor_stage = device.create_buffer(
let cursor_stage = device.create_buffer(
&vk::BufferCreateInfo::default()
.size((CURSOR_MAX * CURSOR_MAX * 4) as u64)
.usage(vk::BufferUsageFlags::TRANSFER_SRC),
None,
)?;
let cs_req = device.get_buffer_memory_requirements(f.cursor_stage);
f.cursor_stage_mem = device.allocate_memory(
let cs_req = device.get_buffer_memory_requirements(cursor_stage);
let cursor_stage_mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(cs_req.size)
.memory_type_index(find_mem(
@@ -2453,39 +2215,39 @@ unsafe fn make_frame(
)),
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
// (cursor) points at the static cursor image (its layout is SHADER_READ_ONLY once prepped).
let dsls = [csc_dsl];
f.csc_set = device.allocate_descriptor_sets(
let csc_set = device.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(csc_pool)
.set_layouts(&dsls),
)?[0];
let y_info = [vk::DescriptorImageInfo::default()
.image_view(f.y_view)
.image_view(y_view)
.image_layout(vk::ImageLayout::GENERAL)];
let uv_info = [vk::DescriptorImageInfo::default()
.image_view(f.uv_view)
.image_view(uv_view)
.image_layout(vk::ImageLayout::GENERAL)];
let cur_info = [vk::DescriptorImageInfo::default()
.sampler(sampler)
.image_view(f.cursor_view)
.image_view(cursor_view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
device.update_descriptor_sets(
&[
vk::WriteDescriptorSet::default()
.dst_set(f.csc_set)
.dst_set(csc_set)
.dst_binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
.image_info(&y_info),
vk::WriteDescriptorSet::default()
.dst_set(f.csc_set)
.dst_set(csc_set)
.dst_binding(2)
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
.image_info(&uv_info),
vk::WriteDescriptorSet::default()
.dst_set(f.csc_set)
.dst_set(csc_set)
.dst_binding(3)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(&cur_info),
@@ -2493,15 +2255,15 @@ unsafe fn make_frame(
&[],
);
// Bitstream buffer + feedback query.
f.bs_buf = device.create_buffer(
let bs_buf = device.create_buffer(
&vk::BufferCreateInfo::default()
.size(bs_size)
.usage(vk::BufferUsageFlags::VIDEO_ENCODE_DST_KHR)
.push_next(profile_list),
None,
)?;
let bs_req = device.get_buffer_memory_requirements(f.bs_buf);
f.bs_mem = device.allocate_memory(
let bs_req = device.get_buffer_memory_requirements(bs_buf);
let bs_mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(bs_req.size)
.memory_type_index(find_mem(
@@ -2511,7 +2273,7 @@ unsafe fn make_frame(
)),
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(
vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BUFFER_OFFSET
| vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BYTES_WRITTEN,
@@ -2521,21 +2283,51 @@ unsafe fn make_frame(
.query_type(vk::QueryType::VIDEO_ENCODE_FEEDBACK_KHR)
.query_count(1);
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.
f.cmd = device.allocate_command_buffers(
let cmd = device.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(cmd_pool)
.command_buffer_count(1),
)?[0];
f.compute_cmd = device.allocate_command_buffers(
let compute_cmd = device.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(compute_pool)
.command_buffer_count(1),
)?[0];
f.csc_sem = device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?;
f.fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
Ok(())
let csc_sem = device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?;
let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
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
@@ -2638,12 +2430,6 @@ unsafe fn build_parameters_h265(
std::ptr::null_mut(),
);
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:?}");
}
let mut buf = vec![0u8; size];
@@ -2655,11 +2441,6 @@ unsafe fn build_parameters_h265(
buf.as_mut_ptr() as *mut c_void,
);
if r != vk::Result::SUCCESS {
(vq_dev.fp().destroy_video_session_parameters_khr)(
device.handle(),
params,
std::ptr::null(),
);
bail!("get header bytes: {r:?}");
}
buf.truncate(size);
@@ -2916,62 +2697,6 @@ mod tests {
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
/// setup slot's dying occupant is not, and `used_by_curr_pic` marks exactly the real reference.
#[test]
+5 -33
View File
@@ -409,12 +409,6 @@ pub struct NvencD3d11Encoder {
events: Vec<usize>,
/// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve).
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.
async_supported: bool,
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
@@ -511,7 +505,6 @@ impl NvencD3d11Encoder {
bitstreams: Vec::new(),
events: Vec::new(),
async_rt: None,
input_ring_depth: None,
async_supported: false,
pending: VecDeque::new(),
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.
let opening = self.next == 0;
// 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
// completion (the retrieve thread is already waiting on its event) before submitting more —
// bounding depth exactly like the sync path's per-tick blocking poll, just `cap` deep
// instead of 1.
//
// 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 {
// keep in-flight depth within the capturer's texture ring (see `async_inflight_cap`). At
// the cap, block on the OLDEST completion (the retrieve thread is already waiting on its
// event) before submitting more — bounding depth exactly like the sync path's per-tick
// blocking poll, just `cap` deep instead of 1.
while self.async_rt.is_some() && self.pending.len() >= async_inflight_cap() {
let done = {
let rt = self.async_rt.as_mut().expect("checked in loop condition");
rt.done_rx
@@ -1353,17 +1336,6 @@ impl Encoder for NvencD3d11Encoder {
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) {
self.force_kf = true;
}
+6 -57
View File
@@ -43,9 +43,6 @@ const BS_SLACK: usize = 256 * 1024;
/// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed.
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
// 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
@@ -139,12 +136,8 @@ pub struct PyroWaveEncoder {
// 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
// NV12 import is unreliable on NVIDIA at arbitrary sizes).
/// The capturer ring generation the cached plane imports below belong to. A recreate bumps it,
/// and every cached import is destroyed — the COM addresses they are keyed on can be recycled
/// 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)>,
y_images: Vec<(isize, pw::pyrowave_image)>,
cbcr_images: Vec<(isize, pw::pyrowave_image)>,
width: u32,
height: u32,
@@ -275,7 +268,6 @@ impl PyroWaveEncoder {
pw_dev,
pw_enc,
sync: std::ptr::null_mut(),
ring_gen: None,
y_images: Vec::new(),
cbcr_images: Vec::new(),
width,
@@ -359,16 +351,10 @@ impl PyroWaveEncoder {
///
/// # Safety
/// 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(
cache: &mut Vec<(PlaneKey, pw::pyrowave_image)>,
cache: &mut Vec<(isize, pw::pyrowave_image)>,
make: impl FnOnce() -> Result<pw::pyrowave_image>,
key: PlaneKey,
key: isize,
) -> Result<pw::pyrowave_image> {
if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) {
return Ok(*img);
@@ -437,21 +423,6 @@ impl PyroWaveEncoder {
!self.pw_enc.is_null(),
"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 {
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)",
)?;
// 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
// 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).
@@ -513,7 +465,7 @@ impl PyroWaveEncoder {
};
let pw_dev = self.pw_dev;
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;
Self::cached_plane(
&mut self.y_images,
@@ -522,7 +474,7 @@ impl PyroWaveEncoder {
)?
};
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;
Self::cached_plane(
&mut self.cbcr_images,
@@ -1024,9 +976,6 @@ mod tests {
cbcr: cbcr_tex,
fence_handle: Some(fence_handle.0 as isize),
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,
+3 -31
View File
@@ -714,16 +714,7 @@ pub struct QsvEncoder {
/// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below.
ltr_active: bool,
/// 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],
/// 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,
ltr_mark_interval: i64,
/// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references.
@@ -798,7 +789,6 @@ impl QsvEncoder {
ir_active: false,
ltr_active: false,
ltr_slots: [None; NUM_LTR_SLOTS],
ltr_tainted: [false; NUM_LTR_SLOTS],
next_ltr_slot: 0,
ltr_mark_interval: ltr_mark_interval(fps),
pending_force: None,
@@ -923,7 +913,6 @@ impl QsvEncoder {
self.ltr_active = ltr_active;
self.ir_active = ir_active;
self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS];
self.next_ltr_slot = 0;
self.pending_force = None;
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
// queued force; the mark cadence below re-anchors on the IDR itself.
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.pending_force = None;
} 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
// 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).
// The slot is no longer emptied by the sweep, so test the taint flag too — a
// tainted slot is exactly the "nothing clean to re-reference" case.
if let Some(idx) = self.ltr_slots[slot].filter(|_| !self.ltr_tainted[slot]) {
if let Some(idx) = self.ltr_slots[slot] {
force_ltr = Some((slot, idx));
recovery_anchor = true;
}
@@ -1075,9 +1061,6 @@ impl Encoder for QsvEncoder {
if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
let slot = self.next_ltr_slot;
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;
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
// 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.
//
// 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() {
for marked in self.ltr_slots.iter_mut() {
if marked.is_some_and(|idx| idx >= first) {
self.ltr_tainted[slot] = true;
*marked = None;
}
}
let mut best: Option<(usize, i64)> = None;
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 idx < first && best.is_none_or(|(_, b)| idx > b) {
best = Some((slot, idx));
@@ -1481,7 +1454,6 @@ impl Encoder for QsvEncoder {
self.ltr_active = ltr;
self.ir_active = ir;
self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS];
self.next_ltr_slot = 0;
self.pending_force = None;
if let Some(inner) = self.inner.as_mut() {
-5
View File
@@ -209,11 +209,6 @@ impl Encoder for TrackedEncoder {
fn set_wire_chunking(&mut self, shard_payload: usize) {
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>> {
self.inner.poll()
}
-6
View File
@@ -52,12 +52,6 @@ pub struct PyroFrameShare {
/// The fence value the capturer signalled after THIS frame's convert. The encoder's Vulkan
/// acquire waits on it, so the wavelet read is ordered after the D3D11 CSC.
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;
@@ -17,7 +17,7 @@
use anyhow::{bail, Context, Result};
use std::mem::size_of;
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::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
/// closes the gadget (the kernel tears down the device).
pub struct SteamDeckGadget {
@@ -238,7 +203,6 @@ pub struct SteamDeckGadget {
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
running: Arc<AtomicBool>,
threads: Vec<JoinHandle<()>>,
wakers: Vec<Waker>,
_fd: Arc<GadgetFd>,
seq: u32,
}
@@ -279,18 +243,6 @@ impl SteamDeckGadget {
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
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.
let control = {
let fd = fd.clone();
@@ -298,15 +250,10 @@ impl SteamDeckGadget {
let ctrl_ep = ctrl_ep.clone();
let configured = configured.clone();
let feedback = feedback.clone();
let tid = ctrl_waker.tid.clone();
let done = ctrl_waker.done.clone();
std::thread::Builder::new()
.name("pf-deck-gadget-ctrl".into())
.spawn(move || {
// SAFETY: `pthread_self` is always valid on the calling thread.
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);
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
})
.context("spawn gadget control thread")?
};
@@ -317,16 +264,9 @@ impl SteamDeckGadget {
let ctrl_ep = ctrl_ep.clone();
let configured = configured.clone();
let report = report.clone();
let tid = stream_waker.tid.clone();
let done = stream_waker.done.clone();
std::thread::Builder::new()
.name("pf-deck-gadget-stream".into())
.spawn(move || {
// 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);
})
.spawn(move || stream_loop(fd, running, ctrl_ep, configured, report))
.context("spawn gadget stream thread")?
};
@@ -335,7 +275,6 @@ impl SteamDeckGadget {
feedback,
running,
threads: vec![control, stream],
wakers: vec![ctrl_waker, stream_waker],
_fd: fd,
seq: 0,
})
@@ -363,32 +302,6 @@ impl SteamDeckGadget {
impl Drop for SteamDeckGadget {
fn drop(&mut self) {
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(..) {
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())? };
// `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).
// HEAP-allocated, deliberately: `sw_create_cb` writes `result` + up to 127 u16 of instance id
// 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 {
let mut ctx = SwCreateCtx {
event,
result: E_FAIL,
instance_id: [0; 128],
}));
// SAFETY: info + the buffers outlive the call; `ctx` is a live heap allocation that outlives every
// path below (reclaimed only where the callback provably ran). windows-rs returns the HSWDEVICE
// (the C out-param) as the Result value.
};
// SAFETY: info + the buffers + ctx outlive the call (we wait on the event before returning);
// windows-rs returns the HSWDEVICE (the C out-param) as the Result value.
let hsw = match unsafe {
SwDeviceCreate(
w!("punktfunk"),
@@ -320,15 +313,13 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
&info,
None,
Some(sw_create_cb),
Some(ctx as *const c_void),
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
)
} {
Ok(h) => h,
Err(e) => {
// SAFETY: the call failed, so no callback was registered and `ctx` is ours to reclaim;
// `event` is valid and unreferenced.
// SAFETY: event is valid.
unsafe {
drop(Box::from_raw(ctx));
let _ = CloseHandle(event);
}
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.
// SAFETY: event is valid.
let wait = unsafe { WaitForSingleObject(event, 10_000) };
// SAFETY: event is valid.
unsafe {
let _ = CloseHandle(event);
}
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.
unsafe { SwDeviceClose(hsw) };
return Err(anyhow!(
"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() {
// SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) };
@@ -62,7 +62,7 @@ impl Ds4WinPad {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let inst = format!("pf_ds4_{index}");
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst,
container_tag: 0x5046_4453, // "PFDS"
container_index: index,
@@ -70,13 +70,13 @@ impl Ds4WinPad {
usb_vid_pid: "VID_054C&PID_09CC",
usb_mi: None,
description: "punktfunk Virtual DualShock 4",
})?; // Propagate, do NOT swallow — see below.
let (hsw, instance_id) = (Some(hsw), instance_id);
// Swallowing a create failure here (the previous behaviour) latched the pad slot to
// `Some(pad)` with no live devnode: `PadSlots::ensure` short-circuits on `is_some()` and
// `gate.on_success()` cleared the backoff, so the create-gate that exists precisely to
// 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.
}) {
Ok((h, id)) => (Some(h), id),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
(None, None)
}
};
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// 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
@@ -82,16 +82,12 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
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
// 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
// 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 {
let mut ctx = SwCreateCtx {
event,
result: E_FAIL,
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 {
SwDeviceCreate(
w!("punktfunk"),
@@ -99,14 +95,13 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
&info,
None,
Some(sw_create_cb),
Some(ctx as *const c_void),
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
)
} {
Ok(h) => h,
Err(e) => {
// SAFETY: the call failed, so no callback is pending and `ctx` is ours to reclaim.
// SAFETY: event is valid.
unsafe {
drop(Box::from_raw(ctx));
let _ = CloseHandle(event);
}
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.
let wait = unsafe { WaitForSingleObject(event, 10_000) };
// SAFETY: event is valid.
unsafe {
let _ = CloseHandle(event);
}
if wait != WAIT_OBJECT_0 {
// Timed out — intentionally leak `ctx` and leave `event` open (see above).
// SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) };
return Err(anyhow!(
"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() {
// SAFETY: hsw is the handle SwDeviceCreate returned.
unsafe { SwDeviceClose(hsw) };
@@ -66,7 +66,7 @@ impl DeckWinPad {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let inst = format!("pf_deck_{index}");
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst,
container_tag: 0x5046_4453, // "PFDS"
container_index: index,
@@ -77,8 +77,13 @@ impl DeckWinPad {
// spike's run-1 failure).
usb_mi: Some(2),
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);
// 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.
+4 -20
View File
@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering};
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.
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
/// 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.
static REAPER: Mutex<Vec<(Child, Instant)>> = 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);
static REAPER: Mutex<Vec<Child>> = Mutex::new(Vec::new());
fn sweep_reaper() {
let mut list = REAPER.lock().unwrap();
let now = Instant::now();
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
});
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
}
/// 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.
if let Some(mut child) = self.child.take() {
if !matches!(child.try_wait(), Ok(Some(_))) {
REAPER.lock().unwrap().push((child, Instant::now()));
REAPER.lock().unwrap().push(child);
}
}
sweep_reaper();
+7 -13
View File
@@ -1003,13 +1003,7 @@ impl RegisteredTexture {
// 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
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
// `count == 1` resource via `&mut self.resource` (a live field). It is issued on
// `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()`.
// `count == 1` resource via `&mut self.resource` (a live field) on the default stream;
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `&copy` is a live
// 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.
unsafe {
ck(
cuGraphicsMapResources(1, &mut self.resource, copy_stream()),
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
"cuGraphicsMapResources",
)?;
let mut array: CUarray = std::ptr::null_mut();
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");
}
let copy = CUDA_MEMCPY2D {
@@ -1037,7 +1031,7 @@ impl RegisteredTexture {
..Default::default()
};
let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2");
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
res
}
}
@@ -1064,12 +1058,12 @@ impl RegisteredTexture {
// so the map/unmap pair stays balanced and the array outlives the copy.
unsafe {
ck(
cuGraphicsMapResources(1, &mut self.resource, copy_stream()),
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
"cuGraphicsMapResources",
)?;
let mut array: CUarray = std::ptr::null_mut();
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");
}
let copy = CUDA_MEMCPY2D {
@@ -1083,7 +1077,7 @@ impl RegisteredTexture {
..Default::default()
};
let res = copy_blocking(&copy, "cuMemcpy2DAsync_v2(plane)");
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
res
}
}
-9
View File
@@ -691,15 +691,6 @@ impl EglImporter {
width: u32,
height: u32,
) -> 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()?;
if self
.linear_nv12_pool
+62 -131
View File
@@ -193,17 +193,10 @@ impl VkBridge {
/// Import `fd` (dup'd internally; Vulkan owns the dup) as a transfer-src buffer of `size`.
unsafe fn import_src(&mut self, fd: i32, size: u64) -> Result<()> {
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd};
let dup = libc::dup(fd);
if dup < 0 {
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()
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
let buffer = self
@@ -219,55 +212,41 @@ impl VkBridge {
.push_next(&mut ext_info),
None,
)
.context("create import buffer")?; // `dup` drops → closes on failure
.context("create import buffer")?;
let mut fd_props = vk::MemoryFdPropertiesKHR::default();
if let Err(e) = self.ext_fd.get_memory_fd_properties(
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
dup.as_raw_fd(),
&mut fd_props,
) {
self.device.destroy_buffer(buffer, None);
return Err(e).context("vkGetMemoryFdPropertiesKHR");
}
self.ext_fd
.get_memory_fd_properties(
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
dup,
&mut fd_props,
)
.context("vkGetMemoryFdPropertiesKHR")?;
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,
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()
.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 memory = match self.device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(reqs.size.max(size))
.memory_type_index(mem_type)
.push_next(&mut import)
.push_next(&mut dedicated),
None,
) {
Ok(m) => m,
Err(e) => {
libc::close(raw); // failed import does not consume the fd
self.device.destroy_buffer(buffer, None);
return Err(anyhow!("import dmabuf memory: {e}"));
}
};
if let Err(e) = self.device.bind_buffer_memory(buffer, memory, 0) {
// `memory` owns the imported fd — freeing it releases the fd too.
self.device.free_memory(memory, None);
self.device.destroy_buffer(buffer, None);
return Err(e).context("bind import memory");
}
let memory = self
.device
.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(reqs.size.max(size))
.memory_type_index(mem_type)
.push_next(&mut import)
.push_next(&mut dedicated),
None,
)
.map_err(|e| {
libc::close(dup); // failed import does not consume the fd
anyhow!("import dmabuf memory: {e}")
})?;
self.device
.bind_buffer_memory(buffer, memory, 0)
.context("bind import memory")?;
self.src_cache.insert(
fd,
SrcBuf {
@@ -284,11 +263,11 @@ impl VkBridge {
if self.dst.as_ref().is_some_and(|d| d.size >= size) {
return Ok(());
}
// Build the replacement FULLY before retiring the old one. Previously the old dst was
// destroyed and `self.dst` nulled up front, so a failed rebuild both dropped the working
// buffer AND leaked every object the partial rebuild created (`buffer`/`memory` are raw ash
// handles with no Drop, and `VkBridge::drop` only frees the live `self.dst`). Now every
// fallible step unwinds locally, and the swap happens only on full success.
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
}
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
let buffer = self
@@ -306,63 +285,35 @@ impl VkBridge {
.context("create export buffer")?;
let reqs = self.device.get_buffer_memory_requirements(buffer);
let mem_type =
match 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);
}
};
self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)?;
let mut export = vk::ExportMemoryAllocateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
let memory = match self.device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(reqs.size)
.memory_type_index(mem_type)
.push_next(&mut export)
.push_next(&mut dedicated),
None,
) {
Ok(m) => m,
Err(e) => {
self.device.destroy_buffer(buffer, None);
return Err(e).context("allocate exportable memory");
}
};
if let Err(e) = self.device.bind_buffer_memory(buffer, memory, 0) {
self.device.free_memory(memory, None);
self.device.destroy_buffer(buffer, None);
return Err(e).context("bind export memory");
}
let opaque_fd = match self.ext_fd.get_memory_fd(
&vk::MemoryGetFdInfoKHR::default()
.memory(memory)
.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");
}
};
let memory = self
.device
.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(reqs.size)
.memory_type_index(mem_type)
.push_next(&mut export)
.push_next(&mut dedicated),
None,
)
.context("allocate exportable memory")?;
self.device
.bind_buffer_memory(buffer, memory, 0)
.context("bind export memory")?;
let opaque_fd = self
.ext_fd
.get_memory_fd(
&vk::MemoryGetFdInfoKHR::default()
.memory(memory)
.handle_type(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD),
)
.context("vkGetMemoryFdKHR")?;
// 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 = match cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size) {
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
}
let cuda = cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size)
.context("cuImportExternalMemory(OPAQUE_FD from Vulkan)")?;
tracing::info!(size, "Vulkan→CUDA exportable staging buffer ready");
self.dst = Some(DstBuf {
buffer,
@@ -593,19 +544,9 @@ impl VkBridge {
self.device
.queue_submit(self.queue, &[submit], self.fence)
.context("queue submit")?;
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still
// 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
self.device
.wait_for_fences(&[self.fence], true, 1_000_000_000)
{
let _ = self.device.device_wait_idle();
let _ = self.device.reset_fences(&[self.fence]);
return Err(e).context("fence wait");
}
.context("fence wait")?;
self.device
.reset_fences(&[self.fence])
.context("reset fence")?;
@@ -698,19 +639,9 @@ impl VkBridge {
self.device
.queue_submit(self.queue, &[submit], self.fence)
.context("queue submit")?;
// Exception-safe wait: a TIMEOUT/DEVICE_LOST must not `?` out with the submission still
// 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
self.device
.wait_for_fences(&[self.fence], true, 1_000_000_000)
{
let _ = self.device.device_wait_idle();
let _ = self.device.reset_fences(&[self.fence]);
return Err(e).context("fence wait");
}
.context("fence wait")?;
self.device
.reset_fences(&[self.fence])
.context("reset fence")?;
-8
View File
@@ -125,12 +125,6 @@ pub struct PunktfunkFrame {
pub frame_index: u32,
pub pts_ns: u64,
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.
@@ -397,7 +391,6 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
frame_index: f.frame_index,
pts_ns: f.pts_ns,
flags: f.flags,
received_ns: f.received_ns,
};
}
PunktfunkStatus::Ok
@@ -1751,7 +1744,6 @@ pub unsafe extern "C" fn punktfunk_connection_next_au(
frame_index: f.frame_index,
pts_ns: f.pts_ns,
flags: f.flags,
received_ns: f.received_ns,
};
}
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.
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.
/// 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
@@ -327,7 +191,6 @@ mod frame_channel_tests {
pts_ns: i as u64,
flags: 0,
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));
}
}
#[cfg(test)]
mod standing_latency_tests {
use super::{
StandingLatAction, StandingLatency, STANDING_LAT_MAX_BLEEDS, STANDING_LAT_THRESH_NS,
STANDING_LAT_WINDOWS,
};
const FLOOR: i128 = 2_000_000; // a healthy 2 ms LAN OWD
const ELEVATED: i128 = FLOOR + STANDING_LAT_THRESH_NS + 7_000_000; // ~one 60fps frame above
/// Run `n` windows at `owd`, asserting every window but the last returns None; returns the
/// last window's action.
fn run_windows(d: &mut StandingLatency, owd: i128, n: u32) -> StandingLatAction {
for i in 0..n {
d.note_frame(owd);
let a = d.on_window(true);
if i + 1 < n {
assert_eq!(a, StandingLatAction::None, "window {i} escalated early");
} else {
return a;
}
}
unreachable!("n > 0 by construction");
}
/// Learn a clean floor: one window at the healthy OWD.
fn learned(d: &mut StandingLatency) {
d.note_frame(FLOOR);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
#[test]
fn healthy_stream_never_escalates() {
let mut d = StandingLatency::new();
learned(&mut d);
// Jitter riding above the floor but under the threshold: never a run.
for _ in 0..(STANDING_LAT_WINDOWS * 4) {
d.note_frame(FLOOR + STANDING_LAT_THRESH_NS - 1);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
}
#[test]
fn escalation_ladder_resync_then_bleeds_then_disarm() {
let mut d = StandingLatency::new();
learned(&mut d);
// First full elevated run asks for the free fix: a clock re-sync.
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
// Re-sync didn't help (no rebase came) — each further run is a bleed, up to the cap...
for _ in 0..STANDING_LAT_MAX_BLEEDS {
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Bleed { .. }
));
d.bled();
}
// ...then the detector gives up loudly, once, and stays quiet.
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Disarm { .. }
));
d.note_frame(ELEVATED);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
#[test]
fn loss_windows_reset_the_run() {
let mut d = StandingLatency::new();
learned(&mut d);
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
d.note_frame(ELEVATED);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
// A lossy window means congestion, not a standing state: run resets...
d.note_frame(ELEVATED);
assert_eq!(d.on_window(false), StandingLatAction::None);
// ...so the ladder needs the full run again before acting.
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
}
#[test]
fn recovery_resets_the_ladder_to_resync_first() {
let mut d = StandingLatency::new();
learned(&mut d);
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
// The elevation clears on its own (e.g. the successful bleed case, or transient): the
// next episode starts back at the free escalation, not at a bleed.
d.note_frame(FLOOR);
assert_eq!(d.on_window(true), StandingLatAction::None);
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
}
#[test]
fn applied_resync_rebases_and_clears_a_stale_offset_elevation() {
let mut d = StandingLatency::new();
learned(&mut d);
assert!(matches!(
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
StandingLatAction::Resync { .. }
));
// The re-sync APPLIES (pump sees clock_gen move) → rebase. The corrected offset brings
// OWD readings back to truth; the floor re-learns and nothing ever escalates to a bleed.
d.rebase();
for _ in 0..(STANDING_LAT_WINDOWS * 2) {
d.note_frame(FLOOR);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
}
#[test]
fn empty_windows_are_no_evidence() {
let mut d = StandingLatency::new();
learned(&mut d);
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
d.note_frame(ELEVATED);
assert_eq!(d.on_window(true), StandingLatAction::None);
}
// A frameless window (paused stream) neither advances nor resets the run...
assert_eq!(d.on_window(true), StandingLatAction::None);
// ...so one more elevated window completes it.
d.note_frame(ELEVATED);
assert!(matches!(
d.on_window(true),
StandingLatAction::Resync { .. }
));
}
}
+5 -19
View File
@@ -456,14 +456,9 @@ impl NativeClient {
hot_tids,
clock_offset,
decode_lat,
// The controller arms exactly when the pump does — all three terms, not two: Automatic
// (the user asked for bitrate 0), not a rate-pinned PyroWave stream, AND the host
// echoed the rate it actually configured. Dropping the last term made this
// 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,
// The controller arms exactly when the pump does (see `abr::BitrateController::new`
// below): Automatic (the user asked for bitrate 0) and not a rate-pinned PyroWave stream.
wants_decode: bitrate_kbps == 0 && negotiated.codec != crate::quic::CODEC_PYROWAVE,
mode: mode_slot,
host_fingerprint: negotiated.host_fingerprint,
resolved_compositor: negotiated.compositor,
@@ -708,23 +703,14 @@ impl NativeClient {
// Reset the accumulator so a fresh run doesn't blend into the previous one.
*self.probe.lock().unwrap() = ProbeState {
active: true,
duration_ms,
..Default::default()
};
let sent = self
.ctrl_tx
self.ctrl_tx
.try_send(CtrlRequest::Probe(ProbeRequest {
target_kbps,
duration_ms,
}))
.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
.map_err(|_| PunktfunkError::Closed)
}
/// 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,
/// The host's `ProbeResult` arrived → the measurement is final.
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`].
+32 -148
View File
@@ -1,9 +1,8 @@
//! The client worker: QUIC handshake + control/input/datagram tasks + the blocking data-plane pump.
use super::frame_channel::{
StandingLatAction, StandingLatency, CLOCK_RESYNC_INTERVAL, FLUSH_AFTER, FLUSH_COOLDOWN,
FLUSH_LATENCY, NOOP_CLOCK_FLUSHES_TO_DISARM, NOOP_FLUSH_DATAGRAMS, QUEUE_HIGH, QUEUE_LOW,
STANDING_TIME,
CLOCK_RESYNC_INTERVAL, FLUSH_AFTER, FLUSH_COOLDOWN, FLUSH_LATENCY,
NOOP_CLOCK_FLUSHES_TO_DISARM, NOOP_FLUSH_DATAGRAMS, QUEUE_HIGH, QUEUE_LOW, STANDING_TIME,
};
use super::worker::reject_from_close;
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
// read produces — the difference between "not accepted" and the actual cause.
let handshake = async {
let (mut send, recv) = conn
let (mut send, mut recv) = conn
.open_bi()
.await
.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(
&mut send,
@@ -140,7 +135,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
.encode(),
)
.await?;
let welcome = Welcome::decode(&recv.read_msg().await?)?;
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?;
if welcome.compositor != CompositorPref::Auto {
tracing::info!(
compositor = welcome.compositor.as_str(),
@@ -466,7 +461,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
break;
}
}
msg = ctrl_recv.read_msg() => {
msg = io::read_msg(&mut ctrl_recv) => {
let Ok(msg) = msg else { break }; // stream closed
if let Ok(ack) = Reconfigured::decode(&msg) {
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
// periodic batch try again.
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
// info, not debug: ≤1/min, and it is THE forensic
// trail for a stale-offset (stepped/slewed wall clock)
// latency plateau — the 2026-07 two-pair investigation
// had to reconstruct this blind.
tracing::info!(
clock_offset.store(offset_ns, Ordering::Relaxed);
clock_gen.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
offset_ns,
rtt_us = rtt_ns / 1000,
"mid-stream clock re-sync applied"
);
clock_offset.store(offset_ns, Ordering::Relaxed);
clock_gen.fetch_add(1, Ordering::Relaxed);
} else {
tracing::info!(
tracing::debug!(
rtt_us = rtt_ns / 1000,
"clock re-sync batch discarded — RTT above the \
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"))
.then(|| Instant::now() + CAPACITY_PROBE_DELAY);
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 flush_in_window = false;
// 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 resync_wanted = false;
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) {
// 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.
@@ -761,10 +740,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
seen_clock_gen = gen;
stale_since = None;
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 {
clock_detector_armed = true;
tracing::info!(
@@ -789,70 +764,31 @@ pub(super) async fn run_pump(args: WorkerArgs) {
}
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
// 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`
// and no correlation id, so a clobber both wrecks the user's "Test connection" figure
// (its base counters get re-snapshotted mid-burst against the full-burst denominator)
// and mis-scales our own ceiling. Retry once it finishes.
if capacity_probe_at.is_some_and(|at| Instant::now() >= at) && probe_active {
capacity_probe_at = Some(Instant::now() + CAPACITY_PROBE_DELAY);
} else if capacity_probe_at.is_some_and(|at| Instant::now() >= at) {
capacity_probe_at = None;
*pump_probe.lock().unwrap() = ProbeState {
active: true,
duration_ms: CAPACITY_PROBE_MS,
..Default::default()
};
if ctrl_tx
.try_send(CtrlRequest::Probe(ProbeRequest {
target_kbps: CAPACITY_PROBE_KBPS,
duration_ms: CAPACITY_PROBE_MS,
}))
.is_ok()
{
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
tracing::info!(
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(at) = capacity_probe_at {
if Instant::now() >= at {
capacity_probe_at = None;
*pump_probe.lock().unwrap() = ProbeState {
active: true,
..Default::default()
};
if ctrl_tx
.try_send(CtrlRequest::Probe(ProbeRequest {
target_kbps: CAPACITY_PROBE_KBPS,
duration_ms: CAPACITY_PROBE_MS,
}))
.is_ok()
{
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
tracing::info!(
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 {
@@ -907,51 +843,6 @@ pub(super) async fn run_pump(args: WorkerArgs) {
window_dropped,
);
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
// feed the controller this window's congestion signals; a decision becomes a
// 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 {
owd_sum_ns += lat_ns;
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
&& clock_offset_ns != 0
+1 -6
View File
@@ -83,12 +83,7 @@ pub use stats::Stats;
/// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
/// `punktfunk_connection_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.
/// v9: `PunktfunkFrame` grew `received_ns` — the reassembly-completion receipt stamp, so
/// 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;
pub const ABI_VERSION: u32 = 8;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+4 -25
View File
@@ -39,19 +39,10 @@ pub struct Packetizer {
/// DATA shards before any block's parity — all blocks' parity must stay alive until the
/// frame's second emission pass.
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 {
pub fn new(config: &Config) -> Self {
let max_data = config.fec.max_data_per_block as usize;
Packetizer {
next_frame_index: 0,
next_probe_index: 0,
@@ -61,9 +52,6 @@ impl Packetizer {
version: config.phase as u8,
tail: 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).
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).
if self.recovery.len() < block_count {
@@ -204,7 +183,7 @@ impl Packetizer {
let mut total_recovery = 0usize;
for b in 0..block_count {
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 {
return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
}
@@ -225,7 +204,7 @@ impl Packetizer {
block_index: b as u16,
block_count: block_count 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_bytes: payload as u16,
magic: PUNKTFUNK_MAGIC,
@@ -244,7 +223,7 @@ impl Packetizer {
// 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 recovery_count = recovery_for(k);
let recovery_count = self.fec.recovery_for(k);
coder.encode_into(&data_shards, recovery_count, &mut self.recovery[b])?;
for (shard_index, body) in data_shards.iter().enumerate() {
@@ -263,7 +242,7 @@ impl Packetizer {
let mut parity_left = total_recovery;
for b in 0..block_count {
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 {
parity_left -= 1;
let mut flags = FLAG_PIC;
+1 -14
View File
@@ -106,19 +106,8 @@ pub struct ReassemblerLimits {
impl ReassemblerLimits {
pub fn from_config(c: &Config) -> Self {
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 =
(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);
ReassemblerLimits {
shard_bytes: c.shard_payload,
@@ -500,7 +489,6 @@ impl Reassembler {
pts_ns: done.pts_ns,
flags: done.user_flags,
complete: true,
received_ns: 0, // stamped by Session::poll_frame at the session boundary
}));
}
Ok(None)
@@ -604,7 +592,6 @@ impl ReassemblyWindow {
pts_ns: f.pts_ns,
flags: f.user_flags,
complete: false,
received_ns: 0, // stamped by Session::poll_frame at the session boundary
});
}
}
-61
View File
@@ -579,64 +579,3 @@ fn rejects_wrong_shard_bytes_and_oversized_frame() {
.is_none());
assert_eq!(stats.snapshot().packets_dropped, 1);
}
/// Adaptive FEC raises `fec_percent` mid-session while the receiver's per-block acceptance
/// ceiling is frozen at session construction and never renegotiated. A maximal block must
/// therefore still land: the sender clamps its parity to the ceiling
/// (`Packetizer::recovery_for`), and the receiver sizes that ceiling from the whole clamp range
/// rather than the start percentage. Regression guard for the wedge this caused — every packet
/// of a large block failing `total > max_total_shards`, so the frame never completed and the
/// resulting loss drove adaptive FEC higher still.
#[test]
fn adaptive_fec_ramp_keeps_maximal_blocks_within_the_peers_ceiling() {
let cfg = e2e_config(FecScheme::Gf16, 10);
let coder = coder_for(FecScheme::Gf16);
let lim = ReassemblerLimits::from_config(&cfg);
let mut pk = Packetizer::new(&cfg);
// Ramp far past the negotiated 10% — exactly what `apply_fec_target` does under loss.
pk.set_fec_percent(50);
// A frame of full `max_data_per_block` blocks: where the ceiling actually binds.
let frame_len = cfg.shard_payload * cfg.fec.max_data_per_block as usize * 2;
let src: Vec<u8> = (0..frame_len).map(|i| (i * 131 + 7) as u8).collect();
let pkts = pk.packetize(&src, 1, 0, coder.as_ref()).unwrap();
let k = cfg.fec.max_data_per_block as usize;
let mut clamped = false;
for p in &pkts {
let hdr = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
let total = hdr.data_shards as usize + hdr.recovery_shards as usize;
assert!(
total <= lim.max_total_shards,
"block total {total} exceeds the peer's ceiling {} — every packet of this block \
would be dropped",
lim.max_total_shards
);
// The unclamped 50% would put 2 parity on a full block; the negotiated 10% ceiling
// leaves room for 1. Proves the clamp actually bound rather than passing vacuously.
if hdr.data_shards as usize == k {
assert!(
(hdr.recovery_shards as usize) < cfg.fec.recovery_for(k).max(1) + 1,
"parity must be clamped to the peer's ceiling"
);
clamped = true;
}
}
assert!(clamped, "test must exercise a maximal block");
// And the frame still reassembles byte-identically.
let mut r = Reassembler::new(lim);
let stats = StatsCounters::default();
let mut got = None;
for p in &pkts {
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
got = Some(f);
}
}
assert_eq!(
got.expect("frame must complete after an adaptive-FEC ramp")
.data,
src
);
}
+2 -2
View File
@@ -38,7 +38,7 @@ pub struct ClockSkew {
/// with, so the offset aligns a client receive instant to the host's capture clock.
pub async fn clock_sync(
send: &mut quinn::SendStream,
recv: &mut io::MsgReader,
recv: &mut quinn::RecvStream,
) -> Option<ClockSkew> {
use std::time::Duration;
const ROUNDS: usize = 8;
@@ -50,7 +50,7 @@ pub async fn clock_sync(
if io::write_msg(send, &probe).await.is_err() {
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 {
Ok(Ok(b)) => match ClockEcho::decode(&b) {
Ok(e) => e,
-76
View File
@@ -1,14 +1,6 @@
//! Length-prefixed framing for QUIC control-stream messages: a `u16` length header followed by the
//! payload, 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>> {
let mut len = [0u8; 2];
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)
}
/// 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.
pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io::Result<()> {
send.write_all(&super::frame(payload))
+1 -82
View File
@@ -1585,7 +1585,7 @@ mod clip_loopback {
/// Stand up two loopback quinn endpoints, connect, and return
/// `(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.
pub(super) async fn connect_pair() -> (
async fn connect_pair() -> (
quinn::Endpoint,
quinn::Endpoint,
quinn::Connection,
@@ -1731,84 +1731,3 @@ mod clip_loopback {
let _host_conn = holder.await.unwrap();
}
}
/// The control stream is read from a `select!` arm on both peers, so the read future is dropped
/// routinely — and quinn documents `read_exact` (what `io::read_msg` uses) as NOT cancel-safe.
/// [`io::MsgReader`] must survive that: the partial frame lives in the reader, not the future.
mod ctrl_framing {
use super::clip_loopback::connect_pair;
use super::*;
use crate::quic::io;
/// A frame whose halves land in different wakeups, with the read cancelled in between, must
/// still be delivered whole — and the NEXT frame must decode correctly too. Without a
/// resumable reader the consumed length prefix is lost, the following read takes two payload
/// bytes as a length, and every later control message is garbage for the rest of the session.
#[tokio::test]
async fn cancelled_mid_frame_read_resumes_without_desync() {
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
let first = b"the-frame-that-straddles-two-wakeups".to_vec();
let second = b"the-frame-after-it".to_vec();
let (f1, f2) = (first.clone(), second.clone());
let writer = tokio::spawn(async move {
let (mut send, _recv) = host_conn.open_bi().await.expect("open bi");
let framed = crate::quic::frame(&f1);
// Length prefix + only part of the payload, then a real pause: this is the ClipOffer
// -sized frame split across two QUIC packets that made the bug reachable.
let split = 2 + f1.len() / 3;
send.write_all(&framed[..split]).await.expect("write head");
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
send.write_all(&framed[split..]).await.expect("write tail");
send.write_all(&crate::quic::frame(&f2))
.await
.expect("write second");
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
host_conn
});
let (_send, recv) = client_conn.accept_bi().await.expect("accept bi");
let mut reader = io::MsgReader::new(recv);
// Cancel mid-frame — exactly what a sibling `select!` arm does.
let cancelled =
tokio::time::timeout(std::time::Duration::from_millis(30), reader.read_msg()).await;
assert!(
cancelled.is_err(),
"the head-only frame must not complete yet (test setup)"
);
let got = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read_msg())
.await
.expect("first frame must arrive after resuming")
.expect("first frame reads cleanly");
assert_eq!(got, first, "the cancelled read must resume, not lose bytes");
let got2 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read_msg())
.await
.expect("second frame must arrive")
.expect("second frame reads cleanly");
assert_eq!(got2, second, "stream must still be framed correctly");
let _host_conn = writer.await.unwrap();
}
/// A zero-length frame is a legal encoding and must not stall the reader or eat the next one.
#[tokio::test]
async fn zero_length_frame_round_trips() {
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
let writer = tokio::spawn(async move {
let (mut send, _recv) = host_conn.open_bi().await.expect("open bi");
send.write_all(&crate::quic::frame(&[])).await.unwrap();
send.write_all(&crate::quic::frame(b"after")).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
host_conn
});
let (_send, recv) = client_conn.accept_bi().await.expect("accept bi");
let mut reader = io::MsgReader::new(recv);
assert!(reader.read_msg().await.unwrap().is_empty());
assert_eq!(reader.read_msg().await.unwrap(), b"after");
let _host_conn = writer.await.unwrap();
}
}
+3 -23
View File
@@ -30,14 +30,6 @@ pub struct Frame {
/// ([`crate::packet::USER_FLAG_CHUNK_ALIGNED`]) are ever delivered partial; missing
/// shard ranges are zero-filled at their exact offsets.
pub complete: bool,
/// Wall-clock instant (ns since the Unix epoch, CLOCK_REALTIME basis — the same clock the
/// skew handshake compares and the host stamps `pts_ns` with) at which this AU finished
/// reassembly, stamped by [`Session::poll_frame`] as the frame leaves the session. Embedders
/// that previously stamped receipt themselves at the hand-off pull should use this instead:
/// the pull stamp additionally contains the pre-decode queue wait, silently folding any
/// client-side standing backlog into the apparent NETWORK latency. The reassembler itself
/// leaves this 0 (it owns no clock — the stamp is the session boundary's job).
pub received_ns: u64,
}
/// One end of a stream. Constructed for a single [`Role`]; calling the other role's
@@ -90,18 +82,6 @@ pub struct Session {
lane_scratch: Vec<Vec<u8>>,
}
/// Stamp [`Frame::received_ns`] as the frame crosses the session boundary in
/// [`Session::poll_frame`] — completed frames return the moment their last shard lands, so
/// stamping at return IS stamping at reassembly completion (µs apart). CLOCK_REALTIME to match
/// `pts_ns` / the skew handshake (deliberately not monotonic — cross-machine latency math).
fn stamp_received(mut f: Frame) -> Frame {
f.received_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
f
}
/// Wire-packet count at which a frame's sealing splits across two lanes (plan Phase 1.5):
/// below it the channel rendezvous (~µs) isn't worth it; at it the halved AES-GCM span
/// (≥ ~125 µs of ~1 µs/packet work) dwarfs the hand-off. ≈300 KB of wire, i.e. ≥150 Mbps
@@ -704,7 +684,7 @@ impl Session {
// Nothing new on the wire — hand over an aged-out partial if one is
// waiting (it can only get staler).
if let Some(p) = self.reassembler.take_partial() {
return Ok(stamp_received(p));
return Ok(p);
}
return Err(PunktfunkError::NoFrame);
}
@@ -768,12 +748,12 @@ impl Session {
}
if let Some(frame) = pushed {
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
// ahead of further draining (its successors are already arriving).
if let Some(p) = self.reassembler.take_partial() {
return Ok(stamp_received(p));
return Ok(p);
}
}
}
+9 -132
View File
@@ -110,18 +110,8 @@ pub fn spawn_data_punch(sock: UdpSocket, stop: std::sync::Arc<std::sync::atomic:
.spawn(move || {
let mut i = 0u32;
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
match sock.send(PUNCH_MAGIC) {
Ok(_) => {}
// 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;
}
if sock.send(PUNCH_MAGIC).is_err() {
break;
}
let delay_ms = if i < 15 { 200 } else { 2000 };
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
/// 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)`.
///
/// `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(
local: &str,
fallback_peer: &str,
expect_ip: std::net::IpAddr,
punch_timeout: std::time::Duration,
) -> std::io::Result<(Self, bool)> {
Self::from_socket_punch(
UdpSocket::bind(local)?,
fallback_peer,
expect_ip,
punch_timeout,
)
Self::from_socket_punch(UdpSocket::bind(local)?, fallback_peer, punch_timeout)
}
/// [`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.
///
/// `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(
socket: UdpSocket,
fallback_peer: &str,
expect_ip: std::net::IpAddr,
punch_timeout: std::time::Duration,
) -> std::io::Result<(Self, bool)> {
socket.set_read_timeout(Some(punch_timeout))?;
let deadline = std::time::Instant::now() + punch_timeout;
let mut buf = [0u8; 64];
let mut observed: Option<std::net::SocketAddr> = None;
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) {
Ok((n, src))
if src.ip() == expect_ip
&& n >= PUNCH_MAGIC.len()
&& &buf[..PUNCH_MAGIC.len()] == PUNCH_MAGIC =>
if n >= PUNCH_MAGIC.len() && &buf[..PUNCH_MAGIC.len()] == PUNCH_MAGIC =>
{
observed = Some(src);
break;
}
// Stray, or a well-formed punch from someone who isn't the authenticated peer —
// keep waiting for a real one.
Ok(_) => {}
Ok(_) => {} // stray datagram — keep waiting for a real punch
Err(e)
if matches!(
e.kind(),
@@ -239,6 +198,9 @@ impl UdpTransport {
}
Err(e) => return Err(e),
}
if std::time::Instant::now() >= deadline {
break;
}
}
let punched = observed.is_some();
let target = observed.map(|s| s.to_string());
@@ -520,89 +482,4 @@ mod tests {
"every datagram should be drained via recv_batch"
);
}
/// The punch discovers the peer's NAT-remapped *port*, so a punch from the authenticated IP on
/// a port that differs from the client-reported one must still be adopted — that is the whole
/// reason hole-punching exists, and the source-IP check must not break it.
#[test]
fn punch_adopts_remapped_port_from_the_authenticated_peer() {
// Stands in for the client's post-NAT data socket: same IP as the "QUIC peer", new port.
let puncher = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
puncher
.set_read_timeout(Some(std::time::Duration::from_millis(500)))
.unwrap();
// The client-*reported* address, which the NAT remapped — video must NOT go here.
let reported = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
reported
.set_read_timeout(Some(std::time::Duration::from_millis(200)))
.unwrap();
let host_sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let host_addr = host_sock.local_addr().unwrap();
puncher.send_to(PUNCH_MAGIC, host_addr).unwrap();
let (transport, punched) = UdpTransport::from_socket_punch(
host_sock,
&reported.local_addr().unwrap().to_string(),
std::net::IpAddr::from([127, 0, 0, 1]),
std::time::Duration::from_millis(500),
)
.unwrap();
assert!(punched, "a punch from the authenticated IP must be adopted");
transport.send(b"video").unwrap();
let mut buf = [0u8; 64];
let n = puncher
.recv(&mut buf)
.expect("video must follow the punched (NAT-remapped) port");
assert_eq!(&buf[..n], b"video");
assert!(
reported.recv(&mut buf).is_err(),
"video must not go to the stale reported port"
);
}
/// A punch from any source other than the QUIC-authenticated peer must be ignored: `PUNCH_MAGIC`
/// is a fixed public constant with no key or session id, so honouring an off-peer punch lets
/// anyone who lands an 8-byte datagram on the ephemeral data port steal (or redirect) the video
/// plane while the control plane stays healthy. Falling back to the reported address is correct.
#[test]
fn punch_from_an_unauthenticated_source_is_ignored() {
let attacker = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
attacker
.set_read_timeout(Some(std::time::Duration::from_millis(200)))
.unwrap();
let legit = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
legit
.set_read_timeout(Some(std::time::Duration::from_millis(500)))
.unwrap();
let host_sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let host_addr = host_sock.local_addr().unwrap();
attacker.send_to(PUNCH_MAGIC, host_addr).unwrap();
// The authenticated peer is TEST-NET-1, so nothing arriving over loopback is the peer.
let (transport, punched) = UdpTransport::from_socket_punch(
host_sock,
&legit.local_addr().unwrap().to_string(),
std::net::IpAddr::from([192, 0, 2, 1]),
std::time::Duration::from_millis(300),
)
.unwrap();
assert!(
!punched,
"an off-peer punch must not be adopted as the video destination"
);
transport.send(b"video").unwrap();
let mut buf = [0u8; 64];
assert!(
attacker.recv(&mut buf).is_err(),
"video must never be redirected to the punch source"
);
let n = legit
.recv(&mut buf)
.expect("video falls back to the reported peer address");
assert_eq!(&buf[..n], b"video");
}
}
+6 -42
View File
@@ -168,29 +168,6 @@ fn main() {
}
}
/// A lightweight management/CLI subcommand — package/service/driver ops, spec/library dumps — as
/// opposed to a streaming/capture command. These never touch DXGI or run the host, so they skip the
/// startup banner and (on Windows) the GPU-preference hook, whose DPI-awareness probe otherwise
/// prints an alarming `SetProcessDpiAwarenessContext … "access denied"` WARN on a plain
/// `plugins add`. `service run` is the SCM-launched host itself, so it is explicitly NOT lightweight
/// (it must keep the hook — the hybrid-GPU ACCESS_LOST fix depends on it).
fn is_management_cli(args: &[String]) -> bool {
match args.first().map(String::as_str) {
Some("plugins")
| Some("driver")
| Some("web")
| Some("openapi")
| Some("library")
| Some("detect-conflicts")
| Some("-h")
| Some("--help")
| Some("help")
| None => true,
Some("service") => args.get(1).map(String::as_str) != Some("run"),
_ => false,
}
}
fn real_main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -203,16 +180,11 @@ fn real_main() -> Result<()> {
return Ok(());
}
// Lightweight CLI commands (e.g. `plugins add`) get none of the host-startup noise below.
let management_cli = is_management_cli(&args);
if !management_cli {
tracing::info!(
"punktfunk-host {} (punktfunk_core ABI v{})",
env!("PUNKTFUNK_VERSION"),
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
// 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
// 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.
// 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")]
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,
// 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() {
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
// 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
-11
View File
@@ -56,10 +56,6 @@ pub struct Options {
/// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated,
/// which [`run`] only permits on loopback binds.
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 {
@@ -67,7 +63,6 @@ impl Default for Options {
Options {
bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
token: None,
plugin_token: None,
}
}
}
@@ -86,9 +81,6 @@ pub(crate) struct MgmtState {
/// (native-only) host, where a Moonlight PIN can never arrive.
gamestream_enabled: bool,
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.
port: u16,
}
@@ -127,7 +119,6 @@ pub async fn run(
let app = app(
state,
Some(token),
opts.plugin_token.filter(|t| !t.trim().is_empty()),
opts.bind.port(),
native,
stats,
@@ -140,7 +131,6 @@ pub async fn run(
fn app(
state: Arc<AppState>,
token: Option<String>,
plugin_token: Option<String>,
port: u16,
native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
@@ -152,7 +142,6 @@ fn app(
stats,
gamestream_enabled,
token,
plugin_token,
port,
});
let (api_routes, api) = api_router_parts();
+1 -54
View File
@@ -1,12 +1,5 @@
//! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere)
//! or a 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.
//! or the bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
use crate::gamestream::tls::PeerAddr;
@@ -93,27 +86,6 @@ pub(crate) async fn require_auth(
.and_then(|v| v.strip_prefix("Bearer "));
match presented {
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(
StatusCode::UNAUTHORIZED,
"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
/// 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
-131
View File
@@ -42,8 +42,6 @@ fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
app(
state,
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,
None,
stats,
@@ -59,7 +57,6 @@ fn test_app_native(state: Arc<AppState>, np: Arc<crate::native_pairing::NativePa
app(
state,
Some("test-secret".to_string()),
Some("plugin-secret".to_string()),
DEFAULT_PORT,
Some(np),
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]
async fn host_info_reports_identity_and_ports() {
let app = test_app(test_state(), None);
@@ -674,7 +544,6 @@ async fn blank_token_rejected() {
let opts = Options {
bind: "127.0.0.1:0".parse().unwrap(),
token: Some(" ".into()),
plugin_token: None,
};
let err = run(test_state(), opts, None, test_stats(), false)
.await
+29 -53
View File
@@ -1,19 +1,12 @@
//! Management-API bearer token resolution.
//!
//! 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
//! (operator override, not persisted); otherwise the persisted file under the config dir is used;
//! otherwise a fresh 32-byte hex token is generated and persisted. Files are written in
//! `KEY=<hex>` form (0600) so the bundled web console can source them directly as a systemd
//! `EnvironmentFile` — a single source of truth shared between the host and its consumers.
//!
//! 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.
//! on a loopback bind. This module guarantees a token always exists: an explicit
//! `PUNKTFUNK_MGMT_TOKEN` env wins (operator override, not persisted); otherwise the persisted
//! `~/.config/punktfunk/mgmt-token` is used; otherwise a fresh 32-byte hex token is generated and
//! persisted. The file is written in `PUNKTFUNK_MGMT_TOKEN=<hex>` form (0600) so the bundled web
//! console can source it directly as a systemd `EnvironmentFile` — a single source of truth shared
//! between the host and the console with no copying.
use anyhow::{Context, Result};
use rand::RngCore;
@@ -22,32 +15,19 @@ use std::path::Path;
const ENV_VAR: &str = "PUNKTFUNK_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)
/// so the persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
/// Resolve the mgmt token (env > persisted file > generate+persist). Hex (not base64) so the
/// persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
pub fn load_or_generate() -> Result<String> {
load_or_generate_impl(ENV_VAR, FILE)
}
/// 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) {
if let Ok(v) = std::env::var(ENV_VAR) {
let v = v.trim();
if !v.is_empty() {
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 Some(tok) = parse_token(&contents, env_var) {
if let Some(tok) = parse_token(&contents) {
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();
// 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()))?;
write_token(&path, env_var, &token)?;
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
write_token(&path, &token)?;
tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
Ok(token)
}
/// 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).
fn parse_token(contents: &str, env_var: &str) -> Option<String> {
/// `PUNKTFUNK_MGMT_TOKEN=<token>` line (the form we write, also valid as an EnvironmentFile).
fn parse_token(contents: &str) -> Option<String> {
let line = contents.lines().find(|l| !l.trim().is_empty())?.trim();
let tok = line
.strip_prefix(env_var)
.and_then(|rest| rest.strip_prefix('='))
.strip_prefix("PUNKTFUNK_MGMT_TOKEN=")
.unwrap_or(line)
.trim();
(!tok.is_empty()).then(|| tok.to_string())
}
/// Write `<KEY>=<token>` to `path` as an owner-only secret — 0600 on Unix AND DACL-locked to
/// SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so both bearer
/// tokens get the SAME Windows lockdown as the host key; the bespoke `cfg(unix)`-only writer used
/// to leave the mgmt token readable by any local user (security-review 2026-06-28 #2).
fn write_token(path: &Path, env_var: &str, token: &str) -> Result<()> {
let line = format!("{env_var}={token}\n");
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path` as an owner-only secret — 0600 on Unix AND
/// DACL-locked to SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so
/// the mgmt bearer token (full admin authority) gets the SAME Windows lockdown as the host key; the
/// bespoke `cfg(unix)`-only writer used to leave it readable by any local user (security-review
/// 2026-06-28 #2).
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())
.with_context(|| format!("write {}", path.display()))
}
@@ -90,17 +70,13 @@ mod tests {
#[test]
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!(
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n", ENV_VAR).as_deref(),
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n").as_deref(),
Some("deadbeef")
);
assert_eq!(
parse_token("PUNKTFUNK_PLUGIN_TOKEN=deadbeef\n", PLUGIN_ENV_VAR).as_deref(),
Some("deadbeef")
);
assert_eq!(parse_token("\n \n", ENV_VAR), None);
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n", ENV_VAR), None);
assert_eq!(parse_token("\n \n"), None);
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n"), None);
}
#[test]
@@ -108,9 +84,9 @@ mod tests {
let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
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();
assert_eq!(parse_token(&read, ENV_VAR).as_deref(), Some("cafef00d"));
assert_eq!(parse_token(&read).as_deref(), Some("cafef00d"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
-4
View File
@@ -1265,10 +1265,6 @@ async fn serve_session(
UdpTransport::from_socket_punch(
data_sock,
&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),
)
};
+2 -6
View File
@@ -16,7 +16,7 @@ use punktfunk_core::quic::{ClipControl, ClipOffer, ClipState};
#[allow(clippy::too_many_arguments)]
pub(super) async fn run(
mut ctrl_send: quinn::SendStream,
ctrl_recv: quinn::RecvStream,
mut ctrl_recv: quinn::RecvStream,
initial_mode: punktfunk_core::Mode,
codec: crate::encode::Codec,
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).
const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
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 {
tokio::select! {
msg = ctrl_reader.read_msg() => {
msg = io::read_msg(&mut ctrl_recv) => {
let Ok(msg) = msg else { break }; // stream closed
if let Ok(req) = Reconfigure::decode(&msg) {
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 {
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;
bitrate_kbps = new_kbps;
live_bitrate.store(new_kbps, Ordering::Relaxed);
@@ -2268,9 +2265,6 @@ fn try_inplace_resize(
if let Some(c) = plan.wire_chunk {
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;
*frame = new_frame;
*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 {
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
// 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).
+9 -230
View File
@@ -14,16 +14,9 @@
//! package present.
//!
//! 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
//! 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 std::process::Command;
@@ -78,15 +71,13 @@ USAGE:
NAMES:
A bare first-party name resolves into the @punktfunk scope: `playnite` installs
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager
always from Punktfunk's own package registry. Any other name (`punktfunk-plugin-*`,
a foreign @scope) installs from the PUBLIC npm registry and is refused unless you
pass --allow-public-registry.
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager.
A scoped (@scope/pkg) or `punktfunk-plugin-*` name is used verbatim.
NOTES:
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
privileges; install only plugins you trust.
turns the runner on. Plugins are operator-installed code that runs as the host user;
install only plugins you trust.
"
);
#[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")]
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!(
"Enable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null; \
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
))?;
println!("Plugin runner enabled and started ({TASK}, runs as LocalService).");
println!("Plugin runner enabled and started ({TASK}).");
Ok(())
}
@@ -290,173 +228,17 @@ fn disable() -> Result<()> {
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
Disable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null"
))?;
revoke_runner_secret_reads();
println!("Plugin runner stopped and disabled ({TASK}).");
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")]
fn status() -> Result<()> {
let out = powershell_output(&format!(
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
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) {
Some("missing") | None => {
@@ -466,10 +248,7 @@ fn status() -> Result<()> {
);
}
Some(state) => {
// "State|Principal" — the principal line makes the SYSTEM→LocalService migration
// 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}");
println!("runner: {TASK}\nstate: {state}");
if state.eq_ignore_ascii_case("Disabled") {
println!("\nEnable it with: punktfunk-host plugins enable");
}
-7
View File
@@ -157,13 +157,6 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
`POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at
[`/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)
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
+6 -9
View File
@@ -31,9 +31,8 @@ punktfunk-host plugins enable # turn the runner on (once)
<Tab value="Windows">
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
itself runs as the low-privilege `NT AUTHORITY\LocalService` account — `plugins enable` sets that
up (including read access to the runner's scoped API token).
The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned, and the runner
task runs as SYSTEM.
```powershell
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 status` | Is the runner enabled and running? |
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
`@scope/pkg`) would install from the **public npm registry** and is refused unless you add
`--allow-public-registry` — a guard against typos and look-alike packages pulling untrusted code
onto your host.
A bare name resolves to the first-party package — `playnite` installs `@punktfunk/plugin-playnite`.
A scoped (`@scope/pkg`) or `punktfunk-plugin-*` name is used verbatim, so third-party plugins work
the same way.
> 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.
## ROM Manager
+1 -12
View File
@@ -37,12 +37,7 @@
// `punktfunk_connection_clipboard_{control,offer,fetch,serve,cancel}` +
// `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.
// v9: `PunktfunkFrame` grew `received_ns` — the reassembly-completion receipt stamp, so
// 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
#define ABI_VERSION 8
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -1117,12 +1112,6 @@ typedef struct {
uint32_t frame_index;
uint64_t pts_ns;
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;
// A single input event. `#[repr(C)]` — shared verbatim with the C ABI as
+3 -7
View File
@@ -291,17 +291,13 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
#endif
#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:
; punktfunk-host plugins enable
; 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.
; Enable-ScheduledTask -TaskName PunktfunkScripting
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
; in the command, so no Inno {{ }} escaping needed.
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
#endif
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
+2 -17
View File
@@ -10,10 +10,8 @@
# you add scripts or install plugins. Turn it on once you have automation to run:
# systemctl --user enable --now punktfunk-scripting
#
# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity
# cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env
# 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.
# Auto-wired like the console: a plugin's connect() reads the host's mgmt token + identity cert from
# ~/.config/punktfunk/{mgmt-token,cert.pem} (written by the host's `serve`) — no env editing.
[Unit]
Description=punktfunk plugin/script runner
Documentation=https://git.unom.io/unom/punktfunk
@@ -31,19 +29,6 @@ RestartSec=2
KillMode=mixed
KillSignal=SIGTERM
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]
WantedBy=default.target
+3 -28
View File
@@ -55,40 +55,15 @@ powershell -ExecutionPolicy Bypass -File scripts\windows\build-web.ps1
this to iterate on the console against an installed host - `punktfunk-host.exe web setup` (or a
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
```powershell
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
web console and plugin runner are **always** included, so the host binary and the runner bundle
never drift apart. If the host build/start fails, `deploy-host.ps1` rolls itself back and throws,
which stops this script before the later steps run.
Thin wrapper: runs `deploy-host.ps1` then `build-web.ps1` in sequence. If the host build/start
fails, `deploy-host.ps1` rolls itself back and throws, which stops this script before the web
console step runs.
## Typical flow after pulling new code
-191
View File
@@ -1,191 +0,0 @@
<#
Rebuild the plugin/script runner bundle from the CURRENT sdk/ source and lay it out next to the
host exe, so `punktfunk-host plugins ...` works and the PunktfunkScripting task runs new code.
powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1
powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1 -EnableTask
WHY the layout matters: the plugins CLI forwards package ops (add/remove/list) to the runner, and
on Windows it resolves the runner RELATIVE TO THE RUNNING EXE - <exe-dir>\bun\bun.exe and
<exe-dir>\scripting\runner-cli.js (crates\punktfunk-host\src\plugins.rs). deploy-host.ps1 runs the
service out of target\release, so a bundle sitting only in the installed {app} leaves the freshly
built exe reporting "the plugin runner isn't installed". We therefore deploy next to EVERY host exe
we can find: the built one, and whatever the PunktfunkHost service actually runs.
The PunktfunkScripting task ships DISABLED (opt-in). We do not silently enable it - pass
-EnableTask on a dev box you are validating plugins on.
#>
param(
[switch]$EnableTask # enable + restart PunktfunkScripting (opt-in)
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path (Split-Path $PSScriptRoot) # scripts\windows -> repo root
$sdk = Join-Path $repo 'sdk'
$task = 'PunktfunkScripting'
# bun is both the bundler AND the runtime the runner ships on. Honor $env:BUN_EXE (what CI sets)
# before falling back to the dev box's portable copy - the same one build-web.ps1 uses.
$bun = $env:BUN_EXE
if (-not ($bun -and (Test-Path $bun))) { $bun = 'C:\Users\Public\bun\bin\bun.exe' }
if (-not (Test-Path $bun)) { throw "bun not found at $bun (set `$env:BUN_EXE to override)" }
Write-Host "== punktfunk plugin/script runner deploy =="
Write-Host "bun : $bun"
# --- 1. build the self-contained bundle ------------------------------------------------------
# --target=bun inlines effect + the SDK into ONE js; the operator's plugin import stays a runtime
# import. --ignore-scripts skips the `prepare` codegen (it wants ../api/openapi.json, not needed).
$stage = Join-Path $repo 'target\scripting'
New-Item -ItemType Directory -Force -Path $stage | Out-Null
$bundle = Join-Path $stage 'runner-cli.js'
Push-Location $sdk
try {
Write-Host "bun install ..."
& $bun install --frozen-lockfile --ignore-scripts
if ($LASTEXITCODE -ne 0) { throw "sdk bun install failed (exit $LASTEXITCODE)" }
Write-Host "bun build src/runner-cli.ts -> $bundle"
& $bun build src/runner-cli.ts --target=bun "--outfile=$bundle"
if ($LASTEXITCODE -ne 0) { throw "runner bundle build failed (exit $LASTEXITCODE)" }
} finally { Pop-Location }
# Same gate CI and the .deb builder use: 'attempt=' only survives when the dynamic plugin import was
# bundled as a RUNTIME import. If it is missing the bundle would load but never run a plugin.
if (-not (Select-String -Path $bundle -Pattern 'attempt=' -Quiet)) {
throw "runner bundle missing the dynamic plugin import - wrong build"
}
Write-Host "bundle : OK ($([math]::Round((Get-Item $bundle).Length / 1KB)) KB)"
# --- 2. work out every host-exe dir that needs the payload ------------------------------------
$targets = New-Object System.Collections.Generic.List[string]
function Add-Target([string]$exe) {
if (-not $exe) { return }
$dir = Split-Path $exe
if ($dir -and (Test-Path $dir) -and -not ($targets -contains $dir)) { $targets.Add($dir) | Out-Null }
}
# a) the binary deploy-host.ps1 just built - what you invoke by hand to test the new CLI.
Add-Target (Join-Path $repo 'target\release\punktfunk-host.exe')
# b) whatever the service actually runs (an installed {app} on a box set up via setup.exe). The
# binPath carries args and may be quoted, so pull the .exe out with a regex.
$qc = & sc.exe qc 'PunktfunkHost' 2>$null
if ($qc) {
$line = $qc | Select-String 'BINARY_PATH_NAME' | Select-Object -First 1
if ($line -and "$line" -match '([A-Za-z]:\\[^"]*?punktfunk-host\.exe)') { Add-Target $Matches[1] }
}
if ($targets.Count -eq 0) { throw "no punktfunk-host.exe found - run deploy-host.ps1 first." }
# --- 3. lay out <exe-dir>\scripting\{runner-cli.js,scripting-run.cmd} + <exe-dir>\bun\bun.exe ---
# Stop a LIVE runner first: it holds bun.exe (and the bundle) open, so copying over them fails with a
# sharing violation. Step 4 restarts it. Reap stragglers by command line the way build-web.ps1 does -
# schtasks /end does not always take the child bun with it.
$existing = Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
if ($existing -and $existing.State -eq 'Running') {
Write-Host "stopping $task (holds bun.exe open) ..."
& schtasks /end /tn $task 2>$null | Out-Null
Get-CimInstance Win32_Process -Filter "Name='bun.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -match 'runner-cli\.js' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep 2
}
foreach ($dir in $targets) {
Write-Host ""
Write-Host "deploying -> $dir"
$scrDir = Join-Path $dir 'scripting'
$bunDir = Join-Path $dir 'bun'
New-Item -ItemType Directory -Force -Path $scrDir, $bunDir | Out-Null
Copy-Item $bundle (Join-Path $scrDir 'runner-cli.js') -Force
Copy-Item (Join-Path $PSScriptRoot 'scripting-run.cmd') (Join-Path $scrDir 'scripting-run.cmd') -Force
# The runner import()s the operator's .ts plugins, so bun ships WITH it rather than being assumed
# on PATH - exactly what the installer does. Skip the copy when it is already the same build.
$bunDst = Join-Path $bunDir 'bun.exe'
if (-not (Test-Path $bunDst) -or (Get-Item $bunDst).Length -ne (Get-Item $bun).Length) {
Copy-Item $bun $bunDst -Force
}
Write-Host " scripting\runner-cli.js, scripting\scripting-run.cmd, bun\bun.exe"
}
# --- 3.5 de-privilege: LocalService principal + secret read grants ----------------------------
# The runner task runs as NT AUTHORITY\LocalService (NOT SYSTEM - a plugin defect must cost a
# throwaway account). Converge a task registered as SYSTEM by an older installer or by hand, and
# grant LocalService read on the two files the runner's connect() needs: the scoped plugin-token
# and the TLS-pin cert.pem. NEVER mgmt-token (full admin). Mirrors `punktfunk-host plugins enable`.
if ($existing) {
$principal = New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount
Set-ScheduledTask -TaskName $task -Principal $principal | Out-Null
Write-Host ""
Write-Host "task : $task principal -> NT AUTHORITY\LocalService"
$cfg = Join-Path $env:ProgramData 'punktfunk'
foreach ($secret in @('plugin-token', 'cert.pem')) {
$file = Join-Path $cfg $secret
if (Test-Path $file) {
& "$env:SystemRoot\System32\icacls.exe" $file /grant:r '*S-1-5-19:(R)' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $file" }
} else {
Write-Host "note : $file not found - start the host once, then re-run (the runner"
Write-Host " needs LocalService read on it to authenticate)."
}
}
# Unit dirs get inheritable (RX,WA): bun's module loader opens unit files requesting
# FILE_WRITE_ATTRIBUTES on top of read - plain (RX) makes every import EPERM (found
# on-glass). WA can only touch timestamps/attribute bits, never content.
foreach ($unitDir in @('plugins', 'scripts')) {
$dirPath = Join-Path $cfg $unitDir
New-Item -ItemType Directory -Force -Path $dirPath | Out-Null
& "$env:SystemRoot\System32\icacls.exe" $dirPath /grant:r '*S-1-5-19:(OI)(CI)(RX,WA)' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $dirPath" }
}
# State root gets inheritable Modify - the ONE writable grant, so a plugin can persist its
# config/cache under plugin-state\<name> (@punktfunk/host's pluginStateDir). Code dirs stay
# RX+WA, secrets stay R; only this dir is writable.
$stateDir = Join-Path $cfg 'plugin-state'
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
& "$env:SystemRoot\System32\icacls.exe" $stateDir /grant:r '*S-1-5-19:(OI)(CI)(M)' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $stateDir" }
# Ingest inbox gets inheritable Modify for BUILTIN\Users - the INVERSE grant, so an
# interactive-user app (the Playnite exporter) can drop ingest\<plugin>\ data the LocalService
# runner reads. The one Users-writable carve-out in the otherwise Users-read-only tree.
$ingestDir = Join-Path $cfg 'ingest'
New-Item -ItemType Directory -Force -Path $ingestDir | Out-Null
& "$env:SystemRoot\System32\icacls.exe" $ingestDir /grant:r '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $ingestDir" }
}
# --- 4. the opt-in scheduled task -------------------------------------------------------------
# Registered DISABLED by the installer; the runner is inert until there is automation to run. We only
# bounce it when it already exists, and only enable it when explicitly asked. ($existing was captured
# in step 3, BEFORE we stopped a running instance - so State still reflects how we found the box.)
Write-Host ""
if (-not $existing) {
Write-Host "note : the $task task is not registered on this box (installer registers it)."
Write-Host " The plugins CLI still works - it runs the runner directly."
}
elseif ($EnableTask) {
if ($existing.State -eq 'Disabled') {
Enable-ScheduledTask -TaskName $task | Out-Null
Write-Host "task : $task ENABLED (-EnableTask)"
}
& schtasks /end /tn $task 2>$null | Out-Null
Start-Sleep 2
& schtasks /run /tn $task | Out-Null
Write-Host "task : $task restarted on the new bundle"
}
elseif ($existing.State -eq 'Disabled') {
Write-Host "note : $task is DISABLED (opt-in, as shipped) - the new bundle is staged but no"
Write-Host " runner is supervising plugins. Enable it for on-glass validation with:"
Write-Host " powershell -File scripts\windows\build-scripting.ps1 -EnableTask"
Write-Host " (or: punktfunk-host plugins enable)"
}
else {
& schtasks /end /tn $task 2>$null | Out-Null
Start-Sleep 2
& schtasks /run /tn $task | Out-Null
Write-Host "task : $task restarted on the new bundle"
}
Write-Host ""
Write-Host "DONE - plugin/script runner deployed."
+7 -21
View File
@@ -1,40 +1,26 @@
<#
Rebuild + redeploy everything: the Windows host service, the web management console AND the
plugin/script runner. Thin wrapper around deploy-host.ps1 + build-web.ps1 + build-scripting.ps1 -
see scripts\windows\README.md for what each one does on its own (rollback behavior, build env, etc).
Rebuild + redeploy everything: the Windows host service AND the web management console.
Thin wrapper around deploy-host.ps1 + build-web.ps1 - see scripts\windows\README.md for what
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 -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
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'
$here = $PSScriptRoot
Write-Host "=========================================="
Write-Host " 1/3 host service"
Write-Host " 1/2 host service"
Write-Host "=========================================="
& (Join-Path $here 'deploy-host.ps1')
Write-Host ""
Write-Host "=========================================="
Write-Host " 2/3 web console"
Write-Host " 2/2 web console"
Write-Host "=========================================="
& (Join-Path $here 'build-web.ps1')
Write-Host ""
Write-Host "=========================================="
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."
Write-Host "DONE - host + web console redeployed."
+2 -4
View File
@@ -6,10 +6,8 @@ rem Enable it once you have scripts/plugins: Enable-ScheduledTask -TaskName Pun
rem
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 %ProgramData%\punktfunk\{scripts,plugins}; a plugin's connect() auto-wires to the host's SCOPED
rem plugin-token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). The
rem task runs as NT AUTHORITY\LocalService - `plugins enable` grants it read on exactly those two
rem files. No env editing.
rem %ProgramData%\punktfunk\{scripts,plugins}; a plugin's connect() auto-wires to the host's mgmt
rem token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). No env editing.
setlocal EnableExtensions
set "BUN=%~dp0..\bun\bun.exe"
+4 -47
View File
@@ -106,7 +106,7 @@ Plus a real-world recipe:
| What | Source |
|---|---|
| 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` |
`<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;
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
surface — status, library, sessions, events, the plugin UI lease — but deliberately **not** hook
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).
The bearer token is the host's **admin** credential and is honored from loopback only — run
scripts on the host box (or through an SSH tunnel).
## 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.
### 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 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
`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
runner's `plugins enable` grants its LocalService principal exactly that read).
`%ProgramData%\punktfunk\mgmt-token` — run the task as an account that can).
## Compatibility
+5 -57
View File
@@ -2,17 +2,9 @@
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
// needs zero configuration.
//
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
// PUNKTFUNK_MGMT_TOKEN (admin override), else PUNKTFUNK_PLUGIN_TOKEN,
// else <config_dir>/plugin-token, else <config_dir>/mgmt-token
// 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.
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
// PUNKTFUNK_MGMT_TOKEN (else <config_dir>/mgmt-token)
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
//
// 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,
@@ -52,47 +44,6 @@ export const configDir = (): string => {
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 => {
try {
return fs.readFileSync(p, "utf8");
@@ -122,14 +73,11 @@ export const resolveConfig = async (
const token =
options?.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")) ?? "");
if (!token) {
throw new Error(
"no management token: set PUNKTFUNK_PLUGIN_TOKEN (or PUNKTFUNK_MGMT_TOKEN), pass " +
"{ token }, or run where the host's token files exist " +
`(${path.join(configDir(), "plugin-token")})`,
"no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " +
`the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
);
}
const caPath = process.env.PUNKTFUNK_MGMT_CA;
-4
View File
@@ -30,10 +30,6 @@ import {
export type { HostApi } from "./api.js";
export { HttpStatusError } from "./core.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 {
type PluginUiHandle,
type PluginUiOptions,
+13 -62
View File
@@ -13,47 +13,21 @@ export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
/** Where plugin packages install: `<config_dir>/plugins` (matches runner.ts discovery). */
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
* `@punktfunk` scope (`playnite` `@punktfunk/plugin-playnite`, `rom-manager`
* `@punktfunk/plugin-rom-manager`); an `@punktfunk/…` name is used verbatim. Anything else
* the unscoped `punktfunk-plugin-…` convention, foreign scopes, registry paths resolves on
* the public registry and is refused unless [`ResolveOptions.allowPublicRegistry`] is set.
* `@punktfunk/plugin-rom-manager`); a scoped name (`@…/…`), the unscoped plugin convention
* (`punktfunk-plugin-…`), or any name with a `/` is used verbatim.
*/
export const resolvePackage = (
name: string,
opts: ResolveOptions = {},
): string => {
export const resolvePackage = (name: string): string => {
const n = name.trim();
if (!n) throw new Error("empty plugin name");
if (!n.startsWith("@") && !n.includes("/") && !n.startsWith("punktfunk-plugin-")) {
return `@punktfunk/plugin-${n}`; // bare first-party name
}
if (n.startsWith("@punktfunk/")) return n; // first-party scope, pinned to our registry
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;
if (n.startsWith("@")) return n; // already scoped, e.g. @punktfunk/plugin-playnite
if (n.startsWith("punktfunk-plugin-")) return n; // unscoped plugin convention, verbatim
if (n.includes("/")) return n; // some other registry path — trust it
return `@punktfunk/plugin-${n}`; // bare first-party name
};
/** 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. */
export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
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`. */
dir?: string;
/** 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}`);
// `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.
const args = [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, {
const res = Bun.spawnSync([process.execPath, action, ...pkgs], {
cwd: dir,
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. */
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
const pkgs = names.map((n) => resolvePackage(n, 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);
};
export const addPlugins = (names: string[], opts: PkgOpts = {}): void =>
runBun("add", names.map(resolvePackage), opts);
/** Uninstall one or more plugins by friendly name or package. Removal is always safe a name
* never gates on the registry it once came from. */
/** Uninstall one or more plugins by friendly name or package. */
export const removePlugins = (names: string[], opts: PkgOpts = {}): void =>
runBun(
"remove",
names.map((n) => resolvePackage(n, { allowPublicRegistry: true })),
opts,
);
runBun("remove", names.map(resolvePackage), opts);
export interface InstalledPlugin {
/** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */
+2 -9
View File
@@ -8,9 +8,7 @@
//
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
// here):
// add <name…> install first-party plugins (playnite, rom-manager); anything resolving on
// the PUBLIC npm registry (punktfunk-plugin-*, foreign scopes) additionally
// needs --allow-public-registry
// add <name…> install first-party (playnite, rom-manager) or punktfunk-plugin-* packages
// remove <name…> uninstall
// list list installed plugin packages
//
@@ -46,12 +44,7 @@ const positionals = (): string[] => {
return out;
};
const pkgOpts = {
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 pkgOpts = { dir: options.pluginsDir };
const runPkgOp = (
op: (names: string[], o: typeof pkgOpts) => void,
+3 -213
View File
@@ -13,15 +13,13 @@
// background work is invisible to supervision; export a plugin to be supervised).
//
// 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
// could have written is refused loudly (mode bits on Unix, owner + DACL on Windows).
// The same sshd rule as hooks applies: a world-writable unit file is refused loudly.
import {
Cause,
Duration,
Effect,
Schedule,
} from "effect";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
@@ -55,217 +53,9 @@ export interface Unit {
const defaultLog = (line: string) =>
console.log(`${new Date().toISOString()} ${line}`);
// ---- unit-file trust (the sshd rule, both halves) ---------------------------------------------
// 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.
*/
/** The sshd rule (RFC §9.1/§9.4): refuse group/world-writable unit files, loudly. */
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 {
const mode = fs.statSync(file).mode & 0o022;
if (mode !== 0) {
-50
View File
@@ -1,50 +0,0 @@
// Connection/config resolution helpers. `pluginStateDir` is the writable location a supervised
// plugin persists into — the one dir the de-privileged Windows runner may write.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as path from "node:path";
import { pluginIngestDir, pluginStateDir } from "../src/config.js";
describe("pluginStateDir", () => {
let saved: string | undefined;
beforeEach(() => {
saved = process.env.PUNKTFUNK_CONFIG_DIR;
});
afterEach(() => {
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
});
test("resolves <config_dir>/plugin-state[/name] and honors the config-dir override", () => {
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg");
expect(pluginStateDir()).toBe(path.join("/tmp", "pf-cfg", "plugin-state"));
expect(pluginStateDir("rom-manager")).toBe(
path.join("/tmp", "pf-cfg", "plugin-state", "rom-manager"),
);
});
test("the per-plugin dir is nested under the shared root", () => {
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg2");
expect(pluginStateDir("x").startsWith(pluginStateDir())).toBe(true);
});
});
describe("pluginIngestDir", () => {
let saved: string | undefined;
beforeEach(() => {
saved = process.env.PUNKTFUNK_CONFIG_DIR;
});
afterEach(() => {
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
});
test("resolves <config_dir>/ingest[/name], distinct from plugin-state", () => {
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg3");
expect(pluginIngestDir()).toBe(path.join("/tmp", "pf-cfg3", "ingest"));
expect(pluginIngestDir("playnite")).toBe(
path.join("/tmp", "pf-cfg3", "ingest", "playnite"),
);
// the inbox (Users-write) is a different tree from state (LocalService-write)
expect(pluginIngestDir("playnite")).not.toBe(pluginStateDir("playnite"));
});
});
+3 -19
View File
@@ -37,28 +37,12 @@ describe("resolvePackage", () => {
expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager");
});
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(
"@punktfunk/plugin-playnite",
);
});
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",
);
expect(resolvePackage("@someone/plugin-x")).toBe("@someone/plugin-x");
expect(resolvePackage("punktfunk-plugin-custom")).toBe("punktfunk-plugin-custom");
});
test("trims and rejects empty", () => {
+1 -86
View File
@@ -5,12 +5,7 @@ import { afterAll, describe, expect, test } from "bun:test";
import { Effect, Fiber } from "effect";
import * as fs from "node:fs";
import * as path from "node:path";
import {
discoverUnits,
runner,
superviseUnit,
windowsSddlUnsafeReason,
} from "../src/runner.js";
import { discoverUnits, runner, superviseUnit } from "../src/runner.js";
const TOKEN = "runner-token";
// 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", () => {
test("async-fn plugin runs with a facade client; clean return completes", async () => {
const server = mockHost();