Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a53369bf21 | |||
| 3213c1b61c | |||
| b3adfb3a56 | |||
| 191c9a4e18 | |||
| dd558be55b | |||
| 810d918d36 | |||
| 7b2cdf5a7a | |||
| 15d51bc0ff | |||
| e9a4c4a601 | |||
| fdbfa37e43 | |||
| 73ec6ed9ef | |||
| 5cd6e8f572 | |||
| 7b7231fdbe | |||
| 7fe07d0fa5 | |||
| 84c47cd0a7 | |||
| 9b5ca0eff3 | |||
| ecec7cc062 | |||
| 1e8b267d93 | |||
| dafab58943 | |||
| 1dcba4dffa | |||
| a784682d4c | |||
| 134874b3c8 | |||
| 134fba1424 | |||
| fe4af1761e | |||
| 26ff005e70 | |||
| 986402f731 | |||
| 28491acf2a | |||
| b0fbb80fd5 | |||
| 6f52397342 | |||
| a38adad943 | |||
| b22d0da75b | |||
| d398e2296f | |||
| f9668b16a1 |
@@ -73,8 +73,34 @@ jobs:
|
||||
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
|
||||
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
|
||||
# there, right before the first `flatpak` network call.
|
||||
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
|
||||
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
- name: Fix container DNS (drop nss-resolve, resolve over TCP)
|
||||
run: |
|
||||
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
|
||||
# Resolve over TCP instead of UDP. The documented root cause of the flathub
|
||||
# bootstrap failures (investigated 2026-07-11, see the Tooling step) is this box's
|
||||
# Docker embedded resolver at 127.0.0.11 DROPPING UDP lookups while the shared
|
||||
# runner fleet is saturated — a datagram nobody retransmits, so the lookup just
|
||||
# times out. The answer then was to widen retry.sh's budget to 10 attempts (~9 min),
|
||||
# which is enough to outlast a main push's ~8-workflow fan-out but NOT a TAG push's
|
||||
# 13: v0.15.0 (twice) and v0.16.0 each burned all 10 attempts and failed the job,
|
||||
# each needing a manual re-run.
|
||||
#
|
||||
# `use-vc` makes glibc use TCP, where the kernel retransmits and the query cannot be
|
||||
# silently lost under load. Same resolver, same search path — only the transport
|
||||
# changes, so internal names (git.unom.io) resolve exactly as before; deliberately
|
||||
# NO extra nameservers, which would risk answering an internal name from a public
|
||||
# resolver. Docker's embedded DNS serves TCP on 127.0.0.11:53 as well as UDP.
|
||||
# retry.sh stays as the backstop for genuine upstream blips.
|
||||
#
|
||||
# Non-fatal: Docker bind-mounts /etc/resolv.conf and can present it read-only, and a
|
||||
# DNS tuning that cannot be applied must not be what fails the release build — that
|
||||
# would trade an occasional re-run for a hard stop. Falling back to UDP just restores
|
||||
# today's behaviour, which retry.sh already covers.
|
||||
if ! grep -q '^options .*use-vc' /etc/resolv.conf 2>/dev/null; then
|
||||
echo 'options use-vc timeout:3 attempts:3' >> /etc/resolv.conf \
|
||||
|| echo "::warning::could not set use-vc (read-only resolv.conf?); staying on UDP"
|
||||
fi
|
||||
cat /etc/resolv.conf || true
|
||||
|
||||
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
|
||||
# executes via the container shell (no node needed), so install node BEFORE checkout.
|
||||
|
||||
@@ -149,6 +149,26 @@ jobs:
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pin + prune Xcode DerivedData
|
||||
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
|
||||
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
|
||||
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
||||
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
||||
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
||||
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
||||
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
||||
run: |
|
||||
DD="$HOME/ci/derived-data/release"
|
||||
mkdir -p "$DD"
|
||||
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
||||
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
||||
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
||||
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
|
||||
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
|
||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
@@ -176,6 +196,7 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
@@ -273,6 +294,7 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
@@ -336,6 +358,7 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
@@ -394,6 +417,7 @@ jobs:
|
||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
|
||||
Generated
+27
-27
@@ -2159,7 +2159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2264,7 +2264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2808,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2832,7 +2832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2850,7 +2850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2871,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2894,7 +2894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2903,7 +2903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2915,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2929,11 +2929,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2961,14 +2961,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2983,7 +2983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3013,7 +3013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3025,7 +3025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3221,7 +3221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3237,7 +3237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3253,7 +3253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3268,7 +3268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3287,7 +3287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3318,7 +3318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3400,7 +3400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3414,7 +3414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3437,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.14.0"
|
||||
"version": "0.16.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
|
||||
@@ -404,7 +404,14 @@ 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 {
|
||||
let received_ns = now_realtime_ns();
|
||||
// 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 mut g = in_flight
|
||||
.lock()
|
||||
|
||||
@@ -221,7 +221,13 @@ 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 {
|
||||
let received_ns = now_realtime_ns();
|
||||
// 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()
|
||||
};
|
||||
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,13 +579,21 @@ struct ContentView: View {
|
||||
model?.disconnect() // the captured-state ⌃⌥⇧D combo
|
||||
},
|
||||
onFrame: { [meter = model.meter, latency = model.latency,
|
||||
split = model.latencySplit, offset = conn.clockOffsetNs] au in
|
||||
split = model.latencySplit, queue = model.clientQueue,
|
||||
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).
|
||||
// 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...
|
||||
split.recordReceipt(
|
||||
ptsNs: au.ptsNs, receivedNs: au.receivedNs, offsetNs: offset)
|
||||
// ...which is measured as its own term instead (receipt→pull, both
|
||||
// client-local).
|
||||
queue.record(
|
||||
ptsNs: UInt64(bitPattern: au.receivedNs), atNs: au.pulledNs,
|
||||
offsetNs: 0)
|
||||
},
|
||||
onSessionEnd: { [weak model] in
|
||||
Task { @MainActor in model?.sessionEnded() }
|
||||
|
||||
@@ -102,6 +102,12 @@ 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 vend→glass pipeline depth — an OS property no client can pace under (~2 refresh
|
||||
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
|
||||
@@ -147,6 +153,9 @@ 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()
|
||||
@@ -489,6 +498,7 @@ final class SessionModel: ObservableObject {
|
||||
endToEndValid = false
|
||||
decodeValid = false
|
||||
displayValid = false
|
||||
clientQueueValid = false
|
||||
osFloorValid = false
|
||||
lostFrames = 0
|
||||
lostPct = 0
|
||||
@@ -679,6 +689,12 @@ 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)
|
||||
@@ -691,7 +707,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",
|
||||
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f",
|
||||
frames,
|
||||
displayWindow?.count ?? 0,
|
||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||
@@ -702,7 +718,8 @@ final class SessionModel: ObservableObject {
|
||||
lost,
|
||||
self.osFloorValid ? self.osFloorP50Ms : -1,
|
||||
self.displayValid ? self.displayAdjP50Ms : -1,
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1)
|
||||
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
|
||||
self.clientQueueValid ? self.clientQueueP50Ms : -1)
|
||||
statsLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,16 @@ 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,10 +35,31 @@ public struct AccessUnit: Sendable {
|
||||
public let ptsNs: UInt64
|
||||
public let frameIndex: UInt32
|
||||
public let flags: UInt32
|
||||
/// 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).
|
||||
/// 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).
|
||||
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
|
||||
@@ -662,11 +683,16 @@ 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 receivedNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
|
||||
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
|
||||
return AccessUnit(
|
||||
data: data, ptsNs: frame.pts_ns,
|
||||
frameIndex: frame.frame_index, flags: frame.flags,
|
||||
receivedNs: receivedNs)
|
||||
receivedNs: receivedNs, pulledNs: pulledNs)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
|
||||
@@ -1213,7 +1213,9 @@ public final class Stage2Pipeline {
|
||||
let chunkAligned =
|
||||
au.flags & PunktfunkConnection.userFlagChunkAligned != 0
|
||||
let ptsNs = au.ptsNs
|
||||
let receivedNs = au.receivedNs
|
||||
// Decode stage starts at the PULL (matching the VT path's FrameContext —
|
||||
// receipt→pull is the HUD's separate client-queue term, ABI v9 split).
|
||||
let receivedNs = au.pulledNs
|
||||
let flags = au.flags
|
||||
let submitted = decoder.decode(
|
||||
au: au.data, chunkAligned: chunkAligned, windowSize: windowSize
|
||||
|
||||
@@ -32,9 +32,12 @@ 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 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.
|
||||
/// 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 receipt→pull is the HUD's own
|
||||
/// client-queue term.) 0 when unknown (a caller that didn't stamp) — the decode-stage meter
|
||||
/// then drops the sample via its sanity guard.
|
||||
public let receivedNs: Int64
|
||||
/// Client `CLOCK_REALTIME` instant decode completed, in nanoseconds.
|
||||
public let decodedNs: Int64
|
||||
@@ -167,7 +170,11 @@ 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.
|
||||
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
|
||||
// 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 receipt→pull wait is the HUD's separate client-queue
|
||||
// term (see AccessUnit.pulledNs).
|
||||
let ctx = FrameContext(receivedNs: au.pulledNs, flags: au.flags)
|
||||
let refcon = Unmanaged.passRetained(ctx).toOpaque()
|
||||
let status = VTDecompressionSessionDecodeFrame(
|
||||
session,
|
||||
|
||||
+10
-12
@@ -458,7 +458,11 @@ async fn session(args: Args) -> Result<()> {
|
||||
),
|
||||
(None, None) => tracing::info!(%remote, "punktfunk/1 connected"),
|
||||
}
|
||||
let (mut send, mut recv) = conn.open_bi().await.context("open control stream")?;
|
||||
let (mut send, recv) = conn.open_bi().await.context("open control stream")?;
|
||||
// Frame every read on the control stream through the resumable reader, exactly as the client
|
||||
// pump does: `clock_sync` bounds each read with a timeout, and a frame straddling two wakeups
|
||||
// would otherwise leave the stream permanently misaligned for the rest of the run.
|
||||
let mut recv = io::MsgReader::new(recv);
|
||||
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
@@ -513,8 +517,8 @@ async fn session(args: Args) -> Result<()> {
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)
|
||||
.map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
||||
let welcome =
|
||||
Welcome::decode(&recv.read_msg().await?).map_err(|e| anyhow!("Welcome decode: {e:?}"))?;
|
||||
tracing::info!(
|
||||
mode = ?welcome.mode,
|
||||
fec = ?welcome.fec,
|
||||
@@ -629,10 +633,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
tracing::error!("Reconfigure write failed");
|
||||
return;
|
||||
}
|
||||
match io::read_msg(&mut rr)
|
||||
.await
|
||||
.map(|b| Reconfigured::decode(&b))
|
||||
{
|
||||
match rr.read_msg().await.map(|b| Reconfigured::decode(&b)) {
|
||||
Ok(Ok(ack)) if ack.accepted => {
|
||||
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
|
||||
}
|
||||
@@ -685,10 +686,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
tracing::error!("SetBitrate write failed");
|
||||
return;
|
||||
}
|
||||
match io::read_msg(&mut rr)
|
||||
.await
|
||||
.map(|b| BitrateChanged::decode(&b))
|
||||
{
|
||||
match rr.read_msg().await.map(|b| BitrateChanged::decode(&b)) {
|
||||
Ok(Ok(ack)) => tracing::info!(
|
||||
applied_kbps = ack.bitrate_kbps,
|
||||
"BITRATE CHANGE acked by host"
|
||||
@@ -750,7 +748,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
tracing::error!("ProbeRequest write failed");
|
||||
return;
|
||||
}
|
||||
let res = match io::read_msg(&mut sr).await.map(|b| ProbeResult::decode(&b)) {
|
||||
let res = match sr.read_msg().await.map(|b| ProbeResult::decode(&b)) {
|
||||
Ok(Ok(r)) => r,
|
||||
other => {
|
||||
tracing::error!(?other, "bad ProbeResult");
|
||||
|
||||
@@ -27,9 +27,13 @@ ui = ["dep:pf-console-ui", "dep:serde_json"]
|
||||
# Same Linux+Windows gating as the rest of the client stack; elsewhere this is a stub
|
||||
# binary.
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
pf-presenter = { path = "../../crates/pf-presenter" }
|
||||
# `default-features = false` on both: THIS crate's `pyrowave` feature (above) is the single
|
||||
# switch that turns the wavelet codec on, and it enables it explicitly on each. Inheriting their
|
||||
# defaults instead would make `--no-default-features` a lie — the Windows ARM64 leg builds that
|
||||
# way precisely to skip the vendored PyroWave C++, which has no ARM64 SIMD path.
|
||||
pf-presenter = { path = "../../crates/pf-presenter", default-features = false }
|
||||
pf-console-ui = { path = "../../crates/pf-console-ui", optional = true }
|
||||
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
||||
@@ -29,7 +29,17 @@ punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
# The shared client service layer: the trust/settings stores (ONE `Settings` struct for the
|
||||
# shell and the spawned session binary — src/trust.rs re-exports it) and the game-library
|
||||
# data model (fetch + art pipeline) behind the library page.
|
||||
pf-client-core = { path = "../../crates/pf-client-core" }
|
||||
#
|
||||
# `default-features = false` drops pf-client-core's default `pyrowave`, which would otherwise
|
||||
# build the vendored PyroWave C++ INTO THE SHELL — dead weight here (the shell never decodes;
|
||||
# it only offers "pyrowave" as a codec preference string the session binary acts on) and fatal
|
||||
# on ARM64, where Granite's math falls back to x86 SSE intrinsics and stops at
|
||||
# `simd.hpp: #error "Implement me."`. This does NOT drop PyroWave from the Windows client:
|
||||
# decode lives in the spawned punktfunk-session binary, whose own default enables the feature,
|
||||
# and cargo's feature unification turns it back on for the shared pf-client-core whenever that
|
||||
# binary is in the same build (x64). On the ARM64 leg both are built --no-default-features, so
|
||||
# nothing enables it and the C++ is never compiled.
|
||||
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
|
||||
|
||||
# WinUI 3 UI via windows-reactor (a declarative React-like framework backed by WinUI). Its
|
||||
# `build.rs` downloads the Windows App SDK NuGets and stages the bootstrap DLL + resources.pri
|
||||
|
||||
@@ -59,9 +59,14 @@
|
||||
</Application>
|
||||
<!--
|
||||
Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the
|
||||
desktop shell is the wrong first screen. Same full-trust executable, launched with
|
||||
`--console`, which hands straight off to the session binary's controller-driven
|
||||
browse mode (host list, pairing, settings, library) fullscreen.
|
||||
desktop shell is the wrong first screen. Its own executable (punktfunk-console.exe)
|
||||
because an MSIX Application entry cannot pass arguments to a full-trust exe; it hands
|
||||
straight off to the session binary's controller-driven browse mode (host list,
|
||||
pairing, settings, library) fullscreen.
|
||||
|
||||
NOTE: never write a double hyphen in this file. XML forbids it inside a comment, and
|
||||
makepri rejects the whole manifest ("Appx manifest not found or is invalid") — which
|
||||
is exactly how the console flag spelled out here broke the v0.15.0 MSIX build.
|
||||
-->
|
||||
<Application Id="PunktfunkConsole" Executable="punktfunk-console.exe"
|
||||
EntryPoint="Windows.FullTrustApplication">
|
||||
|
||||
@@ -1310,21 +1310,25 @@ 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_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() {
|
||||
// `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) };
|
||||
if meta.is_null() {
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
// 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.
|
||||
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
||||
(
|
||||
(*cur).id,
|
||||
@@ -1347,13 +1351,18 @@ mod pipewire {
|
||||
// Position-only update — keep the cached bitmap.
|
||||
return;
|
||||
}
|
||||
// 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 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.
|
||||
let (vfmt, bw, bh, stride, pix_off) = unsafe {
|
||||
(
|
||||
(*bmp).format,
|
||||
@@ -1369,10 +1378,27 @@ mod pipewire {
|
||||
}
|
||||
let row = bw as usize * 4;
|
||||
let stride = if stride < row { row } else { stride };
|
||||
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) };
|
||||
// `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 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 {
|
||||
@@ -2164,36 +2190,37 @@ mod pipewire {
|
||||
}
|
||||
})
|
||||
.process(|stream, ud| {
|
||||
// PipeWire dispatches this from a C trampoline with no catch_unwind; a
|
||||
// panic crossing that FFI boundary would abort the whole host. Contain it.
|
||||
// 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.
|
||||
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.
|
||||
@@ -2272,19 +2299,18 @@ mod pipewire {
|
||||
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
||||
);
|
||||
}
|
||||
// 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) };
|
||||
// Skip this stale/cursor buffer — `newest` is requeued unconditionally below.
|
||||
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,6 +1341,12 @@ 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;
|
||||
@@ -1861,6 +1867,7 @@ impl IddPushCapturer {
|
||||
cbcr,
|
||||
fence_handle,
|
||||
fence_value,
|
||||
ring_gen: self.generation,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
@@ -1919,6 +1926,7 @@ impl IddPushCapturer {
|
||||
cbcr: dst_cbcr,
|
||||
fence_handle,
|
||||
fence_value,
|
||||
ring_gen: self.generation,
|
||||
}),
|
||||
}),
|
||||
cursor: None,
|
||||
|
||||
@@ -447,8 +447,16 @@ fn pump(
|
||||
// every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream).
|
||||
match connector.next_frame(Duration::from_millis(20)) {
|
||||
Ok(frame) => {
|
||||
// The `received` point: AU fully reassembled, in hand, before decode.
|
||||
let received_ns = now_ns();
|
||||
// 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()
|
||||
};
|
||||
// fps / goodput count every received AU (spec), decoded or not.
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
|
||||
@@ -293,6 +293,16 @@ 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<()>;
|
||||
|
||||
@@ -1078,7 +1078,18 @@ impl Encoder for NvencCudaEncoder {
|
||||
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
|
||||
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
|
||||
self.chroma_444 = self.chroma_444 && buf.yuv444;
|
||||
self.init_session()?;
|
||||
// `init_session` publishes `self.encoder` before its remaining fallible steps (bitstream
|
||||
// buffers, input-surface alloc, `register_resource`), so a failure there leaves a live
|
||||
// session with `inited == false`. Every guard on the re-init path keys off `inited`, so
|
||||
// without this the next submit would skip teardown and overwrite `self.encoder`, leaking
|
||||
// the session and its registered input surfaces permanently. `teardown` keys off
|
||||
// `encoder.is_null()`, not `inited`, so it cleans up exactly this half-built state.
|
||||
if let Err(e) = self.init_session() {
|
||||
// SAFETY: the encode thread owns the session and a failed init leaves nothing
|
||||
// mid-encode to race with.
|
||||
unsafe { self.teardown() };
|
||||
return Err(e);
|
||||
}
|
||||
} else {
|
||||
// Steady state: the copy helpers need the shared context current on this thread.
|
||||
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
|
||||
|
||||
@@ -1176,7 +1176,24 @@ 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.
|
||||
unsafe { self.encode_frame(frame) }
|
||||
let r = unsafe { self.encode_frame(frame) };
|
||||
if r.is_err() {
|
||||
// `encode_frame` opens the recording window early and has several fallible steps
|
||||
// inside it (cursor prep, dmabuf import, format mapping, the CPU-RGB staging path,
|
||||
// an unsupported-payload bail, and the encode call itself). Every one returns with
|
||||
// `self.cmd` still RECORDING, and nothing downstream repairs it — there is exactly
|
||||
// one `begin_command_buffer` in this file and `reset()`/`Drop` never touch `cmd` —
|
||||
// so the NEXT frame would call `begin` on a recording buffer, which is invalid usage.
|
||||
// Legal here on every path: the pool carries RESET_COMMAND_BUFFER and the buffer is
|
||||
// not pending (we never reached the submit, or the submit itself failed).
|
||||
// SAFETY: `self.cmd` is owned by this encoder and, on these paths, not in flight.
|
||||
unsafe {
|
||||
let _ = self
|
||||
.device
|
||||
.reset_command_buffer(self.cmd, vk::CommandBufferResetFlags::empty());
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
|
||||
@@ -183,7 +183,8 @@ pub(crate) unsafe fn make_plain_image(
|
||||
None,
|
||||
)?;
|
||||
let req = device.get_image_memory_requirements(img);
|
||||
let mem = device.allocate_memory(
|
||||
// Unwind on failure: callers (the encoders' open paths) only ever see the completed triple.
|
||||
let mem = match device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(find_mem(
|
||||
@@ -192,8 +193,24 @@ pub(crate) unsafe fn make_plain_image(
|
||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||
)),
|
||||
None,
|
||||
)?;
|
||||
device.bind_image_memory(img, mem, 0)?;
|
||||
let view = make_view(device, img, fmt, 0)?;
|
||||
Ok((img, mem, view))
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ const DPB_SLOTS: u32 = 8;
|
||||
/// latency — on-glass validated as rock-solid at 1080p@240, so it is the real-time default;
|
||||
/// 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;
|
||||
@@ -114,6 +120,10 @@ 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
|
||||
@@ -284,6 +294,11 @@ 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);
|
||||
|
||||
@@ -420,6 +435,8 @@ 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 {
|
||||
@@ -453,13 +470,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;
|
||||
@@ -474,7 +491,7 @@ impl VulkanVideoEncoder {
|
||||
.memory_type_index(ti),
|
||||
None,
|
||||
)?;
|
||||
session_mem.push(m);
|
||||
guard.session_mem.push(m);
|
||||
binds.push(
|
||||
vk::BindVideoSessionMemoryInfoKHR::default()
|
||||
.memory_bind_index(rq.memory_bind_index)
|
||||
@@ -512,6 +529,7 @@ 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 =
|
||||
@@ -527,9 +545,13 @@ impl VulkanVideoEncoder {
|
||||
&mut profile_list,
|
||||
&[],
|
||||
)?;
|
||||
let dpb_views: Vec<vk::ImageView> = (0..DPB_SLOTS)
|
||||
.map(|slot| make_view(&device, dpb_image, NV12, slot))
|
||||
.collect::<Result<_>>()?;
|
||||
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)?);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -548,9 +570,11 @@ 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)
|
||||
@@ -568,6 +592,7 @@ 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()
|
||||
@@ -580,6 +605,7 @@ impl VulkanVideoEncoder {
|
||||
.push_constant_ranges(&pc_ranges),
|
||||
None,
|
||||
)?;
|
||||
guard.csc_layout = csc_layout;
|
||||
let stage = vk::PipelineShaderStageCreateInfo::default()
|
||||
.stage(vk::ShaderStageFlags::COMPUTE)
|
||||
.module(shader)
|
||||
@@ -593,7 +619,10 @@ 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 = [
|
||||
@@ -611,6 +640,7 @@ impl VulkanVideoEncoder {
|
||||
.pool_sizes(&pool_sizes),
|
||||
None,
|
||||
)?;
|
||||
guard.csc_pool = csc_pool;
|
||||
|
||||
// ---- bitstream size (shared) + shared command pools ----
|
||||
let bs_size = align_up(
|
||||
@@ -623,17 +653,21 @@ 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 {
|
||||
frames.push(make_frame(
|
||||
// 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(
|
||||
&device,
|
||||
&mem_props,
|
||||
w,
|
||||
@@ -647,9 +681,17 @@ 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,
|
||||
@@ -868,6 +910,15 @@ 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);
|
||||
@@ -1849,9 +1900,39 @@ impl VulkanVideoEncoder {
|
||||
unsafe fn read_slot(&mut self, slot: usize) -> Result<EncodedFrame> {
|
||||
let dev = self.device.clone();
|
||||
let f = &self.frames[slot];
|
||||
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);
|
||||
// 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 p =
|
||||
dev.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8;
|
||||
let prefix: &[u8] = if f.keyframe {
|
||||
@@ -1879,8 +1960,25 @@ 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();
|
||||
self.device
|
||||
.wait_for_fences(&[self.frames[slot].fence], true, u64::MAX)?;
|
||||
// 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()),
|
||||
}
|
||||
let done = self.read_slot(slot)?;
|
||||
self.pending.push_back(done);
|
||||
}
|
||||
@@ -1923,7 +2021,28 @@ 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);
|
||||
@@ -2008,82 +2127,183 @@ impl Encoder for VulkanVideoEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop 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 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).
|
||||
// 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 {
|
||||
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(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);
|
||||
}
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
if let Some((b, m, _)) = f.cpu_stage {
|
||||
self.device.destroy_buffer(b, None);
|
||||
self.device.free_memory(m, 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);
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
if let Some(instance) = self.instance.take() {
|
||||
instance.destroy_instance(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- free helpers ----------
|
||||
|
||||
fn align_up(v: u64, a: u64) -> u64 {
|
||||
@@ -2125,7 +2345,8 @@ unsafe fn make_video_image(
|
||||
}
|
||||
let img = device.create_image(&ci, None)?;
|
||||
let req = device.get_image_memory_requirements(img);
|
||||
let mem = device.allocate_memory(
|
||||
// Unwind on failure: callers (the open path) only ever see the completed pair.
|
||||
let mem = match device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(find_mem(
|
||||
@@ -2134,14 +2355,28 @@ unsafe fn make_video_image(
|
||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||
)),
|
||||
None,
|
||||
)?;
|
||||
device.bind_image_memory(img, mem, 0)?;
|
||||
) {
|
||||
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());
|
||||
}
|
||||
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,
|
||||
@@ -2156,9 +2391,12 @@ unsafe fn make_frame(
|
||||
compute_pool: vk::CommandPool,
|
||||
bs_size: u64,
|
||||
sampler: vk::Sampler,
|
||||
) -> Result<Frame> {
|
||||
f: &mut Frame,
|
||||
) -> Result<()> {
|
||||
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
|
||||
f.cursor_serial = u64::MAX;
|
||||
// NV12 encode-src (filled by the CSC copy) — concurrent compute+encode.
|
||||
let (nv12_src, nv12_mem) = make_video_image(
|
||||
(f.nv12_src, f.nv12_mem) = make_video_image(
|
||||
device,
|
||||
mem_props,
|
||||
NV12,
|
||||
@@ -2169,9 +2407,9 @@ unsafe fn make_frame(
|
||||
profile_list,
|
||||
fams,
|
||||
)?;
|
||||
let nv12_view = make_view(device, nv12_src, NV12, 0)?;
|
||||
f.nv12_view = make_view(device, f.nv12_src, NV12, 0)?;
|
||||
// CSC scratch (Y R8 full-res, UV RG8 half-res).
|
||||
let (y_img, y_mem, y_view) = make_plain_image(
|
||||
(f.y_img, f.y_mem, f.y_view) = make_plain_image(
|
||||
device,
|
||||
mem_props,
|
||||
vk::Format::R8_UNORM,
|
||||
@@ -2179,7 +2417,7 @@ unsafe fn make_frame(
|
||||
h,
|
||||
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
|
||||
)?;
|
||||
let (uv_img, uv_mem, uv_view) = make_plain_image(
|
||||
(f.uv_img, f.uv_mem, f.uv_view) = make_plain_image(
|
||||
device,
|
||||
mem_props,
|
||||
vk::Format::R8G8_UNORM,
|
||||
@@ -2190,7 +2428,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`.
|
||||
let (cursor_img, cursor_mem, cursor_view) = make_plain_image(
|
||||
(f.cursor_img, f.cursor_mem, f.cursor_view) = make_plain_image(
|
||||
device,
|
||||
mem_props,
|
||||
vk::Format::R8G8B8A8_UNORM,
|
||||
@@ -2198,14 +2436,14 @@ unsafe fn make_frame(
|
||||
CURSOR_MAX,
|
||||
vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
|
||||
)?;
|
||||
let cursor_stage = device.create_buffer(
|
||||
f.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(cursor_stage);
|
||||
let cursor_stage_mem = device.allocate_memory(
|
||||
let cs_req = device.get_buffer_memory_requirements(f.cursor_stage);
|
||||
f.cursor_stage_mem = device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(cs_req.size)
|
||||
.memory_type_index(find_mem(
|
||||
@@ -2215,39 +2453,39 @@ unsafe fn make_frame(
|
||||
)),
|
||||
None,
|
||||
)?;
|
||||
device.bind_buffer_memory(cursor_stage, cursor_stage_mem, 0)?;
|
||||
device.bind_buffer_memory(f.cursor_stage, f.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];
|
||||
let csc_set = device.allocate_descriptor_sets(
|
||||
f.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(y_view)
|
||||
.image_view(f.y_view)
|
||||
.image_layout(vk::ImageLayout::GENERAL)];
|
||||
let uv_info = [vk::DescriptorImageInfo::default()
|
||||
.image_view(uv_view)
|
||||
.image_view(f.uv_view)
|
||||
.image_layout(vk::ImageLayout::GENERAL)];
|
||||
let cur_info = [vk::DescriptorImageInfo::default()
|
||||
.sampler(sampler)
|
||||
.image_view(cursor_view)
|
||||
.image_view(f.cursor_view)
|
||||
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
|
||||
device.update_descriptor_sets(
|
||||
&[
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(csc_set)
|
||||
.dst_set(f.csc_set)
|
||||
.dst_binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
|
||||
.image_info(&y_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(csc_set)
|
||||
.dst_set(f.csc_set)
|
||||
.dst_binding(2)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
|
||||
.image_info(&uv_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(csc_set)
|
||||
.dst_set(f.csc_set)
|
||||
.dst_binding(3)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(&cur_info),
|
||||
@@ -2255,15 +2493,15 @@ unsafe fn make_frame(
|
||||
&[],
|
||||
);
|
||||
// Bitstream buffer + feedback query.
|
||||
let bs_buf = device.create_buffer(
|
||||
f.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(bs_buf);
|
||||
let bs_mem = device.allocate_memory(
|
||||
let bs_req = device.get_buffer_memory_requirements(f.bs_buf);
|
||||
f.bs_mem = device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(bs_req.size)
|
||||
.memory_type_index(find_mem(
|
||||
@@ -2273,7 +2511,7 @@ unsafe fn make_frame(
|
||||
)),
|
||||
None,
|
||||
)?;
|
||||
device.bind_buffer_memory(bs_buf, bs_mem, 0)?;
|
||||
device.bind_buffer_memory(f.bs_buf, f.bs_mem, 0)?;
|
||||
let mut fb_ci = vk::QueryPoolVideoEncodeFeedbackCreateInfoKHR::default().encode_feedback_flags(
|
||||
vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BUFFER_OFFSET
|
||||
| vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BYTES_WRITTEN,
|
||||
@@ -2283,51 +2521,21 @@ 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;
|
||||
let query_pool = device.create_query_pool(&query_ci, None)?;
|
||||
f.query_pool = device.create_query_pool(&query_ci, None)?;
|
||||
// Command buffers + per-frame sync.
|
||||
let cmd = device.allocate_command_buffers(
|
||||
f.cmd = device.allocate_command_buffers(
|
||||
&vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(cmd_pool)
|
||||
.command_buffer_count(1),
|
||||
)?[0];
|
||||
let compute_cmd = device.allocate_command_buffers(
|
||||
f.compute_cmd = device.allocate_command_buffers(
|
||||
&vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(compute_pool)
|
||||
.command_buffer_count(1),
|
||||
)?[0];
|
||||
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,
|
||||
})
|
||||
f.csc_sem = device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?;
|
||||
f.fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Author VPS/SPS/PPS (Main, level 4.0, low-latency, conformance-window crop) and return the
|
||||
@@ -2430,6 +2638,12 @@ 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];
|
||||
@@ -2441,6 +2655,11 @@ 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);
|
||||
@@ -2697,6 +2916,62 @@ 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]
|
||||
|
||||
@@ -409,6 +409,12 @@ 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
|
||||
@@ -505,6 +511,7 @@ impl NvencD3d11Encoder {
|
||||
bitstreams: Vec::new(),
|
||||
events: Vec::new(),
|
||||
async_rt: None,
|
||||
input_ring_depth: None,
|
||||
async_supported: false,
|
||||
pending: VecDeque::new(),
|
||||
frame_idx: 0,
|
||||
@@ -1135,7 +1142,19 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.chroma_444 = false;
|
||||
}
|
||||
let device = frame.device.clone();
|
||||
self.init_session(&device)?;
|
||||
// `init_session` publishes `self.encoder` (and charges LIVE_SESSION_UNITS) BEFORE its
|
||||
// last fallible steps, so a failure there leaves a live session with `inited == false`.
|
||||
// Every guard on the re-init path keys off `inited`, so without this the next submit
|
||||
// would skip teardown and overwrite `self.encoder` — leaking the session permanently
|
||||
// (toward the driver's per-process cap) along with its session-budget units.
|
||||
// `teardown` keys off `encoder.is_null()`, not `inited`, so it cleans up exactly this
|
||||
// half-built state and is a no-op when nothing was opened.
|
||||
if let Err(e) = self.init_session(&device) {
|
||||
// SAFETY: same contract as the teardown above — the encode thread owns the session,
|
||||
// and a failed init leaves nothing mid-encode to race with.
|
||||
unsafe { self.teardown() };
|
||||
return Err(e);
|
||||
}
|
||||
self.init_device = dev_raw;
|
||||
}
|
||||
// The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
|
||||
@@ -1144,11 +1163,21 @@ 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 (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() {
|
||||
// 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 {
|
||||
let done = {
|
||||
let rt = self.async_rt.as_mut().expect("checked in loop condition");
|
||||
rt.done_rx
|
||||
@@ -1324,6 +1353,17 @@ 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;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ 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
|
||||
@@ -136,8 +139,12 @@ 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).
|
||||
y_images: Vec<(isize, pw::pyrowave_image)>,
|
||||
cbcr_images: Vec<(isize, pw::pyrowave_image)>,
|
||||
/// 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)>,
|
||||
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -268,6 +275,7 @@ impl PyroWaveEncoder {
|
||||
pw_dev,
|
||||
pw_enc,
|
||||
sync: std::ptr::null_mut(),
|
||||
ring_gen: None,
|
||||
y_images: Vec::new(),
|
||||
cbcr_images: Vec::new(),
|
||||
width,
|
||||
@@ -351,10 +359,16 @@ 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<(isize, pw::pyrowave_image)>,
|
||||
cache: &mut Vec<(PlaneKey, pw::pyrowave_image)>,
|
||||
make: impl FnOnce() -> Result<pw::pyrowave_image>,
|
||||
key: isize,
|
||||
key: PlaneKey,
|
||||
) -> Result<pw::pyrowave_image> {
|
||||
if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) {
|
||||
return Ok(*img);
|
||||
@@ -423,6 +437,21 @@ 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)")
|
||||
};
|
||||
@@ -431,6 +460,25 @@ 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).
|
||||
@@ -465,7 +513,7 @@ impl PyroWaveEncoder {
|
||||
};
|
||||
let pw_dev = self.pw_dev;
|
||||
let y_img = {
|
||||
let key = d3d.texture.as_raw() as isize;
|
||||
let key = (d3d.texture.as_raw() as isize, w, h);
|
||||
let tex = &d3d.texture;
|
||||
Self::cached_plane(
|
||||
&mut self.y_images,
|
||||
@@ -474,7 +522,7 @@ impl PyroWaveEncoder {
|
||||
)?
|
||||
};
|
||||
let cbcr_img = {
|
||||
let key = share.cbcr.as_raw() as isize;
|
||||
let key = (share.cbcr.as_raw() as isize, cw, ch);
|
||||
let tex = &share.cbcr;
|
||||
Self::cached_plane(
|
||||
&mut self.cbcr_images,
|
||||
@@ -976,6 +1024,9 @@ 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,
|
||||
|
||||
@@ -146,7 +146,12 @@ const NUM_LTR_SLOTS: usize = 2;
|
||||
/// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with
|
||||
/// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR.
|
||||
fn ltr_disabled() -> bool {
|
||||
std::env::var("PUNKTFUNK_NO_QSV_LTR").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
// Same accepted spellings as AMF's `ltr_disabled` — this had dropped the `trim()` and the
|
||||
// `yes`/`on` forms, so a value with stray whitespace (easy to produce with `set VAR=1 `)
|
||||
// silently left LTR enabled on Intel while the identical value worked on AMD.
|
||||
std::env::var("PUNKTFUNK_NO_QSV_LTR")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s
|
||||
@@ -171,13 +176,22 @@ fn ltr_test_force_at() -> Option<i64> {
|
||||
/// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave
|
||||
/// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references).
|
||||
fn intra_refresh_requested() -> bool {
|
||||
// Spelling parity with AMF (see `ltr_disabled` above).
|
||||
std::env::var("PUNKTFUNK_INTRA_REFRESH")
|
||||
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The wave period in frames (~0.5 s), the same shape as Linux NVENC / AMF.
|
||||
/// The wave period in frames (~0.5 s), `PUNKTFUNK_IR_PERIOD_FRAMES` overrides — the same knob and
|
||||
/// default as AMF / Linux NVENC. (This claimed parity while ignoring the env var entirely, so the
|
||||
/// knob silently did nothing on Intel; the clamp is kept because `mfxU16` bounds the field.)
|
||||
fn intra_refresh_period(fps: u32) -> u16 {
|
||||
(fps / 2).clamp(8, 240) as u16
|
||||
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.filter(|v| *v >= 2)
|
||||
.unwrap_or(fps / 2)
|
||||
.clamp(8, 240) as u16
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -700,7 +714,16 @@ 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.
|
||||
@@ -775,6 +798,7 @@ 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,
|
||||
@@ -899,6 +923,7 @@ 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;
|
||||
@@ -1024,6 +1049,7 @@ 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) {
|
||||
@@ -1039,7 +1065,9 @@ 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).
|
||||
if let Some(idx) = self.ltr_slots[slot] {
|
||||
// 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]) {
|
||||
force_ltr = Some((slot, idx));
|
||||
recovery_anchor = true;
|
||||
}
|
||||
@@ -1047,6 +1075,9 @@ 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);
|
||||
}
|
||||
@@ -1309,13 +1340,23 @@ 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.
|
||||
for marked in self.ltr_slots.iter_mut() {
|
||||
//
|
||||
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an
|
||||
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it
|
||||
// made the rejection list below (which iterates the post-sweep mirror and only names `Some`
|
||||
// slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame
|
||||
// could still predict from it. With two slots the "exactly one swept" case is the modal
|
||||
// one, and it was the broken one.
|
||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||
if marked.is_some_and(|idx| idx >= first) {
|
||||
*marked = None;
|
||||
self.ltr_tainted[slot] = true;
|
||||
}
|
||||
}
|
||||
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));
|
||||
@@ -1440,6 +1481,7 @@ 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() {
|
||||
|
||||
@@ -209,6 +209,11 @@ 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()
|
||||
}
|
||||
@@ -223,6 +228,13 @@ impl Encoder for TrackedEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ceiling applied to the negotiated bitrate before it reaches openh264: software H.264 realistically
|
||||
/// caps far below the rates a hardware session negotiates, and handing it the full figure just
|
||||
/// misconfigures its rate control. Module-scope so BOTH software arms share one value — the Linux
|
||||
/// arm was missing the clamp the Windows arm applied.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
||||
|
||||
/// Open the platform encoder backend. Returns the encoder together with the display label of the
|
||||
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
|
||||
/// the mgmt API's live-session record, and only the open site knows which internal fallback won
|
||||
@@ -407,8 +419,14 @@ fn open_video_backend(
|
||||
);
|
||||
}
|
||||
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
|
||||
sw::OpenH264Encoder::open(format, width, height, fps, bitrate_bps)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||
sw::OpenH264Encoder::open(
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps.min(SW_BITRATE_CEIL),
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||||
}
|
||||
"auto" | "" => {
|
||||
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
||||
@@ -610,8 +628,6 @@ fn open_video_backend(
|
||||
(build a GPU backend: --features nvenc or amf-qsv, or request H264)"
|
||||
);
|
||||
let _ = (bit_depth, chroma); // the software H.264 path is 8-bit 4:2:0 only
|
||||
// Software H.264 realistically caps far below the negotiated hardware rates.
|
||||
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
||||
sw::OpenH264Encoder::open(
|
||||
format,
|
||||
width,
|
||||
@@ -784,6 +800,12 @@ fn nvidia_present() -> bool {
|
||||
/// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still
|
||||
/// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise
|
||||
/// today's NVIDIA-presence probe, unchanged.
|
||||
///
|
||||
/// ⚠ This resolves the **`auto` case only** — it deliberately ignores `encoder_pref`. It is NOT a
|
||||
/// mirror of [`open_video`]'s dispatch and must not be used to decide which backend a capability
|
||||
/// probe should ask: use [`linux_zero_copy_is_vaapi`], which layers `encoder_pref` on top of this.
|
||||
/// (`can_encode_10bit` used this directly and answered for the wrong backend whenever a host
|
||||
/// forced one.)
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_auto_is_vaapi() -> bool {
|
||||
if let Some(g) = pf_gpu::manual_selection() {
|
||||
@@ -997,7 +1019,13 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||
// only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode)
|
||||
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
|
||||
// honor), since this probe can't know what the compositor will negotiate.
|
||||
if linux_auto_is_vaapi() {
|
||||
// Resolve through the SAME helper `can_encode_444` uses (and which mirrors
|
||||
// `open_video`'s dispatch): `linux_auto_is_vaapi` ignores `encoder_pref`, so on a box
|
||||
// that forces a backend — e.g. `encoder_pref = "vaapi"` on an NVIDIA host — this probe
|
||||
// would answer for NVENC while the session actually opens VAAPI, and the negotiated bit
|
||||
// depth (plus the HDR/SDR colour label derived from it) would describe a backend that
|
||||
// never runs. That is exactly the dishonesty this probe exists to prevent.
|
||||
if linux_zero_copy_is_vaapi() {
|
||||
vaapi::probe_can_encode_10bit(codec)
|
||||
} else {
|
||||
linux::probe_can_encode_10bit(codec)
|
||||
|
||||
@@ -52,6 +52,12 @@ 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, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -196,6 +196,45 @@ 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();
|
||||
// Via `*const ()`: casting a function item straight to an integer is what
|
||||
// `clippy::function_casts_as_integer` rejects, and the pointer hop is the documented
|
||||
// way to spell it. `sa_sigaction` is a `usize`-typed handler slot, so the value is
|
||||
// unchanged.
|
||||
sa.sa_sigaction = noop as *const () 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 {
|
||||
@@ -203,6 +242,7 @@ pub struct SteamDeckGadget {
|
||||
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
||||
running: Arc<AtomicBool>,
|
||||
threads: Vec<JoinHandle<()>>,
|
||||
wakers: Vec<Waker>,
|
||||
_fd: Arc<GadgetFd>,
|
||||
seq: u32,
|
||||
}
|
||||
@@ -243,6 +283,18 @@ 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();
|
||||
@@ -250,10 +302,15 @@ 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 || {
|
||||
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
|
||||
// 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);
|
||||
})
|
||||
.context("spawn gadget control thread")?
|
||||
};
|
||||
@@ -264,9 +321,16 @@ 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 || stream_loop(fd, running, ctrl_ep, configured, report))
|
||||
.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);
|
||||
})
|
||||
.context("spawn gadget stream thread")?
|
||||
};
|
||||
|
||||
@@ -275,6 +339,7 @@ impl SteamDeckGadget {
|
||||
feedback,
|
||||
running,
|
||||
threads: vec![control, stream],
|
||||
wakers: vec![ctrl_waker, stream_waker],
|
||||
_fd: fd,
|
||||
seq: 0,
|
||||
})
|
||||
@@ -302,6 +367,32 @@ 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,13 +299,20 @@ 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).
|
||||
let mut ctx = SwCreateCtx {
|
||||
// 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 {
|
||||
event,
|
||||
result: E_FAIL,
|
||||
instance_id: [0; 128],
|
||||
};
|
||||
// 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.
|
||||
}));
|
||||
// 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.
|
||||
let hsw = match unsafe {
|
||||
SwDeviceCreate(
|
||||
w!("punktfunk"),
|
||||
@@ -313,13 +320,15 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
||||
&info,
|
||||
None,
|
||||
Some(sw_create_cb),
|
||||
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||
Some(ctx as *const c_void),
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// SAFETY: event is valid.
|
||||
// SAFETY: the call failed, so no callback was registered and `ctx` is ours to reclaim;
|
||||
// `event` is valid and unreferenced.
|
||||
unsafe {
|
||||
drop(Box::from_raw(ctx));
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
return Err(anyhow!("SwDeviceCreate failed: {e}"));
|
||||
@@ -328,17 +337,22 @@ 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) = match create_swdevice(&SwDeviceProfile {
|
||||
let (hsw, instance_id) = 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",
|
||||
}) {
|
||||
Ok((h, id)) => (Some(h), id),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
})?; // 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.
|
||||
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,12 +82,16 @@ 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).
|
||||
let mut ctx = SwCreateCtx {
|
||||
// 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 {
|
||||
event,
|
||||
result: E_FAIL,
|
||||
instance_id: [0; 128],
|
||||
};
|
||||
// SAFETY: info + buffers + ctx outlive the call (we wait on the event before returning).
|
||||
}));
|
||||
// SAFETY: info + buffers outlive the call; `ctx` is a live heap allocation outliving every path.
|
||||
let hsw = match unsafe {
|
||||
SwDeviceCreate(
|
||||
w!("punktfunk"),
|
||||
@@ -95,13 +99,14 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
||||
&info,
|
||||
None,
|
||||
Some(sw_create_cb),
|
||||
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||
Some(ctx as *const c_void),
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// SAFETY: event is valid.
|
||||
// SAFETY: the call failed, so no callback is pending and `ctx` is ours to reclaim.
|
||||
unsafe {
|
||||
drop(Box::from_raw(ctx));
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}"));
|
||||
@@ -109,17 +114,20 @@ 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) = match create_swdevice(&SwDeviceProfile {
|
||||
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
@@ -77,13 +77,8 @@ impl DeckWinPad {
|
||||
// spike's run-1 failure).
|
||||
usb_mi: Some(2),
|
||||
description: "punktfunk Virtual Steam Deck",
|
||||
}) {
|
||||
Ok((h, i)) => (Some(h), i),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin).
|
||||
let (hsw, instance_id) = (Some(hsw), instance_id);
|
||||
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.
|
||||
|
||||
@@ -11,7 +11,11 @@ repository.workspace = true
|
||||
# Same Linux+Windows gating as the rest of the client stack (dmabuf import is the one
|
||||
# Linux-only module — see lib.rs).
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
pf-client-core = { path = "../pf-client-core" }
|
||||
# `default-features = false`: the PyroWave decode backend is turned on through THIS crate's own
|
||||
# `pyrowave` feature (which re-exports it below), never by inheriting the dependency's default.
|
||||
# Otherwise a consumer that deliberately builds us without `pyrowave` still drags the vendored
|
||||
# C++ in — fatal on Windows ARM64, where Granite has no SIMD path.
|
||||
pf-client-core = { path = "../pf-client-core", default-features = false }
|
||||
# AVVkFrame access (Vulkan Video frames: live sync state under the frames lock).
|
||||
pf-ffvk = { path = "../pf-ffvk" }
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
|
||||
@@ -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;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 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,11 +64,27 @@ 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>> = Mutex::new(Vec::new());
|
||||
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);
|
||||
|
||||
fn sweep_reaper() {
|
||||
let mut list = REAPER.lock().unwrap();
|
||||
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/// Fd pinned to this process's own executable image, opened (once, lazily) via the
|
||||
@@ -455,7 +471,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);
|
||||
REAPER.lock().unwrap().push((child, Instant::now()));
|
||||
}
|
||||
}
|
||||
sweep_reaper();
|
||||
|
||||
@@ -1003,7 +1003,13 @@ 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) on the default stream;
|
||||
// `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()`.
|
||||
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
|
||||
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `©` is a live
|
||||
// local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid
|
||||
@@ -1012,12 +1018,12 @@ impl RegisteredTexture {
|
||||
// we always unmap afterward (even on error), keeping the map/unmap pair balanced.
|
||||
unsafe {
|
||||
ck(
|
||||
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||
cuGraphicsMapResources(1, &mut self.resource, copy_stream()),
|
||||
"cuGraphicsMapResources",
|
||||
)?;
|
||||
let mut array: CUarray = std::ptr::null_mut();
|
||||
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
||||
bail!("cuGraphicsSubResourceGetMappedArray failed");
|
||||
}
|
||||
let copy = CUDA_MEMCPY2D {
|
||||
@@ -1031,7 +1037,7 @@ impl RegisteredTexture {
|
||||
..Default::default()
|
||||
};
|
||||
let res = copy_blocking(©, "cuMemcpy2DAsync_v2");
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
||||
res
|
||||
}
|
||||
}
|
||||
@@ -1058,12 +1064,12 @@ impl RegisteredTexture {
|
||||
// so the map/unmap pair stays balanced and the array outlives the copy.
|
||||
unsafe {
|
||||
ck(
|
||||
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||
cuGraphicsMapResources(1, &mut self.resource, copy_stream()),
|
||||
"cuGraphicsMapResources",
|
||||
)?;
|
||||
let mut array: CUarray = std::ptr::null_mut();
|
||||
if cuGraphicsSubResourceGetMappedArray(&mut array, self.resource, 0, 0) != 0 {
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
||||
bail!("cuGraphicsSubResourceGetMappedArray failed");
|
||||
}
|
||||
let copy = CUDA_MEMCPY2D {
|
||||
@@ -1077,7 +1083,7 @@ impl RegisteredTexture {
|
||||
..Default::default()
|
||||
};
|
||||
let res = copy_blocking(©, "cuMemcpy2DAsync_v2(plane)");
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, std::ptr::null_mut());
|
||||
let _ = cuGraphicsUnmapResources(1, &mut self.resource, copy_stream());
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,6 +691,15 @@ 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
|
||||
|
||||
@@ -193,10 +193,17 @@ 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
|
||||
@@ -212,41 +219,55 @@ impl VkBridge {
|
||||
.push_next(&mut ext_info),
|
||||
None,
|
||||
)
|
||||
.context("create import buffer")?;
|
||||
.context("create import buffer")?; // `dup` drops → closes on failure
|
||||
let mut fd_props = vk::MemoryFdPropertiesKHR::default();
|
||||
self.ext_fd
|
||||
.get_memory_fd_properties(
|
||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||
dup,
|
||||
&mut fd_props,
|
||||
)
|
||||
.context("vkGetMemoryFdPropertiesKHR")?;
|
||||
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");
|
||||
}
|
||||
let reqs = self.device.get_buffer_memory_requirements(buffer);
|
||||
let mem_type = self.memory_type(
|
||||
let mem_type = match 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(dup); // Vulkan takes ownership of `dup` on success
|
||||
.fd(raw);
|
||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
|
||||
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")?;
|
||||
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");
|
||||
}
|
||||
self.src_cache.insert(
|
||||
fd,
|
||||
SrcBuf {
|
||||
@@ -263,11 +284,11 @@ impl VkBridge {
|
||||
if self.dst.as_ref().is_some_and(|d| d.size >= size) {
|
||||
return Ok(());
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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.
|
||||
let mut ext_info = vk::ExternalMemoryBufferCreateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
||||
let buffer = self
|
||||
@@ -285,35 +306,63 @@ impl VkBridge {
|
||||
.context("create export buffer")?;
|
||||
let reqs = self.device.get_buffer_memory_requirements(buffer);
|
||||
let mem_type =
|
||||
self.memory_type(reqs.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)?;
|
||||
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);
|
||||
}
|
||||
};
|
||||
let mut export = vk::ExportMemoryAllocateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::OPAQUE_FD);
|
||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
|
||||
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")?;
|
||||
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");
|
||||
}
|
||||
};
|
||||
// CUDA imports (and on success owns) the exported fd. Size must match the allocation.
|
||||
let cuda = cuda::ExternalDmabuf::import_owned_fd(opaque_fd, reqs.size)
|
||||
.context("cuImportExternalMemory(OPAQUE_FD from Vulkan)")?;
|
||||
// `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
|
||||
}
|
||||
tracing::info!(size, "Vulkan→CUDA exportable staging buffer ready");
|
||||
self.dst = Some(DstBuf {
|
||||
buffer,
|
||||
@@ -544,9 +593,19 @@ impl VkBridge {
|
||||
self.device
|
||||
.queue_submit(self.queue, &[submit], self.fence)
|
||||
.context("queue submit")?;
|
||||
self.device
|
||||
// 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
|
||||
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
||||
.context("fence wait")?;
|
||||
{
|
||||
let _ = self.device.device_wait_idle();
|
||||
let _ = self.device.reset_fences(&[self.fence]);
|
||||
return Err(e).context("fence wait");
|
||||
}
|
||||
self.device
|
||||
.reset_fences(&[self.fence])
|
||||
.context("reset fence")?;
|
||||
@@ -639,9 +698,19 @@ impl VkBridge {
|
||||
self.device
|
||||
.queue_submit(self.queue, &[submit], self.fence)
|
||||
.context("queue submit")?;
|
||||
self.device
|
||||
// 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
|
||||
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
||||
.context("fence wait")?;
|
||||
{
|
||||
let _ = self.device.device_wait_idle();
|
||||
let _ = self.device.reset_fences(&[self.fence]);
|
||||
return Err(e).context("fence wait");
|
||||
}
|
||||
self.device
|
||||
.reset_fences(&[self.fence])
|
||||
.context("reset fence")?;
|
||||
|
||||
@@ -125,6 +125,12 @@ 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.
|
||||
@@ -391,6 +397,7 @@ 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
|
||||
@@ -1744,6 +1751,7 @@ 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,6 +73,142 @@ 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
|
||||
@@ -191,6 +327,7 @@ mod frame_channel_tests {
|
||||
pts_ns: i as u64,
|
||||
flags: 0,
|
||||
complete: true,
|
||||
received_ns: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,3 +395,143 @@ 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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,9 +456,14 @@ impl NativeClient {
|
||||
hot_tids,
|
||||
clock_offset,
|
||||
decode_lat,
|
||||
// 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,
|
||||
// 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,
|
||||
mode: mode_slot,
|
||||
host_fingerprint: negotiated.host_fingerprint,
|
||||
resolved_compositor: negotiated.compositor,
|
||||
@@ -703,14 +708,23 @@ 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()
|
||||
};
|
||||
self.ctrl_tx
|
||||
let sent = self
|
||||
.ctrl_tx
|
||||
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||
target_kbps,
|
||||
duration_ms,
|
||||
}))
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
.map_err(|_| PunktfunkError::Closed);
|
||||
if sent.is_err() {
|
||||
// Nothing was asked of the host, so nothing will ever answer. Leaving `active` latched
|
||||
// would suppress the pump's entire report tick for the rest of the session (the pump
|
||||
// mirrors the startup path's rollback at the same point).
|
||||
self.probe.lock().unwrap().active = false;
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
/// Read the current speed-test measurement (partial until `done`, final once the host's
|
||||
|
||||
@@ -32,6 +32,11 @@ 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`].
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The client worker: QUIC handshake + control/input/datagram tasks + the blocking data-plane pump.
|
||||
|
||||
use super::frame_channel::{
|
||||
CLOCK_RESYNC_INTERVAL, FLUSH_AFTER, FLUSH_COOLDOWN, FLUSH_LATENCY,
|
||||
NOOP_CLOCK_FLUSHES_TO_DISARM, NOOP_FLUSH_DATAGRAMS, QUEUE_HIGH, QUEUE_LOW, STANDING_TIME,
|
||||
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,
|
||||
};
|
||||
use super::worker::reject_from_close;
|
||||
use super::*;
|
||||
@@ -93,10 +94,14 @@ 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, mut recv) = conn
|
||||
let (mut send, 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,
|
||||
@@ -135,7 +140,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?;
|
||||
let welcome = Welcome::decode(&recv.read_msg().await?)?;
|
||||
if welcome.compositor != CompositorPref::Auto {
|
||||
tracing::info!(
|
||||
compositor = welcome.compositor.as_str(),
|
||||
@@ -461,7 +466,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg = io::read_msg(&mut ctrl_recv) => {
|
||||
msg = ctrl_recv.read_msg() => {
|
||||
let Ok(msg) = msg else { break }; // stream closed
|
||||
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||
if ack.accepted {
|
||||
@@ -514,15 +519,19 @@ 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)) {
|
||||
clock_offset.store(offset_ns, Ordering::Relaxed);
|
||||
clock_gen.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::debug!(
|
||||
// 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!(
|
||||
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::debug!(
|
||||
tracing::info!(
|
||||
rtt_us = rtt_ns / 1000,
|
||||
"clock re-sync batch discarded — RTT above the \
|
||||
connect-time baseline (congested window)"
|
||||
@@ -709,6 +718,12 @@ 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
|
||||
@@ -729,6 +744,12 @@ 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.
|
||||
@@ -740,6 +761,10 @@ 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!(
|
||||
@@ -764,31 +789,70 @@ 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.
|
||||
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
|
||||
}
|
||||
// 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(deadline) = capacity_probe_deadline {
|
||||
@@ -843,6 +907,51 @@ 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.
|
||||
@@ -981,6 +1090,13 @@ 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
|
||||
|
||||
@@ -83,7 +83,12 @@ 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.
|
||||
pub const ABI_VERSION: u32 = 8;
|
||||
/// 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;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
|
||||
@@ -39,10 +39,19 @@ 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,
|
||||
@@ -52,6 +61,9 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +185,15 @@ 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 {
|
||||
@@ -183,7 +204,7 @@ impl Packetizer {
|
||||
let mut total_recovery = 0usize;
|
||||
for b in 0..block_count {
|
||||
let k = block_data_count(b);
|
||||
let m = self.fec.recovery_for(k);
|
||||
let m = recovery_for(k);
|
||||
if k + m > u16::MAX as usize {
|
||||
return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
|
||||
}
|
||||
@@ -204,7 +225,7 @@ impl Packetizer {
|
||||
block_index: b as u16,
|
||||
block_count: block_count as u16,
|
||||
data_shards: k as u16,
|
||||
recovery_shards: self.fec.recovery_for(k) as u16,
|
||||
recovery_shards: recovery_for(k) as u16,
|
||||
shard_index: shard_index as u16,
|
||||
shard_bytes: payload as u16,
|
||||
magic: PUNKTFUNK_MAGIC,
|
||||
@@ -223,7 +244,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 = self.fec.recovery_for(k);
|
||||
let recovery_count = recovery_for(k);
|
||||
coder.encode_into(&data_shards, recovery_count, &mut self.recovery[b])?;
|
||||
|
||||
for (shard_index, body) in data_shards.iter().enumerate() {
|
||||
@@ -242,7 +263,7 @@ impl Packetizer {
|
||||
let mut parity_left = total_recovery;
|
||||
for b in 0..block_count {
|
||||
let k = block_data_count(b);
|
||||
let recovery_count = self.fec.recovery_for(k);
|
||||
let recovery_count = recovery_for(k);
|
||||
for r in 0..recovery_count {
|
||||
parity_left -= 1;
|
||||
let mut flags = FLAG_PIC;
|
||||
|
||||
@@ -106,8 +106,19 @@ 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 + c.fec.recovery_for(max_data)).min(c.fec.scheme.max_total_shards());
|
||||
(max_data + (max_data * 90).div_ceil(100)).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,
|
||||
@@ -489,6 +500,7 @@ 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)
|
||||
@@ -592,6 +604,7 @@ 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,3 +579,64 @@ 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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 quinn::RecvStream,
|
||||
recv: &mut io::MsgReader,
|
||||
) -> 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, io::read_msg(recv)).await;
|
||||
let read = tokio::time::timeout(read_timeout, recv.read_msg()).await;
|
||||
let echo = match read {
|
||||
Ok(Ok(b)) => match ClockEcho::decode(&b) {
|
||||
Ok(e) => e,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
//! 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)
|
||||
@@ -14,6 +22,74 @@ 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))
|
||||
|
||||
@@ -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.
|
||||
async fn connect_pair() -> (
|
||||
pub(super) async fn connect_pair() -> (
|
||||
quinn::Endpoint,
|
||||
quinn::Endpoint,
|
||||
quinn::Connection,
|
||||
@@ -1731,3 +1731,83 @@ 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 crate::quic::io;
|
||||
|
||||
/// A frame whose halves land in different wakeups, with the read cancelled in between, must
|
||||
/// still be delivered whole — and the NEXT frame must decode correctly too. Without a
|
||||
/// resumable reader the consumed length prefix is lost, the following read takes two payload
|
||||
/// bytes as a length, and every later control message is garbage for the rest of the session.
|
||||
#[tokio::test]
|
||||
async fn cancelled_mid_frame_read_resumes_without_desync() {
|
||||
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||
|
||||
let first = b"the-frame-that-straddles-two-wakeups".to_vec();
|
||||
let second = b"the-frame-after-it".to_vec();
|
||||
let (f1, f2) = (first.clone(), second.clone());
|
||||
|
||||
let writer = tokio::spawn(async move {
|
||||
let (mut send, _recv) = host_conn.open_bi().await.expect("open bi");
|
||||
let framed = crate::quic::frame(&f1);
|
||||
// Length prefix + only part of the payload, then a real pause: this is the ClipOffer
|
||||
// -sized frame split across two QUIC packets that made the bug reachable.
|
||||
let split = 2 + f1.len() / 3;
|
||||
send.write_all(&framed[..split]).await.expect("write head");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
|
||||
send.write_all(&framed[split..]).await.expect("write tail");
|
||||
send.write_all(&crate::quic::frame(&f2))
|
||||
.await
|
||||
.expect("write second");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
host_conn
|
||||
});
|
||||
|
||||
let (_send, recv) = client_conn.accept_bi().await.expect("accept bi");
|
||||
let mut reader = io::MsgReader::new(recv);
|
||||
|
||||
// Cancel mid-frame — exactly what a sibling `select!` arm does.
|
||||
let cancelled =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(30), reader.read_msg()).await;
|
||||
assert!(
|
||||
cancelled.is_err(),
|
||||
"the head-only frame must not complete yet (test setup)"
|
||||
);
|
||||
|
||||
let got = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read_msg())
|
||||
.await
|
||||
.expect("first frame must arrive after resuming")
|
||||
.expect("first frame reads cleanly");
|
||||
assert_eq!(got, first, "the cancelled read must resume, not lose bytes");
|
||||
|
||||
let got2 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read_msg())
|
||||
.await
|
||||
.expect("second frame must arrive")
|
||||
.expect("second frame reads cleanly");
|
||||
assert_eq!(got2, second, "stream must still be framed correctly");
|
||||
|
||||
let _host_conn = writer.await.unwrap();
|
||||
}
|
||||
|
||||
/// A zero-length frame is a legal encoding and must not stall the reader or eat the next one.
|
||||
#[tokio::test]
|
||||
async fn zero_length_frame_round_trips() {
|
||||
let (_server_ep, _client_ep, host_conn, client_conn) = connect_pair().await;
|
||||
let writer = tokio::spawn(async move {
|
||||
let (mut send, _recv) = host_conn.open_bi().await.expect("open bi");
|
||||
send.write_all(&crate::quic::frame(&[])).await.unwrap();
|
||||
send.write_all(&crate::quic::frame(b"after")).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
host_conn
|
||||
});
|
||||
let (_send, recv) = client_conn.accept_bi().await.expect("accept bi");
|
||||
let mut reader = io::MsgReader::new(recv);
|
||||
assert!(reader.read_msg().await.unwrap().is_empty());
|
||||
assert_eq!(reader.read_msg().await.unwrap(), b"after");
|
||||
let _host_conn = writer.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ pub struct Frame {
|
||||
/// ([`crate::packet::USER_FLAG_CHUNK_ALIGNED`]) are ever delivered partial; missing
|
||||
/// shard ranges are zero-filled at their exact offsets.
|
||||
pub complete: bool,
|
||||
/// Wall-clock instant (ns since the Unix epoch, CLOCK_REALTIME basis — the same clock the
|
||||
/// skew handshake compares and the host stamps `pts_ns` with) at which this AU finished
|
||||
/// reassembly, stamped by [`Session::poll_frame`] as the frame leaves the session. Embedders
|
||||
/// that previously stamped receipt themselves at the hand-off pull should use this instead:
|
||||
/// the pull stamp additionally contains the pre-decode queue wait, silently folding any
|
||||
/// client-side standing backlog into the apparent NETWORK latency. The reassembler itself
|
||||
/// leaves this 0 (it owns no clock — the stamp is the session boundary's job).
|
||||
pub received_ns: u64,
|
||||
}
|
||||
|
||||
/// One end of a stream. Constructed for a single [`Role`]; calling the other role's
|
||||
@@ -82,6 +90,18 @@ pub struct Session {
|
||||
lane_scratch: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Stamp [`Frame::received_ns`] as the frame crosses the session boundary in
|
||||
/// [`Session::poll_frame`] — completed frames return the moment their last shard lands, so
|
||||
/// stamping at return IS stamping at reassembly completion (µs apart). CLOCK_REALTIME to match
|
||||
/// `pts_ns` / the skew handshake (deliberately not monotonic — cross-machine latency math).
|
||||
fn stamp_received(mut f: Frame) -> Frame {
|
||||
f.received_ns = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
f
|
||||
}
|
||||
|
||||
/// Wire-packet count at which a frame's sealing splits across two lanes (plan Phase 1.5):
|
||||
/// below it the channel rendezvous (~µs) isn't worth it; at it the halved AES-GCM span
|
||||
/// (≥ ~125 µs of ~1 µs/packet work) dwarfs the hand-off. ≈300 KB of wire, i.e. ≥150 Mbps
|
||||
@@ -684,7 +704,7 @@ impl Session {
|
||||
// Nothing new on the wire — hand over an aged-out partial if one is
|
||||
// waiting (it can only get staler).
|
||||
if let Some(p) = self.reassembler.take_partial() {
|
||||
return Ok(p);
|
||||
return Ok(stamp_received(p));
|
||||
}
|
||||
return Err(PunktfunkError::NoFrame);
|
||||
}
|
||||
@@ -748,12 +768,12 @@ impl Session {
|
||||
}
|
||||
if let Some(frame) = pushed {
|
||||
StatsCounters::add(&self.stats.frames_completed, 1);
|
||||
return Ok(frame);
|
||||
return Ok(stamp_received(frame));
|
||||
}
|
||||
// A push that completed nothing may still have aged a partial out — deliver it
|
||||
// ahead of further draining (its successors are already arriving).
|
||||
if let Some(p) = self.reassembler.take_partial() {
|
||||
return Ok(p);
|
||||
return Ok(stamp_received(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +110,18 @@ 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) {
|
||||
if sock.send(PUNCH_MAGIC).is_err() {
|
||||
break;
|
||||
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;
|
||||
}
|
||||
}
|
||||
let delay_ms = if i < 15 { 200 } else { 2000 };
|
||||
i = i.saturating_add(1);
|
||||
@@ -160,34 +170,65 @@ 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, punch_timeout)
|
||||
Self::from_socket_punch(
|
||||
UdpSocket::bind(local)?,
|
||||
fallback_peer,
|
||||
expect_ip,
|
||||
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 n >= PUNCH_MAGIC.len() && &buf[..PUNCH_MAGIC.len()] == PUNCH_MAGIC =>
|
||||
if src.ip() == expect_ip
|
||||
&& n >= PUNCH_MAGIC.len()
|
||||
&& &buf[..PUNCH_MAGIC.len()] == PUNCH_MAGIC =>
|
||||
{
|
||||
observed = Some(src);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {} // stray datagram — keep waiting for a real punch
|
||||
// Stray, or a well-formed punch from someone who isn't the authenticated peer —
|
||||
// keep waiting for a real one.
|
||||
Ok(_) => {}
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
@@ -198,9 +239,6 @@ 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());
|
||||
@@ -482,4 +520,89 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,29 @@ 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();
|
||||
|
||||
@@ -180,11 +203,16 @@ fn real_main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"punktfunk-host {} (punktfunk_core ABI v{})",
|
||||
env!("PUNKTFUNK_VERSION"),
|
||||
punktfunk_core::ABI_VERSION
|
||||
);
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -208,8 +236,12 @@ 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")]
|
||||
crate::capture::dxgi::install_gpu_pref_hook();
|
||||
if !management_cli {
|
||||
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
|
||||
@@ -498,6 +530,10 @@ 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
|
||||
|
||||
@@ -56,6 +56,10 @@ 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 {
|
||||
@@ -63,6 +67,7 @@ impl Default for Options {
|
||||
Options {
|
||||
bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
|
||||
token: None,
|
||||
plugin_token: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +86,9 @@ 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,
|
||||
}
|
||||
@@ -119,6 +127,7 @@ pub async fn run(
|
||||
let app = app(
|
||||
state,
|
||||
Some(token),
|
||||
opts.plugin_token.filter(|t| !t.trim().is_empty()),
|
||||
opts.bind.port(),
|
||||
native,
|
||||
stats,
|
||||
@@ -131,6 +140,7 @@ 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>,
|
||||
@@ -142,6 +152,7 @@ fn app(
|
||||
stats,
|
||||
gamestream_enabled,
|
||||
token,
|
||||
plugin_token,
|
||||
port,
|
||||
});
|
||||
let (api_routes, api) = api_router_parts();
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
//! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere)
|
||||
//! or the bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5).
|
||||
//! 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.
|
||||
|
||||
use super::shared::*;
|
||||
use crate::gamestream::tls::PeerAddr;
|
||||
@@ -86,6 +93,27 @@ 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)",
|
||||
@@ -93,6 +121,31 @@ 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
|
||||
|
||||
@@ -42,6 +42,8 @@ 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,
|
||||
@@ -57,6 +59,7 @@ 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,
|
||||
@@ -374,6 +377,133 @@ 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);
|
||||
@@ -544,6 +674,7 @@ 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
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! 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 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.
|
||||
//! 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.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rand::RngCore;
|
||||
@@ -15,19 +22,32 @@ 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 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 (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`.
|
||||
pub fn load_or_generate() -> Result<String> {
|
||||
if let Ok(v) = std::env::var(ENV_VAR) {
|
||||
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) {
|
||||
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) {
|
||||
if let Some(tok) = parse_token(&contents, env_var) {
|
||||
return Ok(tok);
|
||||
}
|
||||
}
|
||||
@@ -37,29 +57,29 @@ pub fn load_or_generate() -> 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, &token)?;
|
||||
tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
|
||||
write_token(&path, env_var, &token)?;
|
||||
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Parse the token from the persisted file: accept either a bare token line or a
|
||||
/// `PUNKTFUNK_MGMT_TOKEN=<token>` line (the form we write, also valid as an EnvironmentFile).
|
||||
fn parse_token(contents: &str) -> Option<String> {
|
||||
/// `<KEY>=<token>` line (the form we write, also valid as an EnvironmentFile).
|
||||
fn parse_token(contents: &str, env_var: &str) -> Option<String> {
|
||||
let line = contents.lines().find(|l| !l.trim().is_empty())?.trim();
|
||||
let tok = line
|
||||
.strip_prefix("PUNKTFUNK_MGMT_TOKEN=")
|
||||
.strip_prefix(env_var)
|
||||
.and_then(|rest| rest.strip_prefix('='))
|
||||
.unwrap_or(line)
|
||||
.trim();
|
||||
(!tok.is_empty()).then(|| tok.to_string())
|
||||
}
|
||||
|
||||
/// 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");
|
||||
/// 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");
|
||||
pf_paths::write_secret_file(path, line.as_bytes())
|
||||
.with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
@@ -70,13 +90,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_bare_and_keyvalue_forms() {
|
||||
assert_eq!(parse_token("abc123\n").as_deref(), Some("abc123"));
|
||||
assert_eq!(parse_token("abc123\n", ENV_VAR).as_deref(), Some("abc123"));
|
||||
assert_eq!(
|
||||
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n").as_deref(),
|
||||
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n", ENV_VAR).as_deref(),
|
||||
Some("deadbeef")
|
||||
);
|
||||
assert_eq!(parse_token("\n \n"), None);
|
||||
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n"), None);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,9 +108,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, "cafef00d").unwrap();
|
||||
write_token(&path, ENV_VAR, "cafef00d").unwrap();
|
||||
let read = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(parse_token(&read).as_deref(), Some("cafef00d"));
|
||||
assert_eq!(parse_token(&read, ENV_VAR).as_deref(), Some("cafef00d"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -1265,6 +1265,10 @@ 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),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
mut ctrl_recv: quinn::RecvStream,
|
||||
ctrl_recv: quinn::RecvStream,
|
||||
initial_mode: punktfunk_core::Mode,
|
||||
codec: crate::encode::Codec,
|
||||
live_reconfig_ok: bool,
|
||||
@@ -47,9 +47,13 @@ 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 = io::read_msg(&mut ctrl_recv) => {
|
||||
msg = ctrl_reader.read_msg() => {
|
||||
let Ok(msg) = msg else { break }; // stream closed
|
||||
if let Ok(req) = Reconfigure::decode(&msg) {
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
@@ -1477,6 +1477,9 @@ 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);
|
||||
@@ -2265,6 +2268,9 @@ 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);
|
||||
@@ -2579,6 +2585,10 @@ 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).
|
||||
|
||||
@@ -14,9 +14,16 @@
|
||||
//! 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 runs as SYSTEM. We
|
||||
//! `%ProgramData%\punktfunk` (see `pf_paths::create_private_dir`) and the task is admin-owned. 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;
|
||||
@@ -71,13 +78,15 @@ USAGE:
|
||||
|
||||
NAMES:
|
||||
A bare first-party name resolves into the @punktfunk scope: `playnite` installs
|
||||
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager.
|
||||
A scoped (@scope/pkg) or `punktfunk-plugin-*` name is used verbatim.
|
||||
@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.
|
||||
|
||||
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 as the host user;
|
||||
install only plugins you trust.
|
||||
turns the runner on. Plugins are operator-installed code that runs with operator
|
||||
privileges; install only plugins you trust.
|
||||
"
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -212,13 +221,66 @@ 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}).");
|
||||
println!("Plugin runner enabled and started ({TASK}, runs as LocalService).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -228,17 +290,173 @@ 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 {{ \
|
||||
$i = Get-ScheduledTaskInfo -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
\"$($t.State)\" }}"
|
||||
\"$($t.State)|$($t.Principal.UserId)\" }}"
|
||||
));
|
||||
match out.as_deref().map(str::trim) {
|
||||
Some("missing") | None => {
|
||||
@@ -248,7 +466,10 @@ fn status() -> Result<()> {
|
||||
);
|
||||
}
|
||||
Some(state) => {
|
||||
println!("runner: {TASK}\nstate: {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}");
|
||||
if state.eq_ignore_ascii_case("Disabled") {
|
||||
println!("\nEnable it with: punktfunk-host plugins enable");
|
||||
}
|
||||
|
||||
@@ -157,6 +157,13 @@ 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
|
||||
|
||||
@@ -31,8 +31,9 @@ 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, and the runner
|
||||
task runs as SYSTEM.
|
||||
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).
|
||||
|
||||
```powershell
|
||||
punktfunk-host plugins add playnite # or: rom-manager
|
||||
@@ -62,11 +63,13 @@ 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`.
|
||||
A scoped (`@scope/pkg`) or `punktfunk-plugin-*` name is used verbatim, so third-party plugins work
|
||||
the same way.
|
||||
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.
|
||||
|
||||
> Plugins are operator-installed code that runs as the host user — they can launch games and run
|
||||
> Plugins are operator-installed code with operator privileges — they can launch games and run
|
||||
> commands. Install only plugins you trust, from a registry you control.
|
||||
|
||||
## ROM Manager
|
||||
|
||||
@@ -37,7 +37,12 @@
|
||||
// `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.
|
||||
#define ABI_VERSION 8
|
||||
// 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
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -1112,6 +1117,12 @@ 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
|
||||
|
||||
@@ -291,13 +291,17 @@ 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, SYSTEM, restart-on-failure) but leave it
|
||||
; Register the plugin/script runner's scheduled task (boot, restart-on-failure) but leave it
|
||||
; DISABLED - the runner is OPT-IN (inert until you add scripts/plugins). Enable it when ready:
|
||||
; Enable-ScheduledTask -TaskName PunktfunkScripting
|
||||
; 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.
|
||||
; 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 '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"""; \
|
||||
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"""; \
|
||||
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
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
# 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 mgmt token + identity cert from
|
||||
# ~/.config/punktfunk/{mgmt-token,cert.pem} (written by the host's `serve`) — no env editing.
|
||||
# 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.
|
||||
[Unit]
|
||||
Description=punktfunk plugin/script runner
|
||||
Documentation=https://git.unom.io/unom/punktfunk
|
||||
@@ -29,6 +31,19 @@ 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
|
||||
|
||||
@@ -55,15 +55,40 @@ 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` 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.
|
||||
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.
|
||||
|
||||
## Typical flow after pulling new code
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<#
|
||||
Rebuild the plugin/script runner bundle from the CURRENT sdk/ source and lay it out next to the
|
||||
host exe, so `punktfunk-host plugins ...` works and the PunktfunkScripting task runs new code.
|
||||
|
||||
powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1
|
||||
powershell -ExecutionPolicy Bypass -File scripts\windows\build-scripting.ps1 -EnableTask
|
||||
|
||||
WHY the layout matters: the plugins CLI forwards package ops (add/remove/list) to the runner, and
|
||||
on Windows it resolves the runner RELATIVE TO THE RUNNING EXE - <exe-dir>\bun\bun.exe and
|
||||
<exe-dir>\scripting\runner-cli.js (crates\punktfunk-host\src\plugins.rs). deploy-host.ps1 runs the
|
||||
service out of target\release, so a bundle sitting only in the installed {app} leaves the freshly
|
||||
built exe reporting "the plugin runner isn't installed". We therefore deploy next to EVERY host exe
|
||||
we can find: the built one, and whatever the PunktfunkHost service actually runs.
|
||||
|
||||
The PunktfunkScripting task ships DISABLED (opt-in). We do not silently enable it - pass
|
||||
-EnableTask on a dev box you are validating plugins on.
|
||||
#>
|
||||
param(
|
||||
[switch]$EnableTask # enable + restart PunktfunkScripting (opt-in)
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = Split-Path (Split-Path $PSScriptRoot) # scripts\windows -> repo root
|
||||
$sdk = Join-Path $repo 'sdk'
|
||||
$task = 'PunktfunkScripting'
|
||||
|
||||
# bun is both the bundler AND the runtime the runner ships on. Honor $env:BUN_EXE (what CI sets)
|
||||
# before falling back to the dev box's portable copy - the same one build-web.ps1 uses.
|
||||
$bun = $env:BUN_EXE
|
||||
if (-not ($bun -and (Test-Path $bun))) { $bun = 'C:\Users\Public\bun\bin\bun.exe' }
|
||||
if (-not (Test-Path $bun)) { throw "bun not found at $bun (set `$env:BUN_EXE to override)" }
|
||||
|
||||
Write-Host "== punktfunk plugin/script runner deploy =="
|
||||
Write-Host "bun : $bun"
|
||||
|
||||
# --- 1. build the self-contained bundle ------------------------------------------------------
|
||||
# --target=bun inlines effect + the SDK into ONE js; the operator's plugin import stays a runtime
|
||||
# import. --ignore-scripts skips the `prepare` codegen (it wants ../api/openapi.json, not needed).
|
||||
$stage = Join-Path $repo 'target\scripting'
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
$bundle = Join-Path $stage 'runner-cli.js'
|
||||
|
||||
Push-Location $sdk
|
||||
try {
|
||||
Write-Host "bun install ..."
|
||||
& $bun install --frozen-lockfile --ignore-scripts
|
||||
if ($LASTEXITCODE -ne 0) { throw "sdk bun install failed (exit $LASTEXITCODE)" }
|
||||
Write-Host "bun build src/runner-cli.ts -> $bundle"
|
||||
& $bun build src/runner-cli.ts --target=bun "--outfile=$bundle"
|
||||
if ($LASTEXITCODE -ne 0) { throw "runner bundle build failed (exit $LASTEXITCODE)" }
|
||||
} finally { Pop-Location }
|
||||
|
||||
# Same gate CI and the .deb builder use: 'attempt=' only survives when the dynamic plugin import was
|
||||
# bundled as a RUNTIME import. If it is missing the bundle would load but never run a plugin.
|
||||
if (-not (Select-String -Path $bundle -Pattern 'attempt=' -Quiet)) {
|
||||
throw "runner bundle missing the dynamic plugin import - wrong build"
|
||||
}
|
||||
Write-Host "bundle : OK ($([math]::Round((Get-Item $bundle).Length / 1KB)) KB)"
|
||||
|
||||
# --- 2. work out every host-exe dir that needs the payload ------------------------------------
|
||||
$targets = New-Object System.Collections.Generic.List[string]
|
||||
function Add-Target([string]$exe) {
|
||||
if (-not $exe) { return }
|
||||
$dir = Split-Path $exe
|
||||
if ($dir -and (Test-Path $dir) -and -not ($targets -contains $dir)) { $targets.Add($dir) | Out-Null }
|
||||
}
|
||||
|
||||
# a) the binary deploy-host.ps1 just built - what you invoke by hand to test the new CLI.
|
||||
Add-Target (Join-Path $repo 'target\release\punktfunk-host.exe')
|
||||
|
||||
# b) whatever the service actually runs (an installed {app} on a box set up via setup.exe). The
|
||||
# binPath carries args and may be quoted, so pull the .exe out with a regex.
|
||||
$qc = & sc.exe qc 'PunktfunkHost' 2>$null
|
||||
if ($qc) {
|
||||
$line = $qc | Select-String 'BINARY_PATH_NAME' | Select-Object -First 1
|
||||
if ($line -and "$line" -match '([A-Za-z]:\\[^"]*?punktfunk-host\.exe)') { Add-Target $Matches[1] }
|
||||
}
|
||||
|
||||
if ($targets.Count -eq 0) { throw "no punktfunk-host.exe found - run deploy-host.ps1 first." }
|
||||
|
||||
# --- 3. lay out <exe-dir>\scripting\{runner-cli.js,scripting-run.cmd} + <exe-dir>\bun\bun.exe ---
|
||||
# Stop a LIVE runner first: it holds bun.exe (and the bundle) open, so copying over them fails with a
|
||||
# sharing violation. Step 4 restarts it. Reap stragglers by command line the way build-web.ps1 does -
|
||||
# schtasks /end does not always take the child bun with it.
|
||||
$existing = Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
|
||||
if ($existing -and $existing.State -eq 'Running') {
|
||||
Write-Host "stopping $task (holds bun.exe open) ..."
|
||||
& schtasks /end /tn $task 2>$null | Out-Null
|
||||
Get-CimInstance Win32_Process -Filter "Name='bun.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CommandLine -match 'runner-cli\.js' } |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep 2
|
||||
}
|
||||
|
||||
foreach ($dir in $targets) {
|
||||
Write-Host ""
|
||||
Write-Host "deploying -> $dir"
|
||||
$scrDir = Join-Path $dir 'scripting'
|
||||
$bunDir = Join-Path $dir 'bun'
|
||||
New-Item -ItemType Directory -Force -Path $scrDir, $bunDir | Out-Null
|
||||
|
||||
Copy-Item $bundle (Join-Path $scrDir 'runner-cli.js') -Force
|
||||
Copy-Item (Join-Path $PSScriptRoot 'scripting-run.cmd') (Join-Path $scrDir 'scripting-run.cmd') -Force
|
||||
# The runner import()s the operator's .ts plugins, so bun ships WITH it rather than being assumed
|
||||
# on PATH - exactly what the installer does. Skip the copy when it is already the same build.
|
||||
$bunDst = Join-Path $bunDir 'bun.exe'
|
||||
if (-not (Test-Path $bunDst) -or (Get-Item $bunDst).Length -ne (Get-Item $bun).Length) {
|
||||
Copy-Item $bun $bunDst -Force
|
||||
}
|
||||
Write-Host " scripting\runner-cli.js, scripting\scripting-run.cmd, bun\bun.exe"
|
||||
}
|
||||
|
||||
# --- 3.5 de-privilege: LocalService principal + secret read grants ----------------------------
|
||||
# The runner task runs as NT AUTHORITY\LocalService (NOT SYSTEM - a plugin defect must cost a
|
||||
# throwaway account). Converge a task registered as SYSTEM by an older installer or by hand, and
|
||||
# grant LocalService read on the two files the runner's connect() needs: the scoped plugin-token
|
||||
# and the TLS-pin cert.pem. NEVER mgmt-token (full admin). Mirrors `punktfunk-host plugins enable`.
|
||||
if ($existing) {
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount
|
||||
Set-ScheduledTask -TaskName $task -Principal $principal | Out-Null
|
||||
Write-Host ""
|
||||
Write-Host "task : $task principal -> NT AUTHORITY\LocalService"
|
||||
$cfg = Join-Path $env:ProgramData 'punktfunk'
|
||||
foreach ($secret in @('plugin-token', 'cert.pem')) {
|
||||
$file = Join-Path $cfg $secret
|
||||
if (Test-Path $file) {
|
||||
& "$env:SystemRoot\System32\icacls.exe" $file /grant:r '*S-1-5-19:(R)' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $file" }
|
||||
} else {
|
||||
Write-Host "note : $file not found - start the host once, then re-run (the runner"
|
||||
Write-Host " needs LocalService read on it to authenticate)."
|
||||
}
|
||||
}
|
||||
# Unit dirs get inheritable (RX,WA): bun's module loader opens unit files requesting
|
||||
# FILE_WRITE_ATTRIBUTES on top of read - plain (RX) makes every import EPERM (found
|
||||
# on-glass). WA can only touch timestamps/attribute bits, never content.
|
||||
foreach ($unitDir in @('plugins', 'scripts')) {
|
||||
$dirPath = Join-Path $cfg $unitDir
|
||||
New-Item -ItemType Directory -Force -Path $dirPath | Out-Null
|
||||
& "$env:SystemRoot\System32\icacls.exe" $dirPath /grant:r '*S-1-5-19:(OI)(CI)(RX,WA)' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $dirPath" }
|
||||
}
|
||||
# State root gets inheritable Modify - the ONE writable grant, so a plugin can persist its
|
||||
# config/cache under plugin-state\<name> (@punktfunk/host's pluginStateDir). Code dirs stay
|
||||
# RX+WA, secrets stay R; only this dir is writable.
|
||||
$stateDir = Join-Path $cfg 'plugin-state'
|
||||
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
|
||||
& "$env:SystemRoot\System32\icacls.exe" $stateDir /grant:r '*S-1-5-19:(OI)(CI)(M)' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $stateDir" }
|
||||
# Ingest inbox gets inheritable Modify for BUILTIN\Users - the INVERSE grant, so an
|
||||
# interactive-user app (the Playnite exporter) can drop ingest\<plugin>\ data the LocalService
|
||||
# runner reads. The one Users-writable carve-out in the otherwise Users-read-only tree.
|
||||
$ingestDir = Join-Path $cfg 'ingest'
|
||||
New-Item -ItemType Directory -Force -Path $ingestDir | Out-Null
|
||||
& "$env:SystemRoot\System32\icacls.exe" $ingestDir /grant:r '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "warn : icacls grant failed on $ingestDir" }
|
||||
}
|
||||
|
||||
# --- 4. the opt-in scheduled task -------------------------------------------------------------
|
||||
# Registered DISABLED by the installer; the runner is inert until there is automation to run. We only
|
||||
# bounce it when it already exists, and only enable it when explicitly asked. ($existing was captured
|
||||
# in step 3, BEFORE we stopped a running instance - so State still reflects how we found the box.)
|
||||
Write-Host ""
|
||||
if (-not $existing) {
|
||||
Write-Host "note : the $task task is not registered on this box (installer registers it)."
|
||||
Write-Host " The plugins CLI still works - it runs the runner directly."
|
||||
}
|
||||
elseif ($EnableTask) {
|
||||
if ($existing.State -eq 'Disabled') {
|
||||
Enable-ScheduledTask -TaskName $task | Out-Null
|
||||
Write-Host "task : $task ENABLED (-EnableTask)"
|
||||
}
|
||||
& schtasks /end /tn $task 2>$null | Out-Null
|
||||
Start-Sleep 2
|
||||
& schtasks /run /tn $task | Out-Null
|
||||
Write-Host "task : $task restarted on the new bundle"
|
||||
}
|
||||
elseif ($existing.State -eq 'Disabled') {
|
||||
Write-Host "note : $task is DISABLED (opt-in, as shipped) - the new bundle is staged but no"
|
||||
Write-Host " runner is supervising plugins. Enable it for on-glass validation with:"
|
||||
Write-Host " powershell -File scripts\windows\build-scripting.ps1 -EnableTask"
|
||||
Write-Host " (or: punktfunk-host plugins enable)"
|
||||
}
|
||||
else {
|
||||
& schtasks /end /tn $task 2>$null | Out-Null
|
||||
Start-Sleep 2
|
||||
& schtasks /run /tn $task | Out-Null
|
||||
Write-Host "task : $task restarted on the new bundle"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "DONE - plugin/script runner deployed."
|
||||
@@ -1,26 +1,40 @@
|
||||
<#
|
||||
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).
|
||||
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).
|
||||
|
||||
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 web console step runs.
|
||||
build/start, which stops this script before the later steps run.
|
||||
#>
|
||||
param(
|
||||
[switch]$EnableScriptingTask # forwarded to build-scripting.ps1 (opt-in task)
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$here = $PSScriptRoot
|
||||
|
||||
Write-Host "=========================================="
|
||||
Write-Host " 1/2 host service"
|
||||
Write-Host " 1/3 host service"
|
||||
Write-Host "=========================================="
|
||||
& (Join-Path $here 'deploy-host.ps1')
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=========================================="
|
||||
Write-Host " 2/2 web console"
|
||||
Write-Host " 2/3 web console"
|
||||
Write-Host "=========================================="
|
||||
& (Join-Path $here 'build-web.ps1')
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "DONE - host + web console redeployed."
|
||||
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."
|
||||
|
||||
@@ -6,8 +6,10 @@ 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 mgmt
|
||||
rem token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). No env editing.
|
||||
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.
|
||||
setlocal EnableExtensions
|
||||
|
||||
set "BUN=%~dp0..\bun\bun.exe"
|
||||
|
||||
+47
-4
@@ -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` → `<config_dir>/mgmt-token` |
|
||||
| Token | `{ token }` → `PUNKTFUNK_MGMT_TOKEN` → `PUNKTFUNK_PLUGIN_TOKEN` → `<config_dir>/plugin-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,8 +115,13 @@ 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 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).
|
||||
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).
|
||||
|
||||
## Events
|
||||
|
||||
@@ -150,6 +155,43 @@ 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
|
||||
@@ -260,7 +302,8 @@ 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\mgmt-token` — run the task as an account that can).
|
||||
`%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).
|
||||
|
||||
## Compatibility
|
||||
|
||||
|
||||
+57
-5
@@ -2,9 +2,17 @@
|
||||
// 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 (else <config_dir>/mgmt-token)
|
||||
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
|
||||
// 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.
|
||||
//
|
||||
// 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,
|
||||
@@ -44,6 +52,47 @@ 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");
|
||||
@@ -73,11 +122,14 @@ 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_MGMT_TOKEN, pass { token }, or run where " +
|
||||
`the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
|
||||
"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")})`,
|
||||
);
|
||||
}
|
||||
const caPath = process.env.PUNKTFUNK_MGMT_CA;
|
||||
|
||||
@@ -30,6 +30,10 @@ 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,
|
||||
|
||||
+62
-13
@@ -13,21 +13,47 @@ 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`); a scoped name (`@…/…`), the unscoped plugin convention
|
||||
* (`punktfunk-plugin-…`), or any name with a `/` is used verbatim.
|
||||
* `@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.
|
||||
*/
|
||||
export const resolvePackage = (name: string): string => {
|
||||
export const resolvePackage = (
|
||||
name: string,
|
||||
opts: ResolveOptions = {},
|
||||
): string => {
|
||||
const n = name.trim();
|
||||
if (!n) throw new Error("empty plugin name");
|
||||
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
|
||||
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;
|
||||
};
|
||||
|
||||
/** 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 });
|
||||
@@ -66,7 +92,7 @@ export const ensureBunfig = (dir = pluginsDirDefault()): void => {
|
||||
}
|
||||
};
|
||||
|
||||
export interface PkgOpts {
|
||||
export interface PkgOpts extends ResolveOptions {
|
||||
/** Plugins dir. Default `<config_dir>/plugins`. */
|
||||
dir?: string;
|
||||
/** Line sink for progress. Default stdout. */
|
||||
@@ -82,7 +108,16 @@ 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 res = Bun.spawnSync([process.execPath, action, ...pkgs], {
|
||||
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, {
|
||||
cwd: dir,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
@@ -92,12 +127,26 @@ 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 =>
|
||||
runBun("add", names.map(resolvePackage), opts);
|
||||
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);
|
||||
};
|
||||
|
||||
/** Uninstall one or more plugins by friendly name or package. */
|
||||
/** 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. */
|
||||
export const removePlugins = (names: string[], opts: PkgOpts = {}): void =>
|
||||
runBun("remove", names.map(resolvePackage), opts);
|
||||
runBun(
|
||||
"remove",
|
||||
names.map((n) => resolvePackage(n, { allowPublicRegistry: true })),
|
||||
opts,
|
||||
);
|
||||
|
||||
export interface InstalledPlugin {
|
||||
/** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
//
|
||||
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
|
||||
// here):
|
||||
// add <name…> install first-party (playnite, rom-manager) or punktfunk-plugin-* packages
|
||||
// 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
|
||||
// remove <name…> uninstall
|
||||
// list list installed plugin packages
|
||||
//
|
||||
@@ -44,7 +46,12 @@ const positionals = (): string[] => {
|
||||
return out;
|
||||
};
|
||||
|
||||
const pkgOpts = { dir: options.pluginsDir };
|
||||
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 runPkgOp = (
|
||||
op: (names: string[], o: typeof pkgOpts) => void,
|
||||
|
||||
+213
-3
@@ -13,13 +13,15 @@
|
||||
// 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: a world-writable unit file is refused loudly.
|
||||
// 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).
|
||||
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";
|
||||
@@ -53,9 +55,217 @@ export interface Unit {
|
||||
const defaultLog = (line: string) =>
|
||||
console.log(`${new Date().toISOString()} ${line}`);
|
||||
|
||||
/** The sshd rule (RFC §9.1/§9.4): refuse group/world-writable unit files, loudly. */
|
||||
// ---- 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.
|
||||
*/
|
||||
const fileIsSafe = (file: string, log: (l: string) => void): boolean => {
|
||||
if (process.platform === "win32") return true; // config dir is DACL'd; ACL check is a follow-up
|
||||
if (process.platform === "win32") return windowsFileIsSafe(file, log);
|
||||
try {
|
||||
const mode = fs.statSync(file).mode & 0o022;
|
||||
if (mode !== 0) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Connection/config resolution helpers. `pluginStateDir` is the writable location a supervised
|
||||
// plugin persists into — the one dir the de-privileged Windows runner may write.
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { pluginIngestDir, pluginStateDir } from "../src/config.js";
|
||||
|
||||
describe("pluginStateDir", () => {
|
||||
let saved: string | undefined;
|
||||
beforeEach(() => {
|
||||
saved = process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
|
||||
});
|
||||
|
||||
test("resolves <config_dir>/plugin-state[/name] and honors the config-dir override", () => {
|
||||
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg");
|
||||
expect(pluginStateDir()).toBe(path.join("/tmp", "pf-cfg", "plugin-state"));
|
||||
expect(pluginStateDir("rom-manager")).toBe(
|
||||
path.join("/tmp", "pf-cfg", "plugin-state", "rom-manager"),
|
||||
);
|
||||
});
|
||||
|
||||
test("the per-plugin dir is nested under the shared root", () => {
|
||||
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg2");
|
||||
expect(pluginStateDir("x").startsWith(pluginStateDir())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginIngestDir", () => {
|
||||
let saved: string | undefined;
|
||||
beforeEach(() => {
|
||||
saved = process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
|
||||
});
|
||||
|
||||
test("resolves <config_dir>/ingest[/name], distinct from plugin-state", () => {
|
||||
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg3");
|
||||
expect(pluginIngestDir()).toBe(path.join("/tmp", "pf-cfg3", "ingest"));
|
||||
expect(pluginIngestDir("playnite")).toBe(
|
||||
path.join("/tmp", "pf-cfg3", "ingest", "playnite"),
|
||||
);
|
||||
// the inbox (Users-write) is a different tree from state (LocalService-write)
|
||||
expect(pluginIngestDir("playnite")).not.toBe(pluginStateDir("playnite"));
|
||||
});
|
||||
});
|
||||
@@ -37,12 +37,28 @@ describe("resolvePackage", () => {
|
||||
expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager");
|
||||
});
|
||||
|
||||
test("passes through scoped, unscoped-convention, and pathed names verbatim", () => {
|
||||
test("passes @punktfunk-scoped names through verbatim (our registry, no gate)", () => {
|
||||
expect(resolvePackage("@punktfunk/plugin-playnite")).toBe(
|
||||
"@punktfunk/plugin-playnite",
|
||||
);
|
||||
expect(resolvePackage("@someone/plugin-x")).toBe("@someone/plugin-x");
|
||||
expect(resolvePackage("punktfunk-plugin-custom")).toBe("punktfunk-plugin-custom");
|
||||
});
|
||||
|
||||
test("refuses public-registry names without allowPublicRegistry", () => {
|
||||
expect(() => resolvePackage("punktfunk-plugin-custom")).toThrow(
|
||||
/public/i,
|
||||
);
|
||||
expect(() => resolvePackage("@someone/plugin-x")).toThrow(/public/i);
|
||||
expect(() => resolvePackage("some/registry-path")).toThrow(/public/i);
|
||||
});
|
||||
|
||||
test("passes public-registry names through with allowPublicRegistry", () => {
|
||||
const allow = { allowPublicRegistry: true };
|
||||
expect(resolvePackage("punktfunk-plugin-custom", allow)).toBe(
|
||||
"punktfunk-plugin-custom",
|
||||
);
|
||||
expect(resolvePackage("@someone/plugin-x", allow)).toBe(
|
||||
"@someone/plugin-x",
|
||||
);
|
||||
});
|
||||
|
||||
test("trims and rejects empty", () => {
|
||||
|
||||
+86
-1
@@ -5,7 +5,12 @@ 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 } from "../src/runner.js";
|
||||
import {
|
||||
discoverUnits,
|
||||
runner,
|
||||
superviseUnit,
|
||||
windowsSddlUnsafeReason,
|
||||
} from "../src/runner.js";
|
||||
|
||||
const TOKEN = "runner-token";
|
||||
// Fixtures live under sdk/ so the generated plugin files can resolve "effect" and the SDK.
|
||||
@@ -104,6 +109,86 @@ 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();
|
||||
|
||||
Reference in New Issue
Block a user