Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faefbae830 | ||
|
|
44fa12a298 | ||
|
|
55a3d8b919 | ||
|
|
a02014ec19 | ||
|
|
5f55b820bc | ||
|
|
57703fe31c | ||
|
|
90a3304c2b | ||
|
|
017867f211 | ||
|
|
ef0af3b558 | ||
|
|
535e95c4c0 | ||
|
|
b385f0a031 | ||
|
|
7528fd48a9 | ||
|
|
c68e0be688 | ||
|
|
71c1970b93 | ||
|
|
f373dffb5e | ||
|
|
28b6633058 | ||
|
|
c58217e403 | ||
|
|
23f9b1130e | ||
|
|
c2c71f0ac5 | ||
|
|
712ee935d6 | ||
|
|
a2aa0a5f97 | ||
|
|
d6b9862f1e | ||
|
|
f4e39a442b | ||
|
|
a0577cb86e | ||
|
|
2a62fe7857 |
+11
-1
@@ -187,7 +187,17 @@ jobs:
|
||||
|
||||
- name: Verify generated header is committed & up to date
|
||||
run: |
|
||||
cargo build -p punktfunk-core --locked
|
||||
cargo build -p punktfunk-core --locked >/tmp/core-build.log 2>&1 \
|
||||
|| { cat /tmp/core-build.log; exit 1; }
|
||||
cat /tmp/core-build.log
|
||||
# build.rs demotes a cbindgen failure to a warning and then writes NOTHING — the
|
||||
# checked-in header stays untouched and the drift check below stays green while the
|
||||
# header is silently stale. So first assert the regeneration actually happened.
|
||||
# (cargo replays build-script warnings from cache, so this holds on cached builds too.)
|
||||
grep -q "punktfunk-core: wrote" /tmp/core-build.log
|
||||
if grep -q "cbindgen failed" /tmp/core-build.log; then
|
||||
echo "cbindgen failed to parse the ABI surface — header NOT regenerated" && exit 1
|
||||
fi
|
||||
git config --global --add safe.directory "$PWD"
|
||||
git diff --exit-code include/punktfunk_core.h \
|
||||
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
|
||||
|
||||
@@ -184,6 +184,20 @@ but latches nothing; only the full-length attempts that follow hand down negotia
|
||||
classification is a pure function with tests
|
||||
(`pf_capture::linux::first_frame_timeout_tests`).
|
||||
|
||||
### Windows host — an idle box can sleep again (virtual-mic stream idle-stop)
|
||||
|
||||
🛑 **Installing the host blocked system sleep forever, client connected or not.** The
|
||||
host-lifetime mic pump kept a WASAPI render stream RUNNING on the virtual-mic device
|
||||
(typically the Steam Streaming Microphone), writing silence 24/7 — and any running stream makes
|
||||
the Windows audio stack hold a kernel power request ("An audio stream is currently in use" in
|
||||
`powercfg /requests`, attributed to that device) that vetoes sleep. The render loop now stops
|
||||
the stream (`IAudioClient::Stop`; the client stays initialized and the mic *endpoint* keeps
|
||||
existing for apps to bind) after 10 s of silence-only output and resumes on the next mic frame
|
||||
within one device period — below the jitter buffer's prime depth, so nothing is audible.
|
||||
Streaming sessions still hold the box awake through their own `PowerRequest` assertions, as
|
||||
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
|
||||
third-party virtual audio driver misbehaves while its render side is paused.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
Generated
+1
@@ -1116,6 +1116,7 @@ dependencies = [
|
||||
name = "display-disturb"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
|
||||
+2
-2
@@ -66,8 +66,8 @@ ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["unom"]
|
||||
repository = "https://git.unom.io/unom/punktfunk"
|
||||
|
||||
@@ -4753,6 +4753,10 @@
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||
},
|
||||
"edid_lock": {
|
||||
"type": "boolean",
|
||||
"description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off."
|
||||
},
|
||||
"game_session": {
|
||||
"$ref": "#/components/schemas/GameSession",
|
||||
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
||||
|
||||
@@ -43,10 +43,12 @@ struct OutputReady {
|
||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||
enum DecodeEvent {
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||
Au(Frame, bool),
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
|
||||
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
|
||||
/// reassembler's later `frames_dropped` climb for the loss (the feeder already fired the RFI
|
||||
/// request).
|
||||
Au(Frame, u32),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
@@ -603,7 +605,11 @@ fn feeder_loop(
|
||||
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
|
||||
// counting AUs.
|
||||
let au_first = frame.part.is_none_or(|p| p.first);
|
||||
let gap = au_first && client.note_frame_index(frame.frame_index);
|
||||
let gap = if au_first {
|
||||
client.note_frame_index(frame.frame_index)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// 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.
|
||||
@@ -691,9 +697,12 @@ fn dispatch_event(
|
||||
match ev {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo). Credited arm: the gap width
|
||||
// pre-covers the reassembler's ~120 ms-later `frames_dropped` climb for the same loss,
|
||||
// so a fast RFI anchor that heals in between isn't re-frozen by it (the double-arm
|
||||
// race — see `ReanchorGate::arm_expecting_drops`).
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
}
|
||||
// One entry per AU (parts share the pts): the completing delivery carries it.
|
||||
if f.complete {
|
||||
|
||||
@@ -222,8 +222,13 @@ pub(super) fn run_sync(
|
||||
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||
if client.note_frame_index(frame.frame_index) {
|
||||
gate.arm(Instant::now());
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// `frames_dropped` climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race — see
|
||||
// `ReanchorGate::arm_expecting_drops`).
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
}
|
||||
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||
|
||||
@@ -200,7 +200,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
|
||||
///
|
||||
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -214,7 +214,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -245,7 +245,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
|
||||
///
|
||||
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
|
||||
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -60,7 +60,7 @@ const TAG_HID_RAW: u8 = 0x05;
|
||||
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
|
||||
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
|
||||
/// poll thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -99,7 +99,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
|
||||
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
|
||||
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -54,7 +54,7 @@ mod probe;
|
||||
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
_vm: *mut jni::sys::JavaVM,
|
||||
_reserved: *mut std::ffi::c_void,
|
||||
@@ -74,7 +74,7 @@ pub extern "system" fn JNI_OnLoad(
|
||||
/// `NativeBridge.abiVersion(): Int` — the core's C-ABI version. A non-error return is the
|
||||
/// scaffold's proof that `System.loadLibrary` found the `.so`, the JNI symbol resolved, and the
|
||||
/// linked `punktfunk-core` is the one we expect.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -83,7 +83,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
|
||||
}
|
||||
|
||||
/// `NativeBridge.coreVersion(): String` — the crate version, proving JNI string marshaling works.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::time::Duration;
|
||||
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
|
||||
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
|
||||
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -40,7 +40,7 @@ fn client(handle: jlong) -> Option<&'static SessionHandle> {
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -53,7 +53,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport
|
||||
|
||||
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` crosses.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl
|
||||
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
|
||||
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
|
||||
/// counter, newest wins.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -88,7 +88,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferTe
|
||||
|
||||
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
|
||||
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or −1.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -106,7 +106,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchTe
|
||||
|
||||
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
|
||||
/// clipboard's current text (the host is pasting our offer).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -125,7 +125,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeTe
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -144,7 +144,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
|
||||
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
|
||||
/// can never split a UTF-8 sequence.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -36,7 +36,7 @@ fn note_error(e: &punktfunk_core::error::PunktfunkError) {
|
||||
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
|
||||
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
|
||||
/// handle / `""` fingerprint.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr
|
||||
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
||||
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -74,7 +74,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
||||
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
|
||||
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
|
||||
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -120,7 +120,7 @@ fn force_parts_sysprop() -> bool {
|
||||
/// budget: the normal path passes a short value, the no-PIN "request access" path a long one (≥ the
|
||||
/// host's approval-park window) so a slow operator approval lands on this same parked connection
|
||||
/// rather than timing the client out first. Returns an opaque handle, or 0 on failure.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
@@ -322,7 +322,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -344,7 +344,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -363,7 +363,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQ
|
||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
||||
/// connect. `""` on a `0` handle.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -388,7 +388,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
|
||||
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
|
||||
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
|
||||
/// handle. Cheap (one atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -413,7 +413,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
|
||||
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
|
||||
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
|
||||
/// atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -433,7 +433,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
|
||||
@@ -35,7 +35,7 @@ fn send_event(handle: jlong, kind: InputKind, code: u32, x: i32, y: i32, flags:
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
||||
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
||||
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
|
||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
||||
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -86,7 +86,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
|
||||
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
|
||||
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -103,7 +103,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
|
||||
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
|
||||
/// (libei touchscreen / wlroots / SendInput).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -128,7 +128,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
|
||||
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
|
||||
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -151,7 +151,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
|
||||
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
|
||||
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -168,7 +168,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSu
|
||||
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
|
||||
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
|
||||
/// (design/pen-tablet-input.md §7). `0` handle → false.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -197,7 +197,7 @@ const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
|
||||
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
|
||||
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
|
||||
/// while in range (Kotlin side — see `StylusStream`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -264,7 +264,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
|
||||
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -296,7 +296,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
|
||||
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
|
||||
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -318,7 +318,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
|
||||
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
|
||||
/// (−32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -343,7 +343,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
|
||||
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
|
||||
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
///
|
||||
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
|
||||
/// the `Auto` rule inside the predicate itself.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -399,7 +399,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionRe
|
||||
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -415,7 +415,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
|
||||
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
|
||||
/// own report rate (~250–500 Hz) — the direct-buffer read avoids a JNI array copy per report.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -455,7 +455,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
|
||||
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
|
||||
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
|
||||
/// the capture diffs, the host holds per-slot state.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -485,7 +485,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
|
||||
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
|
||||
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
|
||||
/// controller's report rate.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
|
||||
_env: JNIEnv,
|
||||
|
||||
@@ -19,7 +19,7 @@ use super::{jni_guard, lock_recover, SessionHandle};
|
||||
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||||
/// No-op if already started.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -91,7 +91,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -116,7 +116,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
|
||||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
||||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -140,7 +140,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecL
|
||||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
|
||||
/// device).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -161,7 +161,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecode
|
||||
|
||||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||||
/// session). No-op on `0`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -210,7 +210,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -312,7 +312,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||||
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||||
/// build too. Cheap; safe on the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -346,7 +346,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
|
||||
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
|
||||
/// pure `jni` + an atomic store, so it links on the host build too.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||||
/// streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -398,7 +398,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
|
||||
/// closing the session). No-op on `0`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -422,7 +422,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
|
||||
/// the rest of the session streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -457,7 +457,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
|
||||
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -484,7 +484,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
@@ -530,7 +530,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
@@ -553,7 +553,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSel
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
@@ -594,7 +594,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi
|
||||
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
|
||||
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
|
||||
/// no captured audio leaves the process.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -617,7 +617,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
|
||||
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
|
||||
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -23,7 +23,7 @@ const PROBE_RESULT_LEN: usize = 6;
|
||||
/// **briefly pausing video**. Non-blocking: poll
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
|
||||
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -54,7 +54,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
|
||||
///
|
||||
/// Layout (doubles so one array carries both the counts and the percentages):
|
||||
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -10,7 +10,7 @@ use jni::JNIEnv;
|
||||
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
|
||||
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
|
||||
/// Returns true if at least one datagram went out.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -774,12 +774,22 @@ public final class PunktfunkConnection {
|
||||
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
|
||||
/// after close.
|
||||
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
|
||||
noteFrameIndexGapWidth(frameIndex) > 0
|
||||
}
|
||||
|
||||
/// Like `noteFrameIndexGap`, but reports the gap's WIDTH — how many frames this arrival revealed
|
||||
/// as missing (0 = none). The post-loss re-anchor gate arms with the width
|
||||
/// (`ReanchorGate.arm(expectingDrops:)`) so the reassembler's later `framesDropped` climb for
|
||||
/// the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm race).
|
||||
/// Same core side effect as `noteFrameIndex` (the throttled RFI request); call it for every
|
||||
/// received AU. Returns 0 after close.
|
||||
public func noteFrameIndexGapWidth(_ frameIndex: UInt32) -> UInt32 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return false }
|
||||
var gap = false
|
||||
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
|
||||
return gap
|
||||
guard let h = handle, !closeRequested else { return 0 }
|
||||
var width: UInt32 = 0
|
||||
_ = punktfunk_connection_note_frame_index_ex(h, frameIndex, &width)
|
||||
return width
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
|
||||
@@ -55,6 +55,16 @@ final class ReanchorGate: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// `arm()` for a loss detected as a frame-index gap of a known width
|
||||
/// (`PunktfunkConnection.noteFrameIndexGapWidth`). Pre-credits the reassembler's later
|
||||
/// `framesDropped` climb for the same lost frames, so `poll` doesn't re-freeze a stream an
|
||||
/// RFI anchor already healed (the double-arm race — the Rust gate's docs tell the story).
|
||||
func arm(expectingDrops: UInt64) {
|
||||
lock.lock()
|
||||
punktfunk_reanchor_gate_arm_expecting_drops(ptr, expectingDrops)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
|
||||
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
|
||||
/// `decoderKeyframe: false` — VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
|
||||
|
||||
@@ -57,6 +57,8 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
|
||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
||||
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
|
||||
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
||||
/// Pump-side events (loss recovery, format seeding) — the stage-2 sibling of StreamPump's log.
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
|
||||
|
||||
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
||||
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
||||
@@ -921,7 +923,11 @@ public final class Stage2Pipeline {
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze —
|
||||
// the following concealed frames are withheld until a clean re-anchor.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race).
|
||||
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
|
||||
if gapWidth > 0 { reanchorGate.arm(expectingDrops: UInt64(gapWidth)) }
|
||||
onFrame?(au)
|
||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
@@ -932,6 +938,21 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if format == nil {
|
||||
// No decodable format yet: the opening IDR's parameter sets never
|
||||
// arrived (or never parsed), and under the host's infinite GOP nothing
|
||||
// re-delivers them unless we ASK. Without this the guard below drops
|
||||
// every AU silently, forever — the field "black stream, zero recovery
|
||||
// requests" state (2026-08-12): the host streams perfectly, the client
|
||||
// shows nothing and says nothing. awaitingIDR routes through the same
|
||||
// 100 ms-throttled recovery.request() at the top of the loop.
|
||||
if !awaitingIDR {
|
||||
pumpLog.warning(
|
||||
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
|
||||
)
|
||||
}
|
||||
awaitingIDR = true
|
||||
}
|
||||
guard let f = format, !token.isStopped else { return true }
|
||||
if decoder.decode(au: au, format: f) {
|
||||
decodeFailRun = 0
|
||||
|
||||
@@ -100,7 +100,11 @@ final class StreamPump {
|
||||
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race).
|
||||
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
|
||||
if gapWidth > 0 { gate.arm(expectingDrops: UInt64(gapWidth)) }
|
||||
onFrame?(au)
|
||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||
if let f = idrFormat {
|
||||
@@ -116,6 +120,21 @@ final class StreamPump {
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if format == nil {
|
||||
// No decodable format yet: the opening IDR's parameter sets never
|
||||
// arrived (or never parsed), and under the host's infinite GOP nothing
|
||||
// re-delivers them unless we ASK. Without this the format guard below
|
||||
// drops every AU silently, forever — the field "black stream, zero
|
||||
// recovery requests" state (2026-08-12). awaitingIDR routes through the
|
||||
// same 100 ms-throttled recovery.request() at the top of the loop.
|
||||
if !awaitingIDR {
|
||||
awaitingSince = Date()
|
||||
pumpLog.warning(
|
||||
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
|
||||
)
|
||||
}
|
||||
awaitingIDR = true
|
||||
}
|
||||
let failed = layer.status == .failed
|
||||
if failed {
|
||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||
|
||||
@@ -870,6 +870,23 @@ fn deliver_deep_link(url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The crate's one runtime env mutation, isolated so `main.rs`'s `deny(unsafe_code)` covers
|
||||
/// everything else and the exemption is a named function rather than a whole call site.
|
||||
#[allow(unsafe_code)]
|
||||
fn clear_steam_sdl_device_filter() {
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
// SAFETY: called at the top of `run()`, before GTK init or any other thread
|
||||
// exists in this process — nothing reads the environment concurrently.
|
||||
unsafe { std::env::remove_var(var) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -879,15 +896,7 @@ pub fn run() -> glib::ExitCode {
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
||||
// physical pad Steam Input has virtualized; the Settings controller list needs the
|
||||
// real devices (same rationale as the session binary).
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
}
|
||||
clear_steam_sdl_device_filter();
|
||||
// Headless paths (no GTK window).
|
||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||
return crate::cli::headless_pair(&pin);
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`, since edition 2024: clearing Steam's SDL device filter and the spawn
|
||||
// test's `HOME` scoping mutate the process env, which is now an unsafe call. Both carry a named
|
||||
// `#[allow(unsafe_code)]` with the proof at the site; everything else stays compiler-refused.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
||||
|
||||
@@ -128,6 +128,8 @@ mod tests {
|
||||
/// that is merely capped. One test, one `HOME` — the stores are read from it, so this
|
||||
/// deliberately does not split into several that would race over the same env var.
|
||||
#[test]
|
||||
// The crate's one test env mutation (the `HOME` scoping below) — see main.rs's deny note.
|
||||
#[allow(unsafe_code)]
|
||||
fn the_plan_carries_resolved_settings_not_defaults() {
|
||||
use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile};
|
||||
use pf_client_core::trust::{KnownHost, KnownHosts, Settings};
|
||||
@@ -135,7 +137,10 @@ mod tests {
|
||||
let home = std::env::temp_dir().join(format!("pf-spawn-test-{}", std::process::id()));
|
||||
let cfg = home.join(".config/punktfunk");
|
||||
std::fs::create_dir_all(&cfg).unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
// SAFETY: the only env-mutating test in this binary (see the doc above — one test, one
|
||||
// `HOME`, deliberately not split). Parallel tests may `getenv` concurrently; glibc keeps
|
||||
// replaced environ storage alive, and every reader tolerates either value.
|
||||
unsafe { std::env::set_var("HOME", &home) };
|
||||
|
||||
// A device whose owner has set a bitrate, and a host bound to a profile that raises it
|
||||
// further — the two layers the spec has to carry.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! (portrait paths starting with `/` load from disk), the GPU-only dev path.
|
||||
|
||||
use crate::session_main::{
|
||||
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos,
|
||||
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, stats_tier, window_pos,
|
||||
};
|
||||
use pf_client_core::gamepad::is_steam_deck;
|
||||
use pf_client_core::{discovery, library, trust, wol};
|
||||
@@ -141,11 +141,18 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
let json_status = arg_flag("--json-status");
|
||||
let settings_at_start = trust::Settings::load();
|
||||
// The console's window and its input models are built ONCE, from the global defaults, and
|
||||
// live across every launch — so the presentation-tier fields below (stats tier, touch and
|
||||
// mouse model, shortcut inhibit, match-window, render scale) are latched here and a per-host
|
||||
// profile cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec,
|
||||
// audio, pad) is re-resolved per launch and does honor the binding. Closing that gap means
|
||||
// rebuilding the presenter's models per launch — profiles P4 territory, not P0.
|
||||
// live across every launch — so the presentation-tier fields below (touch and mouse model,
|
||||
// shortcut inhibit, match-window, render scale) are latched here and a per-host profile
|
||||
// cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio,
|
||||
// pad) is re-resolved per launch and does honor the binding. Closing the rest of that gap
|
||||
// means rebuilding the presenter's models per launch — profiles P4 territory, not P0.
|
||||
//
|
||||
// ⚠ The STATS TIER used to be latched here too, and that was a bug people hit: the console's
|
||||
// own settings screen writes the tier to the file and redraws its row, so the choice looked
|
||||
// taken while every stream kept the tier the process started on — "no matter what I select
|
||||
// the overlay is stuck on Detailed", cured only by restarting the app. It now rides
|
||||
// `SessionParams` per launch (`stats_verbosity`), so the value below only seeds the loop
|
||||
// until the first stream. Anything else moved off this snapshot has to travel the same way.
|
||||
let latched_mouse = settings_at_start.mouse_mode();
|
||||
|
||||
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
|
||||
@@ -162,11 +169,8 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
),
|
||||
fullscreen: fullscreen_mode(),
|
||||
window_pos: window_pos(),
|
||||
// `--stats` forces the overlay visible without demoting a richer chosen tier.
|
||||
stats_verbosity: match settings_at_start.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
// Seeds the loop only — every launch carries its own freshly resolved tier.
|
||||
stats_verbosity: stats_tier(&settings_at_start),
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
mouse_mode: settings_at_start.mouse_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
|
||||
+86
-11
@@ -13,7 +13,11 @@
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`: edition 2024 makes `std::env::set_var`/`remove_var` unsafe (WP20 —
|
||||
// the env-mutation class made visible), and this bin's three single-threaded-startup env
|
||||
// writes carry documented SAFETY comments under localized `#[allow(unsafe_code)]` (the
|
||||
// pf-update idiom). A `forbid` cannot be overridden at those sites and refuses the file.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
@@ -116,6 +120,28 @@ mod session_main {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// The stats-overlay tier a session starts on: the resolved setting, except that
|
||||
/// `--stats` (tooling/debug runs) forces the overlay VISIBLE without demoting an
|
||||
/// explicitly chosen richer tier.
|
||||
///
|
||||
/// One helper because three callers need the identical rule — both run modes' presenter
|
||||
/// options and the per-launch [`session_params`] — and a fourth reading of it would be
|
||||
/// the bug this is here to prevent.
|
||||
pub(crate) fn stats_tier(settings: &trust::Settings) -> trust::StatsVerbosity {
|
||||
stats_tier_with(settings.stats_verbosity(), arg_flag("--stats"))
|
||||
}
|
||||
|
||||
/// [`stats_tier`]'s rule, with argv lifted out so it is testable.
|
||||
pub(crate) fn stats_tier_with(
|
||||
chosen: trust::StatsVerbosity,
|
||||
stats_flag: bool,
|
||||
) -> trust::StatsVerbosity {
|
||||
match chosen {
|
||||
trust::StatsVerbosity::Off if stats_flag => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
|
||||
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
|
||||
/// "auto" policy keys off this.
|
||||
@@ -420,6 +446,12 @@ mod session_main {
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
profile,
|
||||
// Presentation-tier, carried per launch rather than read once by the run loop:
|
||||
// the console streams many sessions through ONE loop, so this is the only way a
|
||||
// tier the user picked between streams (or one a host's profile carries) reaches
|
||||
// the overlay before the app is restarted. Single mode passes the same value its
|
||||
// presenter options already hold, so it changes nothing there.
|
||||
stats_verbosity: stats_tier(settings),
|
||||
// Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity):
|
||||
// advertised only when the presenter has real on-glass latch stamps
|
||||
// (VK_KHR_present_wait) — without them there is no latch grid to report. The
|
||||
@@ -505,12 +537,18 @@ mod session_main {
|
||||
/// initialises, so a call placed after them leaves the triage tool describing a device
|
||||
/// that cannot decode while the streaming path decodes on it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)] // the two SAFETY-commented single-threaded-startup env writes below
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
match std::env::var("RADV_PERFTEST") {
|
||||
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
|
||||
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
|
||||
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
|
||||
// SAFETY: called at the very top of `run()`, before this process creates any
|
||||
// thread — the Vulkan loader, SDL, and the session runtime all start later.
|
||||
Ok(v) if !v.is_empty() => unsafe {
|
||||
std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}"))
|
||||
},
|
||||
// SAFETY: as above — single-threaded startup.
|
||||
_ => unsafe { std::env::set_var("RADV_PERFTEST", TOKEN) },
|
||||
}
|
||||
tracing::info!(
|
||||
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
|
||||
@@ -804,7 +842,13 @@ mod session_main {
|
||||
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
|
||||
] {
|
||||
if std::env::var_os(var).is_none() && !value.is_empty() {
|
||||
std::env::set_var(var, value);
|
||||
// SAFETY: still the single-threaded startup stretch of `run()` — the
|
||||
// early-exit probes above return out of the process, and everything that
|
||||
// spawns threads (the session, the console, SDL) only starts below.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::set_var(var, value)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -818,7 +862,12 @@ mod session_main {
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
// SAFETY: as the settings block above — single-threaded startup, before SDL
|
||||
// (the reader of these variables) or any other thread exists.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::remove_var(var)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,12 +975,7 @@ mod session_main {
|
||||
window_title: format!("Punktfunk · {title}"),
|
||||
fullscreen,
|
||||
window_pos: window_pos(),
|
||||
// `--stats` forces the overlay visible (tooling/debug runs) without
|
||||
// demoting an explicitly chosen richer tier.
|
||||
stats_verbosity: match settings.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
stats_verbosity: stats_tier(&settings),
|
||||
touch_mode: settings.touch_mode(),
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
@@ -1000,6 +1044,37 @@ mod session_main {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use trust::StatsVerbosity as V;
|
||||
|
||||
/// `--stats` is a floor, never a ceiling: it lifts Off to Normal and leaves every
|
||||
/// richer chosen tier alone. Both run modes' presenter options AND the per-launch
|
||||
/// params read this one rule, which is the point of having it.
|
||||
#[test]
|
||||
fn the_stats_flag_lifts_off_and_demotes_nothing() {
|
||||
assert_eq!(stats_tier_with(V::Off, true), V::Normal);
|
||||
assert_eq!(stats_tier_with(V::Off, false), V::Off);
|
||||
for chosen in [V::Compact, V::Normal, V::Detailed] {
|
||||
assert_eq!(stats_tier_with(chosen, true), chosen);
|
||||
assert_eq!(stats_tier_with(chosen, false), chosen);
|
||||
}
|
||||
}
|
||||
|
||||
/// The console reads the file ONCE for its window, so a tier changed between streams
|
||||
/// can only reach the overlay by riding the launch. Guards the wiring the field exists
|
||||
/// for: whatever settings a launch resolved is what the params carry.
|
||||
#[test]
|
||||
fn a_launch_carries_the_tier_its_settings_resolved() {
|
||||
let mut s = trust::Settings::default();
|
||||
for chosen in [V::Off, V::Compact, V::Normal, V::Detailed] {
|
||||
s.set_stats_verbosity(chosen);
|
||||
assert_eq!(stats_tier_with(s.stats_verbosity(), false), chosen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "punktfunk-client-windows"
|
||||
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
# Not workspace-inherited (1.82): windows-reactor at the pinned rev declares rust-version 1.95+
|
||||
# Not workspace-inherited (1.85): windows-reactor at the pinned rev declares rust-version 1.95+
|
||||
# and edition 2024. rust-toolchain.toml pins 1.96, so this records reality rather than raising it.
|
||||
rust-version = "1.96"
|
||||
license.workspace = true
|
||||
|
||||
@@ -318,10 +318,10 @@ fn edit_editor(
|
||||
if !addr.is_empty() {
|
||||
h.addr = addr;
|
||||
}
|
||||
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
|
||||
if p != 0 {
|
||||
h.port = p;
|
||||
}
|
||||
if let Ok(p) = port_draft.borrow().trim().parse::<u16>()
|
||||
&& p != 0
|
||||
{
|
||||
h.port = p;
|
||||
}
|
||||
let mac = mac_draft.borrow().trim().to_string();
|
||||
h.mac = if mac.is_empty() {
|
||||
@@ -1094,12 +1094,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.close_button_text("Cancel")
|
||||
.is_open(pending.is_some())
|
||||
.on_closed(move |r: ContentDialogResult| {
|
||||
if r == ContentDialogResult::Primary {
|
||||
if let Some((fp, _)) = &pending {
|
||||
let mut known = KnownHosts::load();
|
||||
known.remove_by_fp(fp);
|
||||
let _ = known.save();
|
||||
}
|
||||
if r == ContentDialogResult::Primary
|
||||
&& let Some((fp, _)) = &pending
|
||||
{
|
||||
let mut known = KnownHosts::load();
|
||||
known.remove_by_fp(fp);
|
||||
let _ = known.save();
|
||||
}
|
||||
sf.call(None); // re-renders the page; the row is gone on the next load
|
||||
})
|
||||
|
||||
@@ -515,35 +515,37 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
move || {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-probe".into())
|
||||
.spawn(move || loop {
|
||||
// A spawned session/browse child is running: the shell is hidden
|
||||
// (nobody sees the pips) and one of these hosts is mid-stream —
|
||||
// probing it is pure noise. Sleep through and sweep after it ends.
|
||||
if shared.session.lock().unwrap().is_running() {
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
continue;
|
||||
}
|
||||
let handles: Vec<_> = KnownHosts::load()
|
||||
.hosts
|
||||
.into_iter()
|
||||
.filter(|h| !h.addr.is_empty())
|
||||
.map(|h| {
|
||||
std::thread::spawn(move || {
|
||||
(
|
||||
h.fp_hex,
|
||||
NativeClient::probe(
|
||||
&h.addr,
|
||||
h.port,
|
||||
Duration::from_millis(2500),
|
||||
),
|
||||
)
|
||||
.spawn(move || {
|
||||
loop {
|
||||
// A spawned session/browse child is running: the shell is hidden
|
||||
// (nobody sees the pips) and one of these hosts is mid-stream —
|
||||
// probing it is pure noise. Sleep through and sweep after it ends.
|
||||
if shared.session.lock().unwrap().is_running() {
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
continue;
|
||||
}
|
||||
let handles: Vec<_> = KnownHosts::load()
|
||||
.hosts
|
||||
.into_iter()
|
||||
.filter(|h| !h.addr.is_empty())
|
||||
.map(|h| {
|
||||
std::thread::spawn(move || {
|
||||
(
|
||||
h.fp_hex,
|
||||
NativeClient::probe(
|
||||
&h.addr,
|
||||
h.port,
|
||||
Duration::from_millis(2500),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let map: HashMap<String, bool> =
|
||||
handles.into_iter().filter_map(|h| h.join().ok()).collect();
|
||||
set_probed.call(map);
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
.collect();
|
||||
let map: HashMap<String, bool> =
|
||||
handles.into_iter().filter_map(|h| h.join().ok()).collect();
|
||||
set_probed.call(map);
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -560,14 +562,15 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let anim_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
|
||||
let (anim, set_anim) = cx.use_async_state((Option::<Screen>::None, 1.0f64));
|
||||
cx.use_effect(screen.clone(), {
|
||||
let (s, set_anim, gen) = (screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
|
||||
let (s, set_anim, generation) =
|
||||
(screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
|
||||
move || {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let mine = gen.fetch_add(1, SeqCst) + 1;
|
||||
let mine = generation.fetch_add(1, SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
const STEPS: u32 = 14;
|
||||
for i in 0..=STEPS {
|
||||
if gen.load(SeqCst) != mine {
|
||||
if generation.load(SeqCst) != mine {
|
||||
return; // a newer navigation superseded this tween
|
||||
}
|
||||
let p = f64::from(i) / f64::from(STEPS);
|
||||
@@ -593,18 +596,18 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let nav_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
|
||||
let (nav_anim, set_nav_anim) = cx.use_async_state((String::new(), 1.0f64));
|
||||
cx.use_effect(settings_nav.clone(), {
|
||||
let (s, set_nav_anim, gen) = (
|
||||
let (s, set_nav_anim, generation) = (
|
||||
settings_nav.clone(),
|
||||
set_nav_anim.clone(),
|
||||
nav_gen.borrow().clone(),
|
||||
);
|
||||
move || {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let mine = gen.fetch_add(1, SeqCst) + 1;
|
||||
let mine = generation.fetch_add(1, SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
const STEPS: u32 = 14;
|
||||
for i in 0..=STEPS {
|
||||
if gen.load(SeqCst) != mine {
|
||||
if generation.load(SeqCst) != mine {
|
||||
return; // a newer section switch superseded this tween
|
||||
}
|
||||
let p = f64::from(i) / f64::from(STEPS);
|
||||
@@ -628,10 +631,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let add_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
|
||||
let (add_anim, set_add_anim) = cx.use_async_state(0.0f64);
|
||||
cx.use_effect(show_add, {
|
||||
let (set_add_anim, gen) = (set_add_anim.clone(), add_gen.borrow().clone());
|
||||
let (set_add_anim, generation) = (set_add_anim.clone(), add_gen.borrow().clone());
|
||||
move || {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let mine = gen.fetch_add(1, SeqCst) + 1;
|
||||
let mine = generation.fetch_add(1, SeqCst) + 1;
|
||||
if !show_add {
|
||||
set_add_anim.call(0.0);
|
||||
return;
|
||||
@@ -639,7 +642,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
std::thread::spawn(move || {
|
||||
const STEPS: u32 = 12;
|
||||
for i in 0..=STEPS {
|
||||
if gen.load(SeqCst) != mine {
|
||||
if generation.load(SeqCst) != mine {
|
||||
return; // reopened/closed mid-tween — a newer run owns the value
|
||||
}
|
||||
let p = f64::from(i) / f64::from(STEPS);
|
||||
|
||||
@@ -82,10 +82,11 @@ fn main() {
|
||||
// where the user's hosts already are. A hand-off that finds nobody falls through and this
|
||||
// process becomes the shell that opens it, so the link is never simply lost.
|
||||
let link = deeplink::positional_url(&args);
|
||||
if let Some(url) = &link {
|
||||
if !deeplink::claim_primary() && deeplink::forward_to_primary(url) {
|
||||
return;
|
||||
}
|
||||
if let Some(url) = &link
|
||||
&& !deeplink::claim_primary()
|
||||
&& deeplink::forward_to_primary(url)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if flag("--discover") {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[package]
|
||||
name = "pf-capture"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host frame capture: Linux PipeWire portal + Windows IDD direct-push capturers behind one Capturer trait."
|
||||
|
||||
@@ -64,7 +64,7 @@ pub(crate) fn hybrid_hook_hits() -> u64 {
|
||||
// on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a
|
||||
// different core), so the "same-thread, no flush needed" assumption was wrong.
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn FlushInstructionCache(h: *mut c_void, base: *const c_void, size: usize) -> i32;
|
||||
fn GetCurrentProcess() -> *mut c_void;
|
||||
}
|
||||
|
||||
@@ -164,10 +164,10 @@ fn read_appid(conn: &RustConnection, root: Window, atom: Atom) -> Option<u32> {
|
||||
.ok()?
|
||||
.reply()
|
||||
.ok()?;
|
||||
// Bound rather than returned inline: the iterator borrows `reply`, and as a tail
|
||||
// expression its temporary would outlive it.
|
||||
let id = reply.value32()?.next();
|
||||
id
|
||||
// Inline is sound since edition 2024: tail-expression temporaries now drop BEFORE the
|
||||
// block's locals, so the iterator borrowing `reply` no longer outlives it (the 2021 rule
|
||||
// forced a `let` binding here).
|
||||
reply.value32()?.next()
|
||||
}
|
||||
|
||||
/// The whole decision, separated from X so it can be tested: an overlay is up exactly when
|
||||
|
||||
@@ -104,6 +104,19 @@ pub struct SessionParams {
|
||||
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
|
||||
/// re-reading any store (design/client-settings-profiles.md §5.2).
|
||||
pub profile: Option<String>,
|
||||
/// The stats-overlay tier THIS launch resolved to — the globals, or the profile bound to
|
||||
/// this host. Presentation-tier, like [`profile`](Self::profile): the session controller
|
||||
/// never reads it, it rides along so the presenter can adopt it when a browse-mode launch
|
||||
/// starts.
|
||||
///
|
||||
/// That adoption is the whole point. The console (Gaming Mode / Decky) builds its window
|
||||
/// and its run loop ONCE and streams many sessions through them, so a tier taken only from
|
||||
/// the loop's start-of-process options could never change again — a user picking a tier in
|
||||
/// the console's settings screen saw the row move, the file updated, and every stream keep
|
||||
/// the old overlay until the app was restarted. Carrying it per launch is what lets the
|
||||
/// choice land on the next stream, and it makes a profile's `stats_verbosity` reach the
|
||||
/// console too. The in-stream cycle chord still wins for the rest of the stream it moved.
|
||||
pub stats_verbosity: crate::trust::StatsVerbosity,
|
||||
/// Advertise `quic::CLIENT_CAP_PHASE_LOCK`: this embedder's presenter has REAL on-glass
|
||||
/// latch stamps (`VK_KHR_present_wait`) and will feed [`latch_grid`](Self::latch_grid),
|
||||
/// so the pump sends the ~1 Hz `PhaseReport`s the host phase-locks its capture tick to
|
||||
@@ -885,7 +898,12 @@ fn pump(
|
||||
Some(exp) => {
|
||||
if let Some(gap) = index_gap(exp, frame.frame_index) {
|
||||
let now = Instant::now();
|
||||
gate.arm(now);
|
||||
// Credited arm: the reassembler books these same lost frames into
|
||||
// `frames_dropped` up to ~120 ms from now; the credit keeps that
|
||||
// delayed climb from re-freezing a stream the RFI anchor healed in
|
||||
// between (the double-arm race — see
|
||||
// `ReanchorGate::arm_expecting_drops`).
|
||||
gate.arm_expecting_drops(now, u64::from(gap));
|
||||
next_expected_index = Some(frame.frame_index.wrapping_add(1));
|
||||
// The gap carries the PRECISE lost range — [first missing, newest
|
||||
// received - 1] — so this is the one recovery signal that can drive true
|
||||
|
||||
@@ -2241,7 +2241,7 @@ mod parity {
|
||||
// `desc.Height` rows at `RowPitch` and the chroma plane follows at byte
|
||||
// offset `RowPitch * desc.Height`, so `total` below is exactly the mapped
|
||||
// extent and every sub-slice read is inside it. `Unmap` pairs the `Map`.
|
||||
let out = unsafe {
|
||||
unsafe {
|
||||
let src: ID3D11Resource = pool.cast().expect("pool -> resource");
|
||||
let dst: ID3D11Resource = staging.cast().expect("staging -> resource");
|
||||
self.ctx
|
||||
@@ -2268,8 +2268,7 @@ mod parity {
|
||||
}
|
||||
self.ctx.Unmap(&staging, 0);
|
||||
out
|
||||
};
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[package]
|
||||
name = "pf-clipboard"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host shared clipboard: per-OS session-clipboard backends behind one HostClipboard + the QUIC clipboard-plane coordinator."
|
||||
|
||||
@@ -382,13 +382,13 @@ impl SettingsScreen {
|
||||
}
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
};
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
return match msg {
|
||||
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1054,12 +1054,16 @@ mod tests {
|
||||
fn fake_home() {
|
||||
use std::sync::OnceLock;
|
||||
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
|
||||
let dir = HOME.get_or_init(|| {
|
||||
HOME.get_or_init(|| {
|
||||
let dir = std::env::temp_dir().join(format!("pf-settings-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
|
||||
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
|
||||
// set after the closure ran on EVERY call, so two parallel tests could race the
|
||||
// write; setting once under the OnceLock is what makes this sound.)
|
||||
unsafe { std::env::set_var("HOME", &dir) };
|
||||
dir
|
||||
});
|
||||
std::env::set_var("HOME", dir);
|
||||
}
|
||||
|
||||
/// Render the screen once so its strip and list carry real geometry, then hand back a
|
||||
|
||||
@@ -49,13 +49,16 @@ fn motion_matches_the_shared_vectors() {
|
||||
fn fake_home() {
|
||||
use std::sync::OnceLock;
|
||||
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
|
||||
let dir = HOME.get_or_init(|| {
|
||||
HOME.get_or_init(|| {
|
||||
let dir = std::env::temp_dir().join(format!("pf-console-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("HOME", &dir);
|
||||
dir.clone()
|
||||
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
|
||||
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
|
||||
// re-set after the closure ran on EVERY call, so two parallel tests could race the
|
||||
// write; setting once under the OnceLock is what makes this sound.)
|
||||
unsafe { std::env::set_var("HOME", &dir) };
|
||||
dir
|
||||
});
|
||||
std::env::set_var("HOME", dir);
|
||||
}
|
||||
|
||||
fn hosts() -> Vec<HostRow> {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
[package]
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display (control IOCTLs + IDD-push frame transport)."
|
||||
publish = false
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[package]
|
||||
name = "pf-encode"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host video encode: NVENC/VAAPI/AMF/QSV/Vulkan-Video/PyroWave/openh264 backends behind one Encoder trait."
|
||||
|
||||
@@ -1652,7 +1652,7 @@ impl NvencCudaEncoder {
|
||||
return Err(nvenc_status::call_err(
|
||||
"register_resource (CUDADEVICEPTR)",
|
||||
e,
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
self.ring.push(RingSlot {
|
||||
@@ -2779,6 +2779,20 @@ mod tests {
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use pf_zerocopy::cuda::DeviceBuffer;
|
||||
|
||||
/// Env knob for the `#[ignore]`d hardware spikes, which every caller's doc says to run ALONE
|
||||
/// with `--test-threads=1` (they mutate process env and own the GPU).
|
||||
fn set_env(key: &str, val: impl AsRef<std::ffi::OsStr>) {
|
||||
// SAFETY: only reached from the manually-run `--test-threads=1` hardware tests, so no
|
||||
// other thread exists in this process to read or write the environment concurrently.
|
||||
unsafe { std::env::set_var(key, val) };
|
||||
}
|
||||
|
||||
/// [`set_env`]'s companion; the same single-threaded-run contract.
|
||||
fn remove_env(key: &str) {
|
||||
// SAFETY: as `set_env` — single-threaded manual test run, no concurrent env access.
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
/// The 10-bit input mapping is load-bearing in a way a smoke test can't reach: pick the wrong
|
||||
/// NVENC format for a packed 2:10:10:10 capture and the encoder reads the words as 8-bit
|
||||
/// `ARGB` — a picture that decodes, looks *almost* right, and is silently 8-bit with the
|
||||
@@ -3315,8 +3329,8 @@ mod tests {
|
||||
|
||||
// Isolate the split variable: sub-frame off, and open explicitly split-DISABLED so the
|
||||
// switch below is a real change rather than a no-op.
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
@@ -3428,8 +3442,8 @@ mod tests {
|
||||
}
|
||||
|
||||
enc.flush().ok();
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **spike S1b**, the other half of S1: an in-place `splitEncodeMode` change that
|
||||
@@ -3468,7 +3482,7 @@ mod tests {
|
||||
const SETTLE: u32 = 16;
|
||||
let two = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
|
||||
// Separate buffers rotated per frame, so identical content can't let the encoder
|
||||
// skip-code everything and erase the difference we are trying to measure.
|
||||
@@ -3482,7 +3496,7 @@ mod tests {
|
||||
|
||||
// Returns (early-half p50 µs, late-half p50 µs, median bytes/AU).
|
||||
let run_leg = |open_split: &str, switch_to: Option<u32>| -> (u128, u128, usize) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", open_split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", open_split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
@@ -3554,9 +3568,15 @@ mod tests {
|
||||
|
||||
println!("S1b @ {W}x{H}@60 HEVC 8-bit, {} Mbps CBR:", BPS / 1_000_000);
|
||||
println!(" (early = first half of the measured window, late = second half)");
|
||||
println!(" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU");
|
||||
println!(" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU");
|
||||
println!(" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU");
|
||||
println!(
|
||||
" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU"
|
||||
);
|
||||
println!(
|
||||
" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU"
|
||||
);
|
||||
println!(
|
||||
" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU"
|
||||
);
|
||||
if c_early > c_late + c_late / 8 {
|
||||
println!(
|
||||
" ⇒ leg C SETTLES ({c_early} → {c_late} us): the in-place switch is not \
|
||||
@@ -3583,8 +3603,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
let _ = (a_bytes, b_bytes, c_bytes);
|
||||
}
|
||||
|
||||
@@ -3618,8 +3638,8 @@ mod tests {
|
||||
|
||||
// Open split-DISABLED, and leave sub-frame at its Linux default (ON where the GPU
|
||||
// advertises SUBFRAME_READBACK) — that is the fleet shape the arbitration starts from.
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
@@ -3664,7 +3684,7 @@ mod tests {
|
||||
"S1c SKIPPED: sub-frame is off at open on this GPU/driver, so there is no pair to \
|
||||
flip — the arbitration reduces to S1a's plain split switch here."
|
||||
);
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3715,7 +3735,7 @@ mod tests {
|
||||
}
|
||||
|
||||
enc.flush().ok();
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **the D5 confirm** (design §2 defect D5), the one claim in that list that was
|
||||
@@ -3755,12 +3775,12 @@ mod tests {
|
||||
// produced a spurious "D5 REFUTED" on the first run of this test.
|
||||
let run = |split: Option<&str>, subframe: Option<&str>| -> (u128, bool) {
|
||||
match split {
|
||||
Some(v) => std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", v),
|
||||
None => std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"),
|
||||
Some(v) => set_env("PUNKTFUNK_SPLIT_ENCODE", v),
|
||||
None => remove_env("PUNKTFUNK_SPLIT_ENCODE"),
|
||||
}
|
||||
match subframe {
|
||||
Some(v) => std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", v),
|
||||
None => std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"),
|
||||
Some(v) => set_env("PUNKTFUNK_NVENC_SUBFRAME", v),
|
||||
None => remove_env("PUNKTFUNK_NVENC_SUBFRAME"),
|
||||
}
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
@@ -3841,8 +3861,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **what is the real split ceiling on this GPU?** Feeds WP1.1: we want to use
|
||||
@@ -3870,13 +3890,13 @@ mod tests {
|
||||
const WARMUP: u32 = 8;
|
||||
const MEASURED: u32 = 24;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
|
||||
|
||||
// → (requested mode, mode actually opened, p50 µs, engines the driver reports)
|
||||
let run = |split: &str| -> (u32, u128, i32) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
@@ -3940,11 +3960,7 @@ mod tests {
|
||||
};
|
||||
println!(
|
||||
" req {label} → opened_mode={opened:<2} {} {us:>6} us/frame{vs} [engines={engines}]",
|
||||
if honoured {
|
||||
"HONOURED"
|
||||
} else {
|
||||
"FELL BACK"
|
||||
}
|
||||
if honoured { "HONOURED" } else { "FELL BACK" }
|
||||
);
|
||||
}
|
||||
println!(
|
||||
@@ -3952,8 +3968,8 @@ mod tests {
|
||||
HONOURED but no faster than DISABLE was accepted and did nothing."
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — the live split arbitration end to end (WP3). Opens a 4K session that the
|
||||
@@ -3975,9 +3991,9 @@ mod tests {
|
||||
const H: u32 = 2160;
|
||||
let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
set_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
|
||||
@@ -4045,8 +4061,8 @@ mod tests {
|
||||
max_forced_split_mode(enc_engines)
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
// The verdict cache is process-global: leaving this session's result in it would steer
|
||||
// every later test that opens the same config with the split env unset (the D5 legs do
|
||||
// exactly that).
|
||||
@@ -4086,14 +4102,14 @@ mod tests {
|
||||
})
|
||||
.unwrap_or((3840, 2160, 60));
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
// 10-bit input: the packed 2:10:10:10 PQ path is how a Main10 session is actually fed here
|
||||
// (`bit_depth`/`hdr` are DERIVED from the input format, never trusted from the args).
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| rgb10_frame(w, h, i)).collect();
|
||||
|
||||
let run = |split: &str| -> (u128, u8, usize) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::X2Rgb10,
|
||||
@@ -4157,8 +4173,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **THE BITS/FRAME CURVE**, the measurement this whole programme has been blind
|
||||
@@ -4190,7 +4206,7 @@ mod tests {
|
||||
})
|
||||
.unwrap_or((3840, 2160, 60));
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
// Sweep CONTENT DETAIL, not nominal bitrate. Pure noise is incompressible, so a low
|
||||
// bitrate target simply overshoots (measured: 719 KB/AU against a 104 KB quota) and every
|
||||
@@ -4207,7 +4223,7 @@ mod tests {
|
||||
let frames: Vec<CapturedFrame> =
|
||||
(0..4).map(|i| noise_nv12_frame(w, h, i, block)).collect();
|
||||
let run = |split: &str| -> (u128, usize) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
@@ -4250,8 +4266,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR).
|
||||
@@ -4658,12 +4674,12 @@ mod tests {
|
||||
struct EnvGuard;
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "4");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "1");
|
||||
set_env("PUNKTFUNK_NVENC_SLICES", "4");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "1");
|
||||
let _guard = EnvGuard;
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
@@ -4770,8 +4786,8 @@ mod tests {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
// Defaults under test — make sure another test's knobs aren't leaking in.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
@@ -4866,8 +4882,8 @@ mod tests {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
// The ceiling under test is the negotiated one, not the operator override.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
@@ -4911,16 +4927,16 @@ mod tests {
|
||||
struct EnvGuard;
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
let _guard = EnvGuard;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
|
||||
// Escape 1: explicit single slice — no boundaries to cut, chunked poll disarmed.
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "1");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
set_env("PUNKTFUNK_NVENC_SLICES", "1");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit");
|
||||
@@ -4943,8 +4959,8 @@ mod tests {
|
||||
|
||||
// Escape 2: sub-frame readback vetoed — slices stay (default 4) but chunked poll
|
||||
// disarms and the plain poll path carries the session.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit");
|
||||
|
||||
@@ -425,11 +425,11 @@ pub fn run_from_args(args: &[String]) -> Result<()> {
|
||||
/// the priority intent (it arrives explicitly in `Hello`) and the worker path itself (nothing here
|
||||
/// spawns a worker, and a stale value in a core dump is just noise).
|
||||
fn sanitize_env() {
|
||||
// Single-threaded — this runs before anything in this process creates a thread, which is the
|
||||
// one situation where mutating the environment is sound (the `getenv` race the house rule
|
||||
// about `set_var` is about needs a second thread).
|
||||
for k in ["PYROWAVE_QUEUE_PRIORITY", "PUNKTFUNK_ENCODE_WORKER"] {
|
||||
std::env::remove_var(k);
|
||||
// SAFETY: single-threaded — this runs before anything in this process creates a thread,
|
||||
// which is the one situation where mutating the environment is sound (the `getenv` race
|
||||
// `remove_var`'s contract is about needs a second thread).
|
||||
unsafe { std::env::remove_var(k) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1616,7 +1616,9 @@ impl Encoder for NvencD3d11Encoder {
|
||||
let frame = match &captured.payload {
|
||||
FramePayload::D3d11(f) => f,
|
||||
FramePayload::Cpu(_) => {
|
||||
bail!("NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)")
|
||||
bail!(
|
||||
"NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)"
|
||||
)
|
||||
}
|
||||
};
|
||||
// The capturer recreates its D3D11 device on a desktop switch (secure/Winlogon) and may come
|
||||
@@ -2882,8 +2884,12 @@ mod tests {
|
||||
let two = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
|
||||
|
||||
// Isolate the split variable exactly as the Linux spike does.
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
// SAFETY: this `#[ignore]`d hardware spike is run alone (manual RTX-box run, one test),
|
||||
// so no other thread exists to read or write the environment concurrently.
|
||||
unsafe {
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
}
|
||||
|
||||
// SAFETY: (test-only) the same straight-line D3D11/DXGI setup as `nvenc_reconfigure_no_idr`.
|
||||
unsafe {
|
||||
@@ -3007,8 +3013,11 @@ mod tests {
|
||||
enc.flush().ok();
|
||||
}
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
// SAFETY: as the set above — single-threaded manual test run, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
|
||||
/// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[package]
|
||||
name = "pf-frame"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host shared frame/format vocabulary: CapturedFrame, PixelFormat, HDR metadata, thread QoS, and the Windows DXGI capture identity."
|
||||
|
||||
@@ -21,11 +21,11 @@ mod imp {
|
||||
type Bool = i32;
|
||||
|
||||
#[link(name = "winmm")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn timeBeginPeriod(uPeriod: u32) -> u32;
|
||||
}
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn GetCurrentProcess() -> Handle;
|
||||
fn SetPriorityClass(hProcess: Handle, dwPriorityClass: u32) -> Bool;
|
||||
fn SetThreadExecutionState(esFlags: u32) -> u32;
|
||||
@@ -46,11 +46,11 @@ mod imp {
|
||||
simple_reason: *const u16,
|
||||
}
|
||||
#[link(name = "dwmapi")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn DwmEnableMMCSS(fEnableMMCSS: Bool) -> i32; // HRESULT
|
||||
}
|
||||
#[link(name = "avrt")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn AvSetMmThreadCharacteristicsW(TaskName: *const u16, TaskIndex: *mut u32) -> Handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[package]
|
||||
name = "pf-gpu"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host GPU vendor/adapter enumeration, selection preference, and active-session accounting."
|
||||
publish = false
|
||||
|
||||
+28
-28
@@ -169,7 +169,7 @@ mod kmt {
|
||||
}
|
||||
|
||||
#[link(name = "gdi32")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn D3DKMTOpenAdapterFromLuid(arg: *mut OpenAdapterFromLuid) -> i32;
|
||||
fn D3DKMTQueryAdapterInfo(arg: *mut QueryAdapterInfo) -> i32;
|
||||
fn D3DKMTCloseAdapter(arg: *mut CloseAdapter) -> i32;
|
||||
@@ -500,12 +500,12 @@ pub fn pick(
|
||||
env_substr: Option<&str>,
|
||||
) -> Option<(usize, PickSource)> {
|
||||
let mut preference_missing = false;
|
||||
if pref.mode == GpuMode::Manual {
|
||||
if let Some(want) = &pref.gpu {
|
||||
match find_preferred(gpus, want) {
|
||||
Some(i) => return Some((i, PickSource::Preference)),
|
||||
None => preference_missing = true,
|
||||
}
|
||||
if pref.mode == GpuMode::Manual
|
||||
&& let Some(want) = &pref.gpu
|
||||
{
|
||||
match find_preferred(gpus, want) {
|
||||
Some(i) => return Some((i, PickSource::Preference)),
|
||||
None => preference_missing = true,
|
||||
}
|
||||
}
|
||||
if let Some(sub) = env_substr.filter(|s| !s.is_empty()) {
|
||||
@@ -560,17 +560,17 @@ pub fn selected_gpu() -> Option<SelectedGpu> {
|
||||
let gpus = enumerate();
|
||||
let pref = prefs().get();
|
||||
let mut preference_missing = false;
|
||||
if pref.mode == GpuMode::Manual {
|
||||
if let Some(want) = &pref.gpu {
|
||||
match find_preferred(&gpus, want) {
|
||||
Some(i) => {
|
||||
return Some(SelectedGpu {
|
||||
info: gpus.into_iter().nth(i)?,
|
||||
source: PickSource::Preference,
|
||||
})
|
||||
}
|
||||
None => preference_missing = true,
|
||||
if pref.mode == GpuMode::Manual
|
||||
&& let Some(want) = &pref.gpu
|
||||
{
|
||||
match find_preferred(&gpus, want) {
|
||||
Some(i) => {
|
||||
return Some(SelectedGpu {
|
||||
info: gpus.into_iter().nth(i)?,
|
||||
source: PickSource::Preference,
|
||||
});
|
||||
}
|
||||
None => preference_missing = true,
|
||||
}
|
||||
}
|
||||
let source = if preference_missing {
|
||||
@@ -578,13 +578,13 @@ pub fn selected_gpu() -> Option<SelectedGpu> {
|
||||
} else {
|
||||
PickSource::Auto
|
||||
};
|
||||
if linux_nvidia_present() {
|
||||
if let Some(i) = gpus.iter().position(|g| g.vendor_id == VENDOR_NVIDIA) {
|
||||
return Some(SelectedGpu {
|
||||
info: gpus.into_iter().nth(i)?,
|
||||
source,
|
||||
});
|
||||
}
|
||||
if linux_nvidia_present()
|
||||
&& let Some(i) = gpus.iter().position(|g| g.vendor_id == VENDOR_NVIDIA)
|
||||
{
|
||||
return Some(SelectedGpu {
|
||||
info: gpus.into_iter().nth(i)?,
|
||||
source,
|
||||
});
|
||||
}
|
||||
let node = linux_render_node();
|
||||
let i = gpus
|
||||
@@ -620,10 +620,10 @@ pub fn manual_selection() -> Option<GpuInfo> {
|
||||
/// (a deliberate live env read — see `config.rs` module docs) > `/dev/dri/renderD128`.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_render_node() -> PathBuf {
|
||||
if let Some(g) = manual_selection() {
|
||||
if let Some(node) = g.handle.render_node {
|
||||
return node;
|
||||
}
|
||||
if let Some(g) = manual_selection()
|
||||
&& let Some(node) = g.handle.render_node
|
||||
{
|
||||
return node;
|
||||
}
|
||||
std::env::var("PUNKTFUNK_RENDER_NODE")
|
||||
.ok()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[package]
|
||||
name = "pf-host-config"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Process-wide punktfunk host configuration (env-parsed HostConfig behind a OnceLock)."
|
||||
publish = false
|
||||
|
||||
@@ -84,17 +84,17 @@ impl AudioOutputMode {
|
||||
/// first (it is the more restrictive promise — "do not touch my devices" must not be overridden
|
||||
/// by a stale `PUNKTFUNK_HOST_AUDIO` in the same `host.env`).
|
||||
fn from_env() -> AudioOutputMode {
|
||||
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE") {
|
||||
if !raw.trim().is_empty() {
|
||||
if let Some(m) = AudioOutputMode::parse(&raw) {
|
||||
return m;
|
||||
}
|
||||
// Never silently fall through to a different routing than the operator asked for.
|
||||
eprintln!(
|
||||
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
|
||||
client_only/host_and_client/follow_default — using client_only"
|
||||
);
|
||||
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE")
|
||||
&& !raw.trim().is_empty()
|
||||
{
|
||||
if let Some(m) = AudioOutputMode::parse(&raw) {
|
||||
return m;
|
||||
}
|
||||
// Never silently fall through to a different routing than the operator asked for.
|
||||
eprintln!(
|
||||
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
|
||||
client_only/host_and_client/follow_default — using client_only"
|
||||
);
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
|
||||
return AudioOutputMode::FollowDefault;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[package]
|
||||
name = "pf-inject"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host input injection: per-OS keyboard/mouse injectors + the virtual-gamepad HID backends behind one InputInjector trait."
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[package]
|
||||
name = "pf-paths"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Host config-directory resolution + owner-private file/dir creation (0600/0700 or SYSTEM/Admins DACL)."
|
||||
publish = false
|
||||
|
||||
@@ -1280,6 +1280,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
opts.render_scale_max_dim,
|
||||
);
|
||||
}
|
||||
// Adopt the tier this launch RESOLVED (globals or the host's
|
||||
// profile) instead of keeping the one the process started on.
|
||||
// `opts.stats_verbosity` only ever seeds the loop: the console
|
||||
// outlives every stream, so without this a settings change
|
||||
// reached the file and the settings row and nothing else until
|
||||
// the app was restarted.
|
||||
//
|
||||
// Deliberately HERE and not in `StreamState::new`: the
|
||||
// codec-fallback retry rebuilds the state from a clone of these
|
||||
// params mid-stream, and doing it there would snap the overlay
|
||||
// back every time a session fell down the codec ladder, undoing
|
||||
// a cycle the user had just made with the chord.
|
||||
stats_verbosity = params.stats_verbosity;
|
||||
// A live pump here would be DETACHED by the assignment
|
||||
// below — `StreamState` has no `Drop`, so its thread
|
||||
// would keep decoding onto the shared Vulkan device that
|
||||
|
||||
@@ -63,7 +63,17 @@ pub(crate) fn stamp_window_icon(window: &sdl3::video::Window) {
|
||||
let module = GetModuleHandleW(std::ptr::null());
|
||||
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
|
||||
let px = GetSystemMetrics(metric);
|
||||
let icon = LoadImageW(module, 1 as *const u16, IMAGE_ICON, px, px, LR_DEFAULTCOLOR);
|
||||
// MAKEINTRESOURCE(1): an integer resource ordinal smuggled through the name
|
||||
// pointer, never dereferenced — `without_provenance` says exactly that (and
|
||||
// `1 as *const u16` reads as a dangling pointer to clippy 1.96).
|
||||
let icon = LoadImageW(
|
||||
module,
|
||||
std::ptr::without_provenance(1),
|
||||
IMAGE_ICON,
|
||||
px,
|
||||
px,
|
||||
LR_DEFAULTCOLOR,
|
||||
);
|
||||
if !icon.is_null() {
|
||||
SendMessageW(hwnd, WM_SETICON, which as WPARAM, icon as LPARAM);
|
||||
}
|
||||
|
||||
@@ -398,7 +398,10 @@ mod linux_main {
|
||||
}
|
||||
|
||||
// One libc symbol, declared directly — not worth a libc dependency in a root helper.
|
||||
extern "C" {
|
||||
// (Edition 2024 spells extern blocks `unsafe extern`, which the `unsafe_code` lint now
|
||||
// counts — the same one-named-exemption rule as `effective_uid` below applies.)
|
||||
#[allow(unsafe_code)]
|
||||
unsafe extern "C" {
|
||||
#[link_name = "geteuid"]
|
||||
fn libc_geteuid() -> u32;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[package]
|
||||
name = "pf-vdisplay"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host virtual-display orchestration: per-compositor Linux backends + the Windows IddCx driver backend behind one VirtualDisplay trait."
|
||||
|
||||
@@ -267,6 +267,16 @@ pub struct DisplayPolicy {
|
||||
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
|
||||
#[serde(default)]
|
||||
pub pnp_disable_monitors: bool,
|
||||
/// **EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the
|
||||
/// software equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at
|
||||
/// the first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its
|
||||
/// live-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks
|
||||
/// on its next start. Targets the standby-sink stall class at its SOURCE: with emulation
|
||||
/// pinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD
|
||||
/// driver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like
|
||||
/// `game_session`); `#[serde(default)]` = off.
|
||||
#[serde(default)]
|
||||
pub edid_lock: bool,
|
||||
/// **Mirror a physical monitor instead of creating a virtual display**: the connector name
|
||||
/// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.
|
||||
///
|
||||
@@ -318,6 +328,7 @@ impl Default for DisplayPolicy {
|
||||
game_session: GameSession::default(),
|
||||
ddc_power_off: false,
|
||||
pnp_disable_monitors: false,
|
||||
edid_lock: false,
|
||||
capture_monitor: None,
|
||||
}
|
||||
}
|
||||
@@ -454,6 +465,7 @@ impl EffectivePolicy {
|
||||
game_session: GameSession,
|
||||
ddc_power_off: bool,
|
||||
pnp_disable_monitors: bool,
|
||||
edid_lock: bool,
|
||||
capture_monitor: Option<String>,
|
||||
) -> DisplayPolicy {
|
||||
DisplayPolicy {
|
||||
@@ -474,6 +486,7 @@ impl EffectivePolicy {
|
||||
game_session,
|
||||
ddc_power_off,
|
||||
pnp_disable_monitors,
|
||||
edid_lock,
|
||||
capture_monitor,
|
||||
}
|
||||
}
|
||||
@@ -739,6 +752,13 @@ impl DisplayPolicyStore {
|
||||
self.get().pnp_disable_monitors
|
||||
}
|
||||
|
||||
/// The experimental AMD connector-EDID-emulation axis — orthogonal to the preset (like
|
||||
/// [`Self::game_session`]), read directly off the stored policy (default off when
|
||||
/// unconfigured).
|
||||
pub fn edid_lock(&self) -> bool {
|
||||
self.get().edid_lock
|
||||
}
|
||||
|
||||
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
||||
/// write succeeds, so a full disk can't leave memory and file disagreeing — and the whole
|
||||
/// transaction runs under [`Self::write`], so neither can two concurrent PUTs.
|
||||
@@ -1318,13 +1338,16 @@ mod tests {
|
||||
GameSession::Dedicated,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Some("DP-2".into()),
|
||||
);
|
||||
// The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are
|
||||
// preserved through the transform — arranging displays must not clear an unrelated setting.
|
||||
// The orthogonal axes (game-session, DDC power-off, PnP disable, EDID lock,
|
||||
// capture-monitor pin) are preserved through the transform — arranging displays must not
|
||||
// clear an unrelated setting.
|
||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||
assert!(p.ddc_power_off);
|
||||
assert!(p.pnp_disable_monitors);
|
||||
assert!(p.edid_lock);
|
||||
assert_eq!(p.capture_monitor.as_deref(), Some("DP-2"));
|
||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||
assert_eq!(p.preset, Preset::Custom);
|
||||
@@ -1405,7 +1428,7 @@ mod tests {
|
||||
let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
12,
|
||||
13,
|
||||
"a display-policy axis was added or removed: {keys:?} — wire it into the mgmt PUT's \
|
||||
per-axis merge (and into `EffectivePolicy` if it is a behavior axis) before bumping this"
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! lands — a runtime gate on `remote_fd.is_some()`.
|
||||
//!
|
||||
//! The ownership split: the session's capturer no longer owns the real keepalive — the registry does.
|
||||
//! [`acquire`] hands the session a `VirtualOutput` whose `keepalive` is a lightweight, gen-stamped
|
||||
//! [`acquire`] hands the session a `VirtualOutput` whose `keepalive` is a lightweight, generation-stamped
|
||||
//! `DisplayLease` (mirrors the Windows `MonitorLease`); dropping it releases the registry refcount,
|
||||
//! and the lifecycle machine decides linger / teardown. `capture_virtual_output`'s signature is
|
||||
//! unchanged — it just holds a lease instead of the real keepalive.
|
||||
@@ -85,7 +85,7 @@ fn topology_str() -> String {
|
||||
/// with the quit application code — a user "stop", not a network drop), the display is torn down
|
||||
/// **immediately**, skipping the keep-alive linger. A bare disconnect leaves it `false` → normal linger.
|
||||
///
|
||||
/// `supersedes`: the pool gen of a display this acquire REPLACES (a mid-stream mode switch creates
|
||||
/// `supersedes`: the pool generation of a display this acquire REPLACES (a mid-stream mode switch creates
|
||||
/// the new display before retiring the old — create-before-drop). The replacement inherits group
|
||||
/// topology ownership: without this, the dying predecessor counts as a live sibling and the new
|
||||
/// display "extends" behind it, losing a Primary/Exclusive topology on every resize. `None`
|
||||
@@ -151,7 +151,7 @@ pub fn snapshot() -> Snapshot {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, i)| DisplayInfo {
|
||||
slot: i.gen,
|
||||
slot: i.generation,
|
||||
backend: i.backend.to_string(),
|
||||
mode: i.mode,
|
||||
state: i.state.to_string(),
|
||||
@@ -185,7 +185,7 @@ pub fn snapshot() -> Snapshot {
|
||||
/// released.
|
||||
pub fn release(slot: Option<u64>) -> usize {
|
||||
#[cfg(target_os = "windows")]
|
||||
// Windows slots (Stage W1): `slot` selects one kept monitor by its gen stamp
|
||||
// Windows slots (Stage W1): `slot` selects one kept monitor by its generation stamp
|
||||
// ([`DisplayInfo::slot`]); `None` releases every kept one.
|
||||
let released = super::manager::force_release(slot);
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -211,12 +211,12 @@ pub fn release(slot: Option<u64>) -> usize {
|
||||
/// Tear down a **reused-but-dead** pool entry by its generation stamp (A2). Called by the pipeline
|
||||
/// builder when the first frame fails on a display [`acquire`] handed back as REUSED — so the retry
|
||||
/// loop's next `acquire` creates fresh instead of re-wedging on the same corpse. No-op off Linux / if
|
||||
/// the entry is already gone (idempotent — the subsequent stale-gen lease drop no-ops too).
|
||||
pub fn mark_failed(gen: u64) {
|
||||
/// the entry is already gone (idempotent — the subsequent stale-generation lease drop no-ops too).
|
||||
pub fn mark_failed(generation: u64) {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux::mark_failed(gen);
|
||||
linux::mark_failed(generation);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = gen;
|
||||
let _ = generation;
|
||||
}
|
||||
|
||||
/// Force-release a **superseded** kept display by its generation stamp
|
||||
@@ -225,13 +225,13 @@ pub fn mark_failed(gen: u64) {
|
||||
/// keep-alive policy every resize would accumulate kept monitors at stale modes. The mode-switch
|
||||
/// arm calls this once the new pipeline is up and the old capturer is dropped. Only a KEPT
|
||||
/// (lingering/pinned) entry is released — an Active one is refused, like `/display/release` — and
|
||||
/// a gen that's already gone (immediate teardown) is a no-op. No-op off Linux (Windows
|
||||
/// a generation that's already gone (immediate teardown) is a no-op. No-op off Linux (Windows
|
||||
/// reconfigures the same monitor in place — nothing is superseded).
|
||||
pub fn retire(gen: u64) {
|
||||
pub fn retire(generation: u64) {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux::retire(gen);
|
||||
linux::retire(generation);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = gen;
|
||||
let _ = generation;
|
||||
}
|
||||
|
||||
/// Invalidate every kept display of `backend` — its compositor instance is gone (a Game↔Desktop switch
|
||||
@@ -320,9 +320,9 @@ mod pool {
|
||||
/// The session epoch at creation (A4). Reuse requires an epoch match; the linger timer reaps
|
||||
/// entries whose epoch is stale (their compositor instance was replaced under them).
|
||||
pub(super) epoch: u64,
|
||||
/// Generation stamp: a `DisplayLease` only releases if its gen still matches (a stale lease
|
||||
/// Generation stamp: a `DisplayLease` only releases if its generation still matches (a stale lease
|
||||
/// — its entry was reused + re-stamped — is a no-op).
|
||||
pub(super) gen: u64,
|
||||
pub(super) generation: u64,
|
||||
/// The out-of-band-cursor mode this display was CREATED with (Phase B): metadata-pointer
|
||||
/// (cursor-channel session) vs compositor-embedded. Reuse requires an exact match — a kept
|
||||
/// embedded display has no cursor metadata for a channel session to forward, and a kept
|
||||
@@ -344,15 +344,15 @@ mod pool {
|
||||
/// gamescope **spawn** is an independent nested session per client (no shared desktop), so each
|
||||
/// gamescope display is its OWN group — never auto-rowed against, or topology-/restore-grouped with,
|
||||
/// another gamescope session.
|
||||
pub(super) fn group_key(backend: &str, gen: u64) -> String {
|
||||
pub(super) fn group_key(backend: &str, generation: u64) -> String {
|
||||
if backend == "gamescope" {
|
||||
format!("gamescope#{gen}")
|
||||
format!("gamescope#{generation}")
|
||||
} else {
|
||||
backend.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the pooled entry `(e_backend, e_gen)` a member of the group the display `(backend, gen)`
|
||||
/// Is the pooled entry `(e_backend, e_gen)` a member of the group the display `(backend, generation)`
|
||||
/// belongs to — the ONE definition of membership, shared by the restore hand-off, the
|
||||
/// first-in-group probe and the layout collection.
|
||||
///
|
||||
@@ -370,10 +370,10 @@ mod pool {
|
||||
e_backend: &str,
|
||||
e_gen: u64,
|
||||
backend: &str,
|
||||
gen: u64,
|
||||
generation: u64,
|
||||
supersedes: Option<u64>,
|
||||
) -> bool {
|
||||
Some(e_gen) != supersedes && group_key(e_backend, e_gen) == group_key(backend, gen)
|
||||
Some(e_gen) != supersedes && group_key(e_backend, e_gen) == group_key(backend, generation)
|
||||
}
|
||||
|
||||
/// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a
|
||||
@@ -382,20 +382,20 @@ mod pool {
|
||||
/// reclaimed display's keepalive, so the physical is re-enabled while our output still exists —
|
||||
/// the compositor never sees zero outputs). `None` in → `None` out.
|
||||
///
|
||||
/// `backend`+`gen` identify the DEPARTING display, and both are needed: keyed on the backend name
|
||||
/// `backend`+`generation` identify the DEPARTING display, and both are needed: keyed on the backend name
|
||||
/// alone, one gamescope spawn's restore floated onto an unrelated client's spawn — where it would
|
||||
/// run when THAT session ended and never when its own did.
|
||||
pub(super) fn hand_off_restore(
|
||||
remaining: &mut [Entry],
|
||||
backend: &'static str,
|
||||
gen: u64,
|
||||
generation: u64,
|
||||
restore: Option<Restore>,
|
||||
) -> Option<Restore> {
|
||||
let action = restore?;
|
||||
// At most one restore per group, so any surviving sibling has `None` to receive it.
|
||||
match remaining
|
||||
.iter_mut()
|
||||
.find(|e| in_group(e.backend, e.gen, backend, gen, None))
|
||||
.find(|e| in_group(e.backend, e.generation, backend, generation, None))
|
||||
{
|
||||
Some(sibling) => {
|
||||
sibling.topology_restore = Some(action);
|
||||
@@ -441,8 +441,9 @@ mod pool {
|
||||
&& !matches!(entries[i].life, lifecycle::State::Active { .. });
|
||||
if entries[i].life.poll_expiry(now) || dead_epoch {
|
||||
let mut e = entries.remove(i);
|
||||
let (backend, gen) = (e.backend, e.gen);
|
||||
if let Some(r) = hand_off_restore(entries, backend, gen, e.topology_restore.take())
|
||||
let (backend, generation) = (e.backend, e.generation);
|
||||
if let Some(r) =
|
||||
hand_off_restore(entries, backend, generation, e.topology_restore.take())
|
||||
{
|
||||
restores.push(r);
|
||||
}
|
||||
@@ -470,7 +471,7 @@ mod pool {
|
||||
/// One live/kept display, flattened out of the pool under the lock — so the group + arrangement
|
||||
/// math (which calls the layout engine) runs OUTSIDE the lock.
|
||||
pub(super) struct Row {
|
||||
pub(super) gen: u64,
|
||||
pub(super) generation: u64,
|
||||
pub(super) backend: &'static str,
|
||||
pub(super) mode: Mode,
|
||||
pub(super) identity_slot: Option<u32>,
|
||||
@@ -480,7 +481,7 @@ mod pool {
|
||||
}
|
||||
|
||||
/// The desktop position for a display just appended to its group (design §6.2): the group's
|
||||
/// `existing` members (each with its acquire `gen`) plus `new` last, ordered by `gen`, arranged by
|
||||
/// `existing` members (each with its acquire `generation`) plus `new` last, ordered by `generation`, arranged by
|
||||
/// the pure [`layout`](crate::layout) engine, taking the new member's placement. Pure — so the
|
||||
/// append-in-acquire-order + auto-row/manual arrangement is unit-tested independent of the
|
||||
/// pool/global.
|
||||
@@ -503,10 +504,10 @@ mod pool {
|
||||
/// is dropped. Pure over its `known`/`next` state — the caller owns the process-lifetime copy.
|
||||
///
|
||||
/// Ids used to be the index into the sorted key list, which meant a new group could RENUMBER an
|
||||
/// untouched one: with one KWin desktop at group 1, a gamescope spawn at gen 3 sorts ahead of
|
||||
/// untouched one: with one KWin desktop at group 1, a gamescope spawn at generation 3 sorts ahead of
|
||||
/// `"kwin"` and silently moved the unchanged desktop to group 2 on the next `/display/state` poll.
|
||||
/// A monotonic counter, remembered per key, cannot do that. Pruning to the live keys is what keeps
|
||||
/// the map bounded: the per-spawn keys (`gamescope#<gen>`, one per dedicated session) would
|
||||
/// the map bounded: the per-spawn keys (`gamescope#<generation>`, one per dedicated session) would
|
||||
/// otherwise accumulate for the host's lifetime — an id is retired with its group.
|
||||
pub(super) fn assign_group_ids(
|
||||
known: &mut std::collections::BTreeMap<String, u32>,
|
||||
@@ -523,7 +524,7 @@ mod pool {
|
||||
}
|
||||
|
||||
/// Group the flattened rows into the mgmt `/display/state` view (design §6.1/§6.2) by
|
||||
/// [`group_key`], ordered by acquire (`gen`), with each member's position from the pure
|
||||
/// [`group_key`], ordered by acquire (`generation`), with each member's position from the pure
|
||||
/// [`layout`](crate::layout) engine. `ids` maps each group key to its reported group id (see
|
||||
/// [`group_ids`]). Pure — no I/O, no global — so the grouping / ordering / position assignment is
|
||||
/// unit-tested against synthetic rows.
|
||||
@@ -535,20 +536,23 @@ mod pool {
|
||||
) -> Vec<DisplayInfo> {
|
||||
use crate::layout::{self, Member};
|
||||
|
||||
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
|
||||
let mut keys: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|r| group_key(r.backend, r.generation))
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
|
||||
let mut out: Vec<DisplayInfo> = Vec::new();
|
||||
for key in keys.iter() {
|
||||
// This group's members in acquire order (gen ascending) → display_index + arrangement.
|
||||
// This group's members in acquire order (generation ascending) → display_index + arrangement.
|
||||
let mut idx: Vec<usize> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| &group_key(row.backend, row.gen) == key)
|
||||
.filter(|(_, row)| &group_key(row.backend, row.generation) == key)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
idx.sort_by_key(|&i| rows[i].gen);
|
||||
idx.sort_by_key(|&i| rows[i].generation);
|
||||
let members: Vec<Member> = idx
|
||||
.iter()
|
||||
.map(|&i| Member {
|
||||
@@ -561,7 +565,7 @@ mod pool {
|
||||
let row = &rows[i];
|
||||
let p = places[ord];
|
||||
out.push(DisplayInfo {
|
||||
slot: row.gen,
|
||||
slot: row.generation,
|
||||
backend: row.backend.to_string(),
|
||||
mode: (row.mode.width, row.mode.height, row.mode.refresh_hz),
|
||||
state: row.state.to_string(),
|
||||
@@ -590,8 +594,8 @@ mod pool {
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A minimal pool entry for the pure teardown/restore tests (dummy keepalive; the
|
||||
/// `hand_off_restore` logic only reads `backend` + `gen` + `topology_restore`).
|
||||
fn test_entry(backend: &'static str, gen: u64, restore: Option<Restore>) -> Entry {
|
||||
/// `hand_off_restore` logic only reads `backend` + `generation` + `topology_restore`).
|
||||
fn test_entry(backend: &'static str, generation: u64, restore: Option<Restore>) -> Entry {
|
||||
Entry {
|
||||
life: lifecycle::State::default(),
|
||||
keepalive: Box::new(()),
|
||||
@@ -607,7 +611,7 @@ mod pool {
|
||||
topology_restore: restore,
|
||||
launch: None,
|
||||
epoch: 0,
|
||||
gen,
|
||||
generation,
|
||||
hw_cursor: false,
|
||||
hdr: false,
|
||||
}
|
||||
@@ -631,7 +635,10 @@ mod pool {
|
||||
/// `ids_for` against a CARRIED map — for the stability test, which needs the same state
|
||||
/// across two assemblies.
|
||||
fn ids_into(known: &mut BTreeMap<String, u32>, next: &mut u32, rows: &[Row]) {
|
||||
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
|
||||
let mut keys: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|r| group_key(r.backend, r.generation))
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
assign_group_ids(known, next, &keys);
|
||||
@@ -659,7 +666,7 @@ mod pool {
|
||||
#[test]
|
||||
fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() {
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
// Two KWin displays in one group; the first (gen 1) carries the group's restore.
|
||||
// Two KWin displays in one group; the first (generation 1) carries the group's restore.
|
||||
let mut pool = vec![
|
||||
test_entry("kwin", 1, Some(flag_restore(&ran))),
|
||||
test_entry("kwin", 2, None),
|
||||
@@ -697,7 +704,7 @@ mod pool {
|
||||
#[test]
|
||||
fn tearing_down_a_non_carrier_first_leaves_the_restore_for_last() {
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
// gen 2 carries the restore; gen 1 does not (a later exclusive session found the physical
|
||||
// generation 2 carries the restore; generation 1 does not (a later exclusive session found the physical
|
||||
// already disabled).
|
||||
let mut pool = vec![
|
||||
test_entry("kwin", 1, None),
|
||||
@@ -706,7 +713,7 @@ mod pool {
|
||||
// Tear down the non-carrier first → nothing to hand off, carrier untouched.
|
||||
let mut e1 = pool.remove(0);
|
||||
assert!(hand_off_restore(&mut pool, "kwin", 1, e1.topology_restore.take()).is_none());
|
||||
// The carrier (gen 2) still holds the group's restore.
|
||||
// The carrier (generation 2) still holds the group's restore.
|
||||
assert!(pool[0].topology_restore.is_some());
|
||||
// Now the carrier (last member) → run.
|
||||
let mut e2 = pool.remove(0);
|
||||
@@ -755,9 +762,9 @@ mod pool {
|
||||
assert!(in_group("kwin", 3, "kwin", 2, Some(1)));
|
||||
}
|
||||
|
||||
fn row(gen: u64, backend: &'static str, w: u32, slot: Option<u32>) -> Row {
|
||||
fn row(generation: u64, backend: &'static str, w: u32, slot: Option<u32>) -> Row {
|
||||
Row {
|
||||
gen,
|
||||
generation,
|
||||
backend,
|
||||
mode: Mode {
|
||||
width: w,
|
||||
@@ -773,7 +780,7 @@ mod pool {
|
||||
|
||||
#[test]
|
||||
fn groups_by_backend_and_auto_rows_in_acquire_order() {
|
||||
// Two KWin displays (acquired gen 5 then gen 2 — deliberately out of vec order) + a Mutter one.
|
||||
// Two KWin displays (acquired generation 5 then generation 2 — deliberately out of vec order) + a Mutter one.
|
||||
let rows = vec![
|
||||
row(5, "kwin", 2560, Some(1)),
|
||||
row(2, "kwin", 1920, Some(7)),
|
||||
@@ -784,12 +791,12 @@ mod pool {
|
||||
|
||||
let kwin: Vec<&DisplayInfo> = out.iter().filter(|d| d.backend == "kwin").collect();
|
||||
assert_eq!(kwin.len(), 2);
|
||||
assert_eq!(kwin[0].slot, 2); // lower gen (earlier acquire) sorts to index 0
|
||||
assert_eq!(kwin[0].slot, 2); // lower generation (earlier acquire) sorts to index 0
|
||||
assert_eq!(kwin[0].display_index, 0);
|
||||
assert_eq!(kwin[0].position, (0, 0));
|
||||
assert_eq!(kwin[1].slot, 5);
|
||||
assert_eq!(kwin[1].display_index, 1);
|
||||
assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px gen-2 display
|
||||
assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px generation-2 display
|
||||
assert_eq!(kwin[0].topology, "exclusive");
|
||||
|
||||
// A distinct backend is a distinct group.
|
||||
@@ -830,7 +837,7 @@ mod pool {
|
||||
identity_slot: slot,
|
||||
width: w,
|
||||
};
|
||||
// Existing group (given out of gen order): gen 8 @ 1920 acquired AFTER gen 3 @ 2560.
|
||||
// Existing group (given out of generation order): generation 8 @ 1920 acquired AFTER generation 3 @ 2560.
|
||||
let existing = vec![(8, m(Some(2), 1920)), (3, m(Some(1), 2560))];
|
||||
// A new 1280-wide display appends to the right of 2560 + 1920.
|
||||
let pos = position_for_new(existing, m(Some(5), 1280), &Layout::default());
|
||||
@@ -900,30 +907,30 @@ mod pool {
|
||||
use std::time::Duration;
|
||||
let t0 = Instant::now();
|
||||
let mut es = Vec::new();
|
||||
// gen 1: lingering, deadline passed.
|
||||
// generation 1: lingering, deadline passed.
|
||||
let mut e1 = test_entry("kwin", 1, None);
|
||||
e1.life = lifecycle::State::Lingering {
|
||||
until: t0 - Duration::from_millis(1),
|
||||
};
|
||||
es.push(e1);
|
||||
// gen 2: lingering, deadline in the future, current epoch → survives.
|
||||
// generation 2: lingering, deadline in the future, current epoch → survives.
|
||||
let mut e2 = test_entry("kwin", 2, None);
|
||||
e2.life = lifecycle::State::Lingering {
|
||||
until: t0 + Duration::from_secs(60),
|
||||
};
|
||||
e2.epoch = 5;
|
||||
es.push(e2);
|
||||
// gen 3: pinned, but from a DEAD epoch → reaped (its compositor is gone).
|
||||
// generation 3: pinned, but from a DEAD epoch → reaped (its compositor is gone).
|
||||
let mut e3 = test_entry("kwin", 3, None);
|
||||
e3.life = lifecycle::State::Pinned;
|
||||
e3.epoch = 4;
|
||||
es.push(e3);
|
||||
// gen 4: ACTIVE from a dead epoch → left to its own session's rebuild.
|
||||
// generation 4: ACTIVE from a dead epoch → left to its own session's rebuild.
|
||||
let mut e4 = test_entry("kwin", 4, None);
|
||||
e4.life = lifecycle::State::Active { refs: 1 };
|
||||
e4.epoch = 4;
|
||||
es.push(e4);
|
||||
// gen 5: a gamescope spawn from a "dead" epoch — exempt (independent nested session).
|
||||
// generation 5: a gamescope spawn from a "dead" epoch — exempt (independent nested session).
|
||||
let mut e5 = test_entry("gamescope", 5, None);
|
||||
e5.life = lifecycle::State::Pinned;
|
||||
e5.epoch = 1;
|
||||
@@ -931,9 +938,9 @@ mod pool {
|
||||
|
||||
let (expired, restores) = take_expired(&mut es, t0, 5);
|
||||
assert!(restores.is_empty());
|
||||
let gone: Vec<u64> = expired.iter().map(|e| e.gen).collect();
|
||||
let gone: Vec<u64> = expired.iter().map(|e| e.generation).collect();
|
||||
assert_eq!(gone, vec![1, 3]);
|
||||
let left: Vec<u64> = es.iter().map(|e| e.gen).collect();
|
||||
let left: Vec<u64> = es.iter().map(|e| e.generation).collect();
|
||||
assert_eq!(left, vec![2, 4, 5]);
|
||||
}
|
||||
}
|
||||
@@ -974,7 +981,7 @@ mod linux {
|
||||
|
||||
struct Reg {
|
||||
entries: Mutex<Vec<Entry>>,
|
||||
gen: AtomicU64,
|
||||
generation: AtomicU64,
|
||||
}
|
||||
|
||||
static REG: OnceLock<Reg> = OnceLock::new();
|
||||
@@ -982,7 +989,7 @@ mod linux {
|
||||
fn reg() -> &'static Reg {
|
||||
REG.get_or_init(|| Reg {
|
||||
entries: Mutex::new(Vec::new()),
|
||||
gen: AtomicU64::new(1),
|
||||
generation: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1029,25 +1036,27 @@ mod linux {
|
||||
}
|
||||
match std::thread::Builder::new()
|
||||
.name("vdisplay-linger".into())
|
||||
.spawn(|| loop {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let (expired, restores) = {
|
||||
let mut es = reg().entries.lock().unwrap();
|
||||
take_expired(&mut es, Instant::now(), crate::session_epoch())
|
||||
};
|
||||
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
|
||||
for restore in restores {
|
||||
restore();
|
||||
.spawn(|| {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let (expired, restores) = {
|
||||
let mut es = reg().entries.lock().unwrap();
|
||||
take_expired(&mut es, Instant::now(), crate::session_epoch())
|
||||
};
|
||||
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
|
||||
for restore in restores {
|
||||
restore();
|
||||
}
|
||||
let reaped = expired.len();
|
||||
for e in expired {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
"virtual display: linger expired — torn down"
|
||||
);
|
||||
drop(e); // outside the lock
|
||||
}
|
||||
emit_released(reaped);
|
||||
}
|
||||
let reaped = expired.len();
|
||||
for e in expired {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
"virtual display: linger expired — torn down"
|
||||
);
|
||||
drop(e); // outside the lock
|
||||
}
|
||||
emit_released(reaped);
|
||||
}) {
|
||||
Ok(_) => *started = true,
|
||||
Err(e) => tracing::error!(
|
||||
@@ -1069,27 +1078,27 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the session-facing [`VirtualOutput`]: the kept node + a fresh gen-stamped lease. Only
|
||||
/// Build the session-facing [`VirtualOutput`]: the kept node + a fresh generation-stamped lease. Only
|
||||
/// the poolable (`remote_fd == None`) backends reach here, so `remote_fd` is always `None`.
|
||||
fn output_for(
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
gen: u64,
|
||||
generation: u64,
|
||||
quit: Arc<AtomicBool>,
|
||||
reused: bool,
|
||||
) -> VirtualOutput {
|
||||
// The pooled display is registry-owned; the session holds a gen-stamped lease as its keepalive.
|
||||
// The pooled display is registry-owned; the session holds a generation-stamped lease as its keepalive.
|
||||
let mut out = VirtualOutput::owned(
|
||||
node_id,
|
||||
preferred_mode,
|
||||
Box::new(DisplayLease { gen, quit }),
|
||||
Box::new(DisplayLease { generation, quit }),
|
||||
);
|
||||
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
|
||||
// `mark_failed(gen)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
|
||||
out.reused_gen = reused.then_some(gen);
|
||||
// H4: every pooled display carries its gen, so a mode-switch rebuild can `retire` the entry
|
||||
// `mark_failed(generation)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
|
||||
out.reused_gen = reused.then_some(generation);
|
||||
// H4: every pooled display carries its generation, so a mode-switch rebuild can `retire` the entry
|
||||
// this output's successor supersedes.
|
||||
out.pool_gen = Some(gen);
|
||||
out.pool_gen = Some(generation);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1129,9 +1138,9 @@ mod linux {
|
||||
// gamescope spawns are independent nested sessions, exempt from the active-session epoch —
|
||||
// see `epoch_matches`). The liveness probe (`kept_display_alive`, which may shell `pw-dump`
|
||||
// for gamescope) must NOT run under the pool lock (it can block / hang the daemon), so:
|
||||
// 1. find the candidate + snapshot (gen, node_id) UNDER the lock, then release it;
|
||||
// 1. find the candidate + snapshot (generation, node_id) UNDER the lock, then release it;
|
||||
// 2. probe liveness OUTSIDE the lock;
|
||||
// 3. re-lock and re-find the SAME entry by its gen (another thread may have reused/removed
|
||||
// 3. re-lock and re-find the SAME entry by its generation (another thread may have reused/removed
|
||||
// it meanwhile — then we just miss and create fresh).
|
||||
let candidate = {
|
||||
let es = r.entries.lock().unwrap();
|
||||
@@ -1147,16 +1156,16 @@ mod linux {
|
||||
&& e.hdr == vd.hdr()
|
||||
&& epoch_matches(e.backend, e.epoch, cur_epoch)
|
||||
})
|
||||
.map(|e| (e.gen, e.node_id))
|
||||
.map(|e| (e.generation, e.node_id))
|
||||
};
|
||||
if let Some((cand_gen, node_id)) = candidate {
|
||||
let alive = vd.kept_display_alive(node_id); // OUTSIDE the lock (may block)
|
||||
let reuse = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
// Re-find the SAME entry by its snapshot gen; skip if it's gone or no longer kept
|
||||
// Re-find the SAME entry by its snapshot generation; skip if it's gone or no longer kept
|
||||
// (a concurrent reconnect adopted it) — we then miss and create fresh.
|
||||
match es.iter().position(|e| {
|
||||
e.gen == cand_gen
|
||||
e.generation == cand_gen
|
||||
&& matches!(
|
||||
e.life,
|
||||
lifecycle::State::Lingering { .. } | lifecycle::State::Pinned
|
||||
@@ -1164,8 +1173,8 @@ mod linux {
|
||||
}) {
|
||||
Some(idx) if alive => {
|
||||
es[idx].life.acquire();
|
||||
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
|
||||
es[idx].gen = gen;
|
||||
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
|
||||
es[idx].generation = generation;
|
||||
let preferred_mode = es[idx].preferred_mode;
|
||||
tracing::info!(
|
||||
backend,
|
||||
@@ -1175,7 +1184,7 @@ mod linux {
|
||||
ReuseOutcome::Reused(output_for(
|
||||
node_id,
|
||||
preferred_mode,
|
||||
gen,
|
||||
generation,
|
||||
quit.clone(),
|
||||
true,
|
||||
))
|
||||
@@ -1183,7 +1192,7 @@ mod linux {
|
||||
Some(idx) => {
|
||||
// Dead kept display: remove it, hand off its group restore, create fresh.
|
||||
let mut dead = es.remove(idx);
|
||||
let (b, g) = (dead.backend, dead.gen);
|
||||
let (b, g) = (dead.backend, dead.generation);
|
||||
let restore =
|
||||
hand_off_restore(&mut es, b, g, dead.topology_restore.take());
|
||||
ReuseOutcome::Dead(dead, restore)
|
||||
@@ -1213,9 +1222,9 @@ mod linux {
|
||||
|
||||
// The new display's generation stamp, taken BEFORE the group questions below: it is this
|
||||
// display's identity for the rest of the acquire, and `group_key` needs it — a gamescope
|
||||
// spawn's group IS its gen. A gen burned by a failed create is harmless (they are opaque
|
||||
// spawn's group IS its generation. A generation burned by a failed create is harmless (they are opaque
|
||||
// and monotonic, never an index).
|
||||
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
|
||||
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// NOTE: the operator's `max_displays` ceiling is NOT enforced here. It belongs at
|
||||
// admission (`admission::admit`), which is where Windows has always applied it, and the
|
||||
@@ -1240,7 +1249,7 @@ mod linux {
|
||||
let first_in_group = {
|
||||
let es = r.entries.lock().unwrap();
|
||||
!es.iter().any(|e| {
|
||||
in_group(e.backend, e.gen, backend, gen, supersedes)
|
||||
in_group(e.backend, e.generation, backend, generation, supersedes)
|
||||
&& matches!(e.life, lifecycle::State::Active { .. })
|
||||
})
|
||||
};
|
||||
@@ -1292,7 +1301,7 @@ mod linux {
|
||||
topology_restore,
|
||||
launch: launch.clone(),
|
||||
epoch: cur_epoch,
|
||||
gen,
|
||||
generation,
|
||||
hw_cursor: vd.hw_cursor(),
|
||||
hdr: vd.hdr(),
|
||||
};
|
||||
@@ -1314,10 +1323,10 @@ mod linux {
|
||||
// width to the right on every mode switch.
|
||||
let existing: Vec<(u64, Member)> = es
|
||||
.iter()
|
||||
.filter(|e| in_group(e.backend, e.gen, backend, gen, supersedes))
|
||||
.filter(|e| in_group(e.backend, e.generation, backend, generation, supersedes))
|
||||
.map(|e| {
|
||||
(
|
||||
e.gen,
|
||||
e.generation,
|
||||
Member {
|
||||
identity_slot: e.identity_slot,
|
||||
width: e.mode.width as i32,
|
||||
@@ -1340,7 +1349,7 @@ mod linux {
|
||||
if (position.x, position.y) != (0, 0) {
|
||||
vd.apply_position(position.x, position.y);
|
||||
}
|
||||
let mut out = output_for(node_id, preferred_mode, gen, quit, false);
|
||||
let mut out = output_for(node_id, preferred_mode, generation, quit, false);
|
||||
out.expect_exact_dims = expect_exact_dims;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1348,18 +1357,18 @@ mod linux {
|
||||
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
|
||||
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
|
||||
/// the lock is released.
|
||||
fn release(gen: u64, force_immediate: bool) {
|
||||
fn release(generation: u64, force_immediate: bool) {
|
||||
let Some(r) = REG.get() else { return };
|
||||
let linger = effective_linger(force_immediate, linger());
|
||||
let (torn_down, restore) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
|
||||
let Some(idx) = es.iter().position(|e| e.generation == generation) else {
|
||||
return; // stale lease (entry reused + re-stamped, or already gone) — no-op
|
||||
};
|
||||
match es[idx].life.release(Instant::now(), linger) {
|
||||
Release::Teardown => {
|
||||
let mut e = es.remove(idx);
|
||||
let (backend, g) = (e.backend, e.gen);
|
||||
let (backend, g) = (e.backend, e.generation);
|
||||
// Per-group restore (§6.1): hand the physical re-enable to a surviving sibling, or run
|
||||
// it now if this was the group's last member.
|
||||
let restore = hand_off_restore(&mut es, backend, g, e.topology_restore.take());
|
||||
@@ -1368,8 +1377,8 @@ mod linux {
|
||||
// A release against a slot with NO live hold — a stale or duplicate lease drop. The
|
||||
// machine's contract for it is "do nothing", and it was wired to the teardown arm:
|
||||
// the one outcome that means the caller has no claim on this display would have torn
|
||||
// the display down. Unreachable today (a lease's gen is unique per acquire and the
|
||||
// lookup above is by gen, so a stale lease misses the pool entirely and returns
|
||||
// the display down. Unreachable today (a lease's generation is unique per acquire and the
|
||||
// lookup above is by generation, so a stale lease misses the pool entirely and returns
|
||||
// early), which is exactly why it has to be right by construction rather than by
|
||||
// luck — matching the Windows manager's own Noop arm.
|
||||
Release::Noop => (None, None),
|
||||
@@ -1434,7 +1443,7 @@ mod linux {
|
||||
lifecycle::State::Idle => return None,
|
||||
};
|
||||
Some(Row {
|
||||
gen: e.gen,
|
||||
generation: e.generation,
|
||||
backend: e.backend,
|
||||
mode: e.mode,
|
||||
identity_slot: e.identity_slot,
|
||||
@@ -1455,7 +1464,10 @@ mod linux {
|
||||
// Stable per-group ids, carried across polls (see `assign_group_ids`) — a new group must
|
||||
// never renumber an existing one under the console. The state is process-lifetime and this
|
||||
// is the only thing that touches it, so it lives here rather than in the pure core.
|
||||
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
|
||||
let mut keys: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|r| group_key(r.backend, r.generation))
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
static GROUP_IDS: Mutex<Option<(std::collections::BTreeMap<String, u32>, u32)>> =
|
||||
@@ -1475,13 +1487,13 @@ mod linux {
|
||||
}
|
||||
|
||||
/// H4 — force-release a display superseded by a mid-stream mode switch. Same machinery as
|
||||
/// [`force_release`] (kept entries only — an Active entry is refused, and a gen already torn
|
||||
/// [`force_release`] (kept entries only — an Active entry is refused, and a generation already torn
|
||||
/// down under `immediate` is a no-op), distinct log line.
|
||||
pub(super) fn retire(gen: u64) {
|
||||
release_kept(Some(gen), "retired (superseded by a mode switch)");
|
||||
pub(super) fn retire(generation: u64) {
|
||||
release_kept(Some(generation), "retired (superseded by a mode switch)");
|
||||
}
|
||||
|
||||
/// Remove + tear down KEPT (lingering/pinned) entries — all of them, or one by gen — running /
|
||||
/// Remove + tear down KEPT (lingering/pinned) entries — all of them, or one by generation — running /
|
||||
/// handing off group topology restores, with keepalive drops outside the lock. The shared core
|
||||
/// of [`force_release`] (mgmt) and [`retire`] (mode-switch supersede).
|
||||
fn release_kept(slot: Option<u64>, why: &'static str) -> usize {
|
||||
@@ -1492,10 +1504,10 @@ mod linux {
|
||||
let mut restores = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < es.len() {
|
||||
let selected = slot.is_none_or(|s| es[i].gen == s);
|
||||
let selected = slot.is_none_or(|s| es[i].generation == s);
|
||||
if selected && es[i].life.force_release() {
|
||||
let mut e = es.remove(i);
|
||||
let (backend, g) = (e.backend, e.gen);
|
||||
let (backend, g) = (e.backend, e.generation);
|
||||
let restore = e.topology_restore.take();
|
||||
if let Some(rst) = hand_off_restore(&mut es, backend, g, restore) {
|
||||
restores.push(rst);
|
||||
@@ -1522,15 +1534,15 @@ mod linux {
|
||||
|
||||
/// A2 — tear down a reused-but-dead pool entry by its generation stamp. Removes it (hand off /
|
||||
/// run its group restore), drops the keepalive outside the lock. Idempotent (already gone → no-op).
|
||||
pub(super) fn mark_failed(gen: u64) {
|
||||
pub(super) fn mark_failed(generation: u64) {
|
||||
let Some(r) = REG.get() else { return };
|
||||
let (torn, restore) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
|
||||
return; // already gone — the subsequent stale-gen lease drop no-ops too
|
||||
let Some(idx) = es.iter().position(|e| e.generation == generation) else {
|
||||
return; // already gone — the subsequent stale-generation lease drop no-ops too
|
||||
};
|
||||
let mut e = es.remove(idx);
|
||||
let (backend, g) = (e.backend, e.gen);
|
||||
let (backend, g) = (e.backend, e.generation);
|
||||
let restore = hand_off_restore(&mut es, backend, g, e.topology_restore.take());
|
||||
(e, restore)
|
||||
};
|
||||
@@ -1559,7 +1571,7 @@ mod linux {
|
||||
while i < es.len() {
|
||||
if es[i].backend == backend {
|
||||
let mut e = es.remove(i);
|
||||
let (b, g) = (e.backend, e.gen);
|
||||
let (b, g) = (e.backend, e.generation);
|
||||
if let Some(rst) = hand_off_restore(&mut es, b, g, e.topology_restore.take()) {
|
||||
restores.push(rst);
|
||||
}
|
||||
@@ -1591,7 +1603,7 @@ mod linux {
|
||||
/// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the
|
||||
/// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op.
|
||||
struct DisplayLease {
|
||||
gen: u64,
|
||||
generation: u64,
|
||||
/// The session's deliberate-quit flag: set when the client closes with the quit application
|
||||
/// code (a user "stop", not a network drop), so this lease's `Drop` tears the display down
|
||||
/// immediately instead of lingering. `false` on a bare disconnect → normal keep-alive.
|
||||
@@ -1600,7 +1612,7 @@ mod linux {
|
||||
|
||||
impl Drop for DisplayLease {
|
||||
fn drop(&mut self) {
|
||||
release(self.gen, self.quit.load(Ordering::SeqCst));
|
||||
release(self.generation, self.quit.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,10 @@ pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) -> Option<Gam
|
||||
// injector as sway/river, no code change.
|
||||
Compositor::Wlroots | Compositor::Hyprland => "wlr",
|
||||
};
|
||||
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
||||
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (lib.rs) serializing
|
||||
// every process-env writer on the session-setup path; steady-state threads read cached
|
||||
// config, not the environment.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend) };
|
||||
drop(_env_guard);
|
||||
resolve_gamescope_route(chosen, dedicated_launch)
|
||||
}
|
||||
@@ -464,11 +467,15 @@ mod tests {
|
||||
use super::operator_gamescope;
|
||||
let first = operator_gamescope();
|
||||
let restore = crate::with_env_lock(|| std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE"));
|
||||
crate::with_env_lock(|| std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto"));
|
||||
// SAFETY: both mutations run under `with_env_lock` — the crate's env-writer
|
||||
// serialization (ENV_LOCK, lib.rs); the readers under test sample once at startup.
|
||||
crate::with_env_lock(|| unsafe { std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto") });
|
||||
let second = operator_gamescope();
|
||||
crate::with_env_lock(|| match &restore {
|
||||
Some(v) => std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", v),
|
||||
None => std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"),
|
||||
// SAFETY: as above — the restore also runs under the same env-writer lock.
|
||||
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", v) },
|
||||
// SAFETY: as above.
|
||||
None => unsafe { std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE") },
|
||||
});
|
||||
assert_eq!(
|
||||
second.node, first.node,
|
||||
|
||||
@@ -585,41 +585,49 @@ fn find_wayland_socket(env: &EnvProbe, runtime: &str, uid: u32) -> Option<String
|
||||
pub fn apply_session_env(active: &ActiveSession) {
|
||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let e = &active.env;
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
|
||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
|
||||
if let Some(w) = &e.wayland_display {
|
||||
std::env::set_var("WAYLAND_DISPLAY", w);
|
||||
}
|
||||
if let Some(d) = &e.xdg_current_desktop {
|
||||
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
||||
}
|
||||
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live compositor
|
||||
// (fixes G4 for the systemd `--user` host, which never inherited it). Only set when detection
|
||||
// found a Hyprland session; a stale value from a previous connect is cleared otherwise so a
|
||||
// Hyprland→sway switch can't leave `hyprctl` pointed at a dead instance.
|
||||
match &e.hyprland_signature {
|
||||
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
||||
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||
}
|
||||
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the capture
|
||||
// chooser) is unreachable without it, so a systemd `--user` host that never inherited the login
|
||||
// environment had no sway backend at all. Cleared when nothing sway-shaped is live, so a
|
||||
// sway→Hyprland switch can't leave `swaymsg` aimed at a dead socket. `wlroots::is_available()`
|
||||
// keys off this variable, so setting it here is also what makes the backend visible at all.
|
||||
match &e.sway_socket {
|
||||
Some(sock) => std::env::set_var("SWAYSOCK", sock),
|
||||
None => std::env::remove_var("SWAYSOCK"),
|
||||
}
|
||||
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
|
||||
// connect's retarget, and the availability probes read them: after a gnome-shell crash
|
||||
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
||||
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
|
||||
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
|
||||
// graphical session" handshake error. Clear them so `available()` reports the truth and the
|
||||
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
|
||||
if active.kind == ActiveKind::None {
|
||||
std::env::remove_var("XDG_CURRENT_DESKTOP");
|
||||
std::env::remove_var("WAYLAND_DISPLAY");
|
||||
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (see its doc in lib.rs)
|
||||
// that serializes every process-env writer on the session-setup path; steady-state streaming
|
||||
// threads read cached config, not the environment (security-review 2026-06-28 #7).
|
||||
unsafe {
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
|
||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
|
||||
if let Some(w) = &e.wayland_display {
|
||||
std::env::set_var("WAYLAND_DISPLAY", w);
|
||||
}
|
||||
if let Some(d) = &e.xdg_current_desktop {
|
||||
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
||||
}
|
||||
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live
|
||||
// compositor (fixes G4 for the systemd `--user` host, which never inherited it). Only set
|
||||
// when detection found a Hyprland session; a stale value from a previous connect is
|
||||
// cleared otherwise so a Hyprland→sway switch can't leave `hyprctl` pointed at a dead
|
||||
// instance.
|
||||
match &e.hyprland_signature {
|
||||
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
||||
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||
}
|
||||
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the
|
||||
// capture chooser) is unreachable without it, so a systemd `--user` host that never
|
||||
// inherited the login environment had no sway backend at all. Cleared when nothing
|
||||
// sway-shaped is live, so a sway→Hyprland switch can't leave `swaymsg` aimed at a dead
|
||||
// socket. `wlroots::is_available()` keys off this variable, so setting it here is also
|
||||
// what makes the backend visible at all.
|
||||
match &e.sway_socket {
|
||||
Some(sock) => std::env::set_var("SWAYSOCK", sock),
|
||||
None => std::env::remove_var("SWAYSOCK"),
|
||||
}
|
||||
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
|
||||
// connect's retarget, and the availability probes read them: after a gnome-shell crash
|
||||
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
||||
// `mutter::is_available()` true, so a client's explicit backend request routed into the
|
||||
// dead session — 45 s create timeouts and a libei error loop instead of the crisp "no
|
||||
// live graphical session" handshake error. Clear them so `available()` reports the truth
|
||||
// and the client fails fast (and, when configured, `try_recover_session` can bring the
|
||||
// desktop back).
|
||||
if active.kind == ActiveKind::None {
|
||||
std::env::remove_var("XDG_CURRENT_DESKTOP");
|
||||
std::env::remove_var("WAYLAND_DISPLAY");
|
||||
}
|
||||
}
|
||||
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
||||
|
||||
@@ -112,8 +112,8 @@ struct Monitor {
|
||||
/// This monitor's desktop-space origin from the group layout (`(0,0)` until a multi-slot
|
||||
/// arrangement places it) — reported via [`ManagedInfo`].
|
||||
position: (i32, i32),
|
||||
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
|
||||
gen: u64,
|
||||
/// Generation stamp; a [`MonitorLease`] only releases if its generation still matches (stale-lease no-op).
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
@@ -178,6 +178,10 @@ struct GroupState {
|
||||
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at
|
||||
/// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore.
|
||||
pnp_disabled: Vec<String>,
|
||||
/// Whether the EXPERIMENTAL `edid_lock` axis pinned AMD connector emulation at the group's
|
||||
/// first isolate (`pf_win_display::adl_emul::lock_for_stream`) — last-member teardown owes the
|
||||
/// unlock (pinned emulation outlives the process, so a crash journal backs this flag up).
|
||||
edid_locked: bool,
|
||||
/// Whether `ccd_saved` was captured by an EXCLUSIVE isolate (vs `Primary`, which also
|
||||
/// snapshots but deliberately keeps the physical displays active) — gates the re-assert
|
||||
/// watchdog, which must never "fix" a Primary group's lit panels. Cleared with the restore.
|
||||
@@ -367,10 +371,10 @@ struct MgrInner {
|
||||
}
|
||||
|
||||
impl MgrInner {
|
||||
/// Live target ids in acquire (gen) order — the CCD isolate keep-set + the layout member order.
|
||||
/// Live target ids in acquire (generation) order — the CCD isolate keep-set + the layout member order.
|
||||
fn target_ids(&self) -> Vec<u32> {
|
||||
let mut mons: Vec<&Monitor> = self.slots.values().map(SlotState::mon).collect();
|
||||
mons.sort_by_key(|m| m.gen);
|
||||
mons.sort_by_key(|m| m.generation);
|
||||
mons.iter().map(|m| m.target_id).collect()
|
||||
}
|
||||
}
|
||||
@@ -425,7 +429,7 @@ pub struct VirtualDisplayManager {
|
||||
/// fallback. One attempt per process, in case a future OS build honors the refresh.
|
||||
update_modes_futile: AtomicBool,
|
||||
/// Monotonic lease-generation counter (was the `MON_GEN` global).
|
||||
gen: AtomicU64,
|
||||
generation: AtomicU64,
|
||||
state: Mutex<MgrInner>,
|
||||
/// Serializes IDD-push session SETUP (preempt + monitor create) — MANAGER-WIDE even with slots:
|
||||
/// monitor create/teardown stays serialized (the 400 ms async-departure settle and the IddCx
|
||||
@@ -473,7 +477,7 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
|
||||
watchdog_s: AtomicU32::new(3),
|
||||
driver_proto: AtomicU32::new(0),
|
||||
update_modes_futile: AtomicBool::new(false),
|
||||
gen: AtomicU64::new(1),
|
||||
generation: AtomicU64::new(1),
|
||||
state: Mutex::new(MgrInner::default()),
|
||||
setup_lock: Mutex::new(()),
|
||||
idd_session_stops: Mutex::new(std::collections::HashMap::new()),
|
||||
@@ -700,7 +704,7 @@ impl VirtualDisplayManager {
|
||||
// IDD-push: a new connection while THIS SLOT's monitor is kept (LINGERING or PINNED) is a
|
||||
// single-client RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is
|
||||
// DEAD, so reusing it hands a black screen — PREEMPT: tear the kept monitor down and create a
|
||||
// fresh one. The old session's lease is gen-stamped, so its later drop is a no-op. A SIBLING
|
||||
// fresh one. The old session's lease is generation-stamped, so its later drop is a no-op. A SIBLING
|
||||
// slot's kept monitor is never touched — that's another client's display.
|
||||
//
|
||||
// ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the
|
||||
@@ -746,7 +750,7 @@ impl VirtualDisplayManager {
|
||||
// hand the rebuild the dead monitor's target (stale wudf_pid) and starve it to the rebuild
|
||||
// budget. Preempt instead: best-effort teardown (REMOVE fails harmlessly on a dead/retired
|
||||
// device) and fall through to a fresh create on the auto-restarted device. Held leases are
|
||||
// gen-stamped, so their eventual release is a no-op. ONE WUDFHost hosts every slot's
|
||||
// generation-stamped, so their eventual release is a no-op. ONE WUDFHost hosts every slot's
|
||||
// publisher, so its death is ALL-slot shared fate — but each sibling's session fails through
|
||||
// its own capturer watch and rebuilds through this same path; no cross-slot teardown here.
|
||||
if matches!(inner.slots.get(&slot), Some(SlotState::Active { mon, .. }) if !wudf_alive(mon.wudf_pid))
|
||||
@@ -807,7 +811,7 @@ impl VirtualDisplayManager {
|
||||
match unsafe { self.resize_in_place(dev_raw(&dev), mon, mode) } {
|
||||
Ok(()) => {
|
||||
// Same join semantics as the re-arrival: +1 ref for the new
|
||||
// (build-then-drop overlap) lease; `gen` untouched, so the old
|
||||
// (build-then-drop overlap) lease; `generation` untouched, so the old
|
||||
// session's lease stays valid.
|
||||
*refs += 1;
|
||||
let refs = *refs;
|
||||
@@ -858,7 +862,7 @@ impl VirtualDisplayManager {
|
||||
// acked the switch; Fix 2's corrective ack tells the client the resolution
|
||||
// did not change). Store the RECOVERED monitor, not the one handed in:
|
||||
// that one's driver monitor was REMOVEd before the failed ADD, so its
|
||||
// `key`/`target_id`/`gdi_name` are all dead. `gen`/`refs` are preserved,
|
||||
// `key`/`target_id`/`gdi_name` are all dead. `generation`/`refs` are preserved,
|
||||
// so leases stay valid either way.
|
||||
inner.slots.insert(
|
||||
slot,
|
||||
@@ -875,7 +879,7 @@ impl VirtualDisplayManager {
|
||||
return Err(err).context("mid-stream resize re-arrival (slot left empty)");
|
||||
}
|
||||
};
|
||||
// `re_add` preserved `gen`, so both the old session's lease and this new one match on
|
||||
// `re_add` preserved `generation`, so both the old session's lease and this new one match on
|
||||
// release. +1 ref for the new (build-then-drop overlap) lease.
|
||||
let out = self.output_for(slot, &new_mon, quit);
|
||||
inner.slots.insert(
|
||||
@@ -965,7 +969,7 @@ impl VirtualDisplayManager {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for
|
||||
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh generation-stamped lease) for
|
||||
/// `mon` in `slot`. `quit` is the session's deliberate-quit flag, read by the lease `Drop` (see
|
||||
/// [`Self::release`]).
|
||||
fn output_for(
|
||||
@@ -981,7 +985,7 @@ impl VirtualDisplayManager {
|
||||
keepalive: Box::new(MonitorLease {
|
||||
mgr: self,
|
||||
slot,
|
||||
gen: mon.gen,
|
||||
generation: mon.generation,
|
||||
quit,
|
||||
}),
|
||||
// The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry
|
||||
@@ -1211,18 +1215,18 @@ impl VirtualDisplayManager {
|
||||
return;
|
||||
}
|
||||
let layout_policy = crate::policy::prefs().get().effective().layout;
|
||||
// Members in acquire (gen) order — the auto-row order; identity slot 0 = anonymous (no
|
||||
// manual pin can address it, so it always auto-rows). `(slot, gen, target_id, width)`
|
||||
// Members in acquire (generation) order — the auto-row order; identity slot 0 = anonymous (no
|
||||
// manual pin can address it, so it always auto-rows). `(slot, generation, target_id, width)`
|
||||
// copied out so the arrangement below can write back through `get_mut`.
|
||||
let mut ordered: Vec<(u32, u64, u32, i32)> = inner
|
||||
.slots
|
||||
.iter()
|
||||
.map(|(slot, s)| {
|
||||
let m = s.mon();
|
||||
(*slot, m.gen, m.target_id, m.mode.width as i32)
|
||||
(*slot, m.generation, m.target_id, m.mode.width as i32)
|
||||
})
|
||||
.collect();
|
||||
ordered.sort_by_key(|&(_, gen, _, _)| gen);
|
||||
ordered.sort_by_key(|&(_, generation, _, _)| generation);
|
||||
let members: Vec<Member> = ordered
|
||||
.iter()
|
||||
.map(|&(slot, _, _, width)| Member {
|
||||
@@ -1400,6 +1404,18 @@ impl VirtualDisplayManager {
|
||||
if crate::policy::prefs().ddc_power_off() {
|
||||
inner.group.ddc_panels_off = crate::ddc::panel_off_except(n);
|
||||
}
|
||||
// EXPERIMENTAL `edid_lock` policy axis (AMD only): pin connector EDID
|
||||
// emulation BEFORE the isolate deactivates the physicals — an awake
|
||||
// sink still answers the live-EDID read the lock pins (asleep sinks
|
||||
// fall back to the driver's stored emulation data). With emulation at
|
||||
// ADL_EMUL_MODE_ALWAYS the KMD stops servicing the sleeping sink's
|
||||
// HPD/DDC/link — the standby-sink stall class at its source
|
||||
// (rationale + crash journal in `pf_win_display::adl_emul`). First
|
||||
// member only, like the DDC leg: the connectors are host-wide.
|
||||
if crate::policy::prefs().edid_lock() {
|
||||
inner.group.edid_locked =
|
||||
pf_win_display::adl_emul::lock_for_stream();
|
||||
}
|
||||
inner.group.ccd_saved = isolate_displays_ccd_seam(&keep);
|
||||
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
|
||||
// additionally disable the deactivated monitors' PnP devnodes (persistent
|
||||
@@ -1553,7 +1569,7 @@ impl VirtualDisplayManager {
|
||||
requested_mode,
|
||||
resolved_monitor_id: added.resolved_monitor_id,
|
||||
position: (0, 0),
|
||||
gen: self.gen.fetch_add(1, Ordering::Relaxed),
|
||||
generation: self.generation.fetch_add(1, Ordering::Relaxed),
|
||||
hw_cursor,
|
||||
cursor_excluded: added.cursor_excluded,
|
||||
})
|
||||
@@ -1677,7 +1693,7 @@ impl VirtualDisplayManager {
|
||||
/// / ContainerId) so the OS keeps the monitor's identity + saved per-monitor DPI. The visible cost
|
||||
/// is one monitor hotplug per switch (the design's accepted "re-arrival for everything").
|
||||
///
|
||||
/// Refcount/lease continuity: the rebuilt `Monitor` PRESERVES the old `gen`, so the outstanding
|
||||
/// Refcount/lease continuity: the rebuilt `Monitor` PRESERVES the old `generation`, so the outstanding
|
||||
/// session lease(s) still match on release — the linger/refcount machine is untouched. The group
|
||||
/// restore snapshot (`group.ccd_saved` / DDC / PnP) is likewise PRESERVED (a mid-session swap, not
|
||||
/// a first-member create): [`reisolate_after_swap`](Self::reisolate_after_swap) re-isolates the new
|
||||
@@ -1821,8 +1837,8 @@ impl VirtualDisplayManager {
|
||||
added.target_id
|
||||
),
|
||||
}
|
||||
// 5. Rebuild the Monitor from the ADD reply, PRESERVING `gen` (lease/refcount continuity) and
|
||||
// the group-layout `position`. A fresh `gen` would strand the old session's lease release.
|
||||
// 5. Rebuild the Monitor from the ADD reply, PRESERVING `generation` (lease/refcount continuity) and
|
||||
// the group-layout `position`. A fresh `generation` would strand the old session's lease release.
|
||||
let mon = Box::new(Monitor {
|
||||
key: added.key,
|
||||
target_id: added.target_id,
|
||||
@@ -1834,7 +1850,7 @@ impl VirtualDisplayManager {
|
||||
requested_mode,
|
||||
resolved_monitor_id: added.resolved_monitor_id,
|
||||
position: old.position,
|
||||
gen: old.gen,
|
||||
generation: old.generation,
|
||||
hw_cursor: old.hw_cursor,
|
||||
// Fresh from THIS reply, not `old`: the driver's per-target declare registry is the
|
||||
// ground truth (this session may itself have declared since the original ADD).
|
||||
@@ -1958,6 +1974,15 @@ impl VirtualDisplayManager {
|
||||
);
|
||||
inner.group.ddc_panels_off = 0;
|
||||
}
|
||||
// EXPERIMENTAL `edid_lock` unlock. AFTER the CCD restore + DDC wake: the re-activated
|
||||
// physical paths do not depend on it (the pinned emulation IS the real monitor's
|
||||
// EDID), and unlocking last keeps the driver from re-probing the sinks mid-restore.
|
||||
// OUTSIDE the `ccd_saved` gate for the same reason as the DDC wake above — the lock
|
||||
// was applied BEFORE the isolate, whose snapshot capture can have failed.
|
||||
if inner.group.edid_locked {
|
||||
pf_win_display::adl_emul::unlock_after_stream();
|
||||
inner.group.edid_locked = false;
|
||||
}
|
||||
} else {
|
||||
match shrink_action(inner.group.ccd_exclusive, inner.group.ccd_saved.is_some()) {
|
||||
// Re-issue the isolate over the shrunk set (defensive — the departing monitor's
|
||||
@@ -2023,10 +2048,10 @@ impl VirtualDisplayManager {
|
||||
/// deliberate quit still pins — only `/display/release` frees a pinned monitor. A STALE lease
|
||||
/// (its monitor was preempted + recreated under it) is a no-op, so it can't tear down the
|
||||
/// CURRENT monitor.
|
||||
fn release(&self, slot: u32, gen: u64, quit_now: bool) {
|
||||
fn release(&self, slot: u32, generation: u64, quit_now: bool) {
|
||||
let mut inner = self.state.lock().unwrap();
|
||||
let stale = match inner.slots.get(&slot) {
|
||||
Some(s) => s.mon().gen != gen,
|
||||
Some(s) => s.mon().generation != generation,
|
||||
None => true,
|
||||
};
|
||||
if stale {
|
||||
@@ -2135,7 +2160,7 @@ impl VirtualDisplayManager {
|
||||
// HERE instead. This runs at most ONCE per session (we hold `setup_lock`), so —
|
||||
// unlike preempting inside `acquire` — it does not reintroduce the per-retry churn.
|
||||
// The next `acquire` then sees the slot empty and creates a fresh monitor; the stale
|
||||
// session's gen-stamped lease release is a no-op.
|
||||
// session's generation-stamped lease release is a no-op.
|
||||
if let Some(dev) = self.device_handle() {
|
||||
let mut inner = self.state.lock().unwrap();
|
||||
let taken = match inner.slots.get(&slot) {
|
||||
@@ -2195,38 +2220,40 @@ impl VirtualDisplayManager {
|
||||
TIMER.call_once(|| {
|
||||
thread::Builder::new()
|
||||
.name("vdisplay-linger".into())
|
||||
.spawn(move || loop {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let Some(dev) = self.device_handle() else {
|
||||
continue;
|
||||
};
|
||||
let mut g = self.state.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
let expired: Vec<u32> = g
|
||||
.slots
|
||||
.iter()
|
||||
.filter_map(|(slot, s)| {
|
||||
matches!(s, SlotState::Lingering { until, .. } if now >= *until)
|
||||
.then_some(*slot)
|
||||
})
|
||||
.collect();
|
||||
for slot in expired {
|
||||
if let Some(SlotState::Lingering { mon, .. }) = g.slots.remove(&slot) {
|
||||
// Teardown UNDER the state lock. Dropping the lock first (the old shape)
|
||||
// let a concurrent `acquire` see the slot empty and run its ADD + CCD
|
||||
// isolate while this monitor's CCD-restore / REMOVE were still in flight
|
||||
// — the late restore then de-isolated (or the REMOVE churn-rejected) the
|
||||
// fresh session at the linger-expiry boundary. Holding the lock makes
|
||||
// the racing acquire WAIT the few teardown seconds instead of failing
|
||||
// its session. Lock order stays state → device (teardown's invalidate
|
||||
// path), same as every other holder; the pinger takes only the device
|
||||
// lock — no inversion.
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
|
||||
// Arc from `self.device_handle()` is held across this call, so the
|
||||
// handle stays open (a concurrent retire drops only the manager's
|
||||
// reference; see `DeviceSlot`). `mon` was moved out of the map under
|
||||
// the lock, so it is exclusively owned here.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
|
||||
.spawn(move || {
|
||||
loop {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let Some(dev) = self.device_handle() else {
|
||||
continue;
|
||||
};
|
||||
let mut g = self.state.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
let expired: Vec<u32> = g
|
||||
.slots
|
||||
.iter()
|
||||
.filter_map(|(slot, s)| {
|
||||
matches!(s, SlotState::Lingering { until, .. } if now >= *until)
|
||||
.then_some(*slot)
|
||||
})
|
||||
.collect();
|
||||
for slot in expired {
|
||||
if let Some(SlotState::Lingering { mon, .. }) = g.slots.remove(&slot) {
|
||||
// Teardown UNDER the state lock. Dropping the lock first (the old shape)
|
||||
// let a concurrent `acquire` see the slot empty and run its ADD + CCD
|
||||
// isolate while this monitor's CCD-restore / REMOVE were still in flight
|
||||
// — the late restore then de-isolated (or the REMOVE churn-rejected) the
|
||||
// fresh session at the linger-expiry boundary. Holding the lock makes
|
||||
// the racing acquire WAIT the few teardown seconds instead of failing
|
||||
// its session. Lock order stays state → device (teardown's invalidate
|
||||
// path), same as every other holder; the pinger takes only the device
|
||||
// lock — no inversion.
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
|
||||
// Arc from `self.device_handle()` is held across this call, so the
|
||||
// handle stays open (a concurrent retire drops only the manager's
|
||||
// reference; see `DeviceSlot`). `mon` was moved out of the map under
|
||||
// the lock, so it is exclusively owned here.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2240,7 +2267,7 @@ impl VirtualDisplayManager {
|
||||
struct MonitorLease {
|
||||
mgr: &'static VirtualDisplayManager,
|
||||
slot: u32,
|
||||
gen: u64,
|
||||
generation: u64,
|
||||
/// The session's deliberate-quit flag (the client closed with the QUIT application code — a user
|
||||
/// "stop", not a network drop). Read at drop time: a quit release tears the monitor down NOW
|
||||
/// instead of lingering, mirroring the Linux registry's `Linger::Immediate`. `None` = no signal
|
||||
@@ -2251,7 +2278,7 @@ struct MonitorLease {
|
||||
impl Drop for MonitorLease {
|
||||
fn drop(&mut self) {
|
||||
let quit_now = self.quit.as_ref().is_some_and(|q| q.load(Ordering::SeqCst));
|
||||
self.mgr.release(self.slot, self.gen, quit_now);
|
||||
self.mgr.release(self.slot, self.generation, quit_now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2319,7 +2346,7 @@ pub(crate) struct ManagedInfo {
|
||||
/// Live sessions holding the monitor.
|
||||
pub sessions: u32,
|
||||
/// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg.
|
||||
pub gen: u64,
|
||||
pub generation: u64,
|
||||
/// The slot key: the client's stable identity slot (`1..=15`), or `0` = anonymous/auto.
|
||||
pub slot_id: u32,
|
||||
/// Desktop-space origin from the group layout (`(0,0)` for a single display).
|
||||
@@ -2327,7 +2354,7 @@ pub(crate) struct ManagedInfo {
|
||||
}
|
||||
|
||||
impl VirtualDisplayManager {
|
||||
/// Snapshot the managed slots for the mgmt `/display/state` endpoint, in acquire (gen) order.
|
||||
/// Snapshot the managed slots for the mgmt `/display/state` endpoint, in acquire (generation) order.
|
||||
/// Empty when no slot lives.
|
||||
pub(crate) fn snapshot(&self) -> Vec<ManagedInfo> {
|
||||
let inner = self.state.lock().unwrap();
|
||||
@@ -2351,20 +2378,20 @@ impl VirtualDisplayManager {
|
||||
state,
|
||||
expires_in_ms,
|
||||
sessions,
|
||||
gen: mon.gen,
|
||||
generation: mon.generation,
|
||||
slot_id: *slot,
|
||||
position: mon.position,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|i| i.gen);
|
||||
out.sort_by_key(|i| i.generation);
|
||||
out
|
||||
}
|
||||
|
||||
/// Force-tear-down kept (LINGERING **or** PINNED) monitors now (the `/display/release` endpoint) —
|
||||
/// so a physical-screen user gets their screen back without waiting out the linger, and it is the §8
|
||||
/// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. `slot` selects one kept monitor
|
||||
/// by its [`ManagedInfo::gen`] stamp; `None` releases every kept one. Active monitors are refused
|
||||
/// by its [`ManagedInfo::generation`] stamp; `None` releases every kept one. Active monitors are refused
|
||||
/// (stopping a live session is session management, not display management). Returns the number
|
||||
/// released.
|
||||
pub(crate) fn force_release(&self, slot: Option<u64>) -> usize {
|
||||
@@ -2377,7 +2404,7 @@ impl VirtualDisplayManager {
|
||||
.iter()
|
||||
.filter_map(|(k, s)| match s {
|
||||
SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }
|
||||
if slot.is_none_or(|g| g == mon.gen) =>
|
||||
if slot.is_none_or(|g| g == mon.generation) =>
|
||||
{
|
||||
Some(*k)
|
||||
}
|
||||
@@ -2409,7 +2436,7 @@ pub(crate) fn snapshot() -> Vec<ManagedInfo> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Force-release kept monitors now (`slot` = a [`ManagedInfo::gen`] stamp, `None` = all kept); `0`
|
||||
/// Force-release kept monitors now (`slot` = a [`ManagedInfo::generation`] stamp, `None` = all kept); `0`
|
||||
/// if nothing was kept (or the manager is uninitialised).
|
||||
pub(crate) fn force_release(slot: Option<u64>) -> usize {
|
||||
VDM.get().map(|m| m.force_release(slot)).unwrap_or(0)
|
||||
|
||||
@@ -46,6 +46,7 @@ use pf_bitstream::h264::PlanError;
|
||||
use pf_bitstream::h264::PlanWarning;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::derive_caps;
|
||||
use crate::caps::query_h264_caps;
|
||||
@@ -685,6 +686,9 @@ pub struct VkH264Decoder {
|
||||
/// Session generation: bumped on every rebuild, stamped into frames.
|
||||
generation: u64,
|
||||
device_lost: bool,
|
||||
/// The over-declared-level warning has fired (once per decoder — the condition
|
||||
/// is a property of the stream's SPS, so repeating it per AU is noise).
|
||||
level_clamp_warned: bool,
|
||||
}
|
||||
|
||||
impl VkH264Decoder {
|
||||
@@ -723,6 +727,7 @@ impl VkH264Decoder {
|
||||
decoded: 0,
|
||||
generation: 0,
|
||||
device_lost: false,
|
||||
level_clamp_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1365,18 +1370,26 @@ impl VkH264Decoder {
|
||||
unsafe { query_h264_caps(&self.dev, std_profile) }.map_err(VkDecodeError::from)?;
|
||||
self.caps = Some((std_profile, derive_caps(&raw)?));
|
||||
}
|
||||
// The level gate: a stream above the device's maxLevelIdc is refused up
|
||||
// front (within one codec the Std code points ascend with the level, so
|
||||
// the comparison is numeric), never submitted on a hope. The ceiling came
|
||||
// from an H.264 caps query, so it is compared against an H.264 code point
|
||||
// — the pairing MaxLevelIdc's tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above
|
||||
// `maxLevelIdc` is NOT a refusal — encoders over-claim levels in the wild
|
||||
// (the H.265 twin carries the field evidence: AMF stamps the codec
|
||||
// maximum). The stream's REAL demands are enforced where they are
|
||||
// physical facts — coded extent and DPB depth, checked in
|
||||
// `rebuild_state` — and the session's parameter sets are clamped to the
|
||||
// ceiling (`SessionConfig::max_level_idc`) so the driver is never handed
|
||||
// a level above its caps. The comparison stays within one codec's Std
|
||||
// code space (`MaxLevelIdc`'s tag carries that argument).
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = level_to_std(plan.picture.level_idc);
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (Std code point {stream_level}) above the device's \
|
||||
maxLevelIdc ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
|
||||
self.level_clamp_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an H.264 level above the device ceiling — the \
|
||||
declared level is advisory (over-declared by some encoders); \
|
||||
proceeding with the parameter sets clamped to the ceiling"
|
||||
);
|
||||
}
|
||||
let coded = vk::Extent2D {
|
||||
width: plan.picture.coded_width,
|
||||
@@ -1488,6 +1501,7 @@ impl VkH264Decoder {
|
||||
max_dpb_slots: required_slots,
|
||||
max_active_references: (required_slots - 1).min(caps.max_active_references),
|
||||
std_profile_idc: std_profile,
|
||||
max_level_idc: caps.max_level_idc.code_point(),
|
||||
};
|
||||
let mut pool_plan = plan_pools(caps, required_slots);
|
||||
// TEST-ONLY readback hook: the GPU parity test (tests/gpu_parity.rs)
|
||||
|
||||
@@ -57,6 +57,7 @@ use pf_bitstream::h265::PlanError;
|
||||
use pf_bitstream::h265::PlanWarning;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::DecodeCaps;
|
||||
use crate::caps::DecodeProfile;
|
||||
@@ -219,6 +220,9 @@ pub struct VkH265Decoder {
|
||||
/// Recovery owed after a failed AU whose planning had already advanced
|
||||
/// ([`RecoveryLatch`] docs for the whole argument).
|
||||
recovery: RecoveryLatch,
|
||||
/// The over-declared-level warning has fired (once per decoder — the condition
|
||||
/// is a property of the stream's SPS, so repeating it per AU is noise).
|
||||
level_clamp_warned: bool,
|
||||
}
|
||||
|
||||
impl VkH265Decoder {
|
||||
@@ -266,6 +270,7 @@ impl VkH265Decoder {
|
||||
generation: 0,
|
||||
device_lost: false,
|
||||
recovery: RecoveryLatch::default(),
|
||||
level_clamp_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1004,18 +1009,28 @@ impl VkH265Decoder {
|
||||
let raw = unsafe { query_h265_caps(&self.dev, key) }.map_err(VkDecodeError::from)?;
|
||||
self.caps = Some((key, derive_caps_h265(&raw, wanted)?));
|
||||
}
|
||||
// The level gate: a stream above the device's maxLevelIdc is refused up
|
||||
// front (within one codec the Std code points ascend with the level, so
|
||||
// the comparison is numeric), never submitted on a hope. The ceiling came
|
||||
// from an H.265 caps query, so it is compared against an H.265 code point
|
||||
// — the pairing MaxLevelIdc's tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above
|
||||
// `maxLevelIdc` is NOT a refusal. The level in an SPS is a claim, and
|
||||
// encoders over-claim in the wild — AMF stamps 6.2 (the codec maximum)
|
||||
// on 4K120 streams that need 5.2, which on an RTX 5060 (ceiling 6.1)
|
||||
// demoted every HEVC session to D3D11VA (2026-08-12 field report). The
|
||||
// stream's REAL demands are enforced where they are physical facts:
|
||||
// coded extent and DPB depth, checked in `rebuild_state`. The session's
|
||||
// parameter sets are clamped to the ceiling (`SessionConfigH265::
|
||||
// max_level_idc`) so the driver is never handed a level above its caps,
|
||||
// and the comparison stays within one codec's Std code space
|
||||
// (`MaxLevelIdc`'s tag carries that argument).
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = level_to_std_h265(plan.picture.level_idc);
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (Std code point {stream_level}) above the device's \
|
||||
maxLevelIdc ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
|
||||
self.level_clamp_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an H.265 level above the device ceiling — the \
|
||||
declared level is advisory (over-declared by some encoders); \
|
||||
proceeding with the parameter sets clamped to the ceiling"
|
||||
);
|
||||
}
|
||||
let coded = vk::Extent2D {
|
||||
width: plan.picture.coded_width,
|
||||
@@ -1108,6 +1123,7 @@ impl VkH265Decoder {
|
||||
max_dpb_slots: required_slots,
|
||||
max_active_references: (required_slots - 1).min(caps.max_active_references),
|
||||
profile: key,
|
||||
max_level_idc: caps.max_level_idc.code_point(),
|
||||
};
|
||||
let mut pool_plan = plan_pools(caps, required_slots);
|
||||
// TEST-ONLY readback hook, exactly as the H.264 decoder's: the parity
|
||||
|
||||
@@ -115,6 +115,17 @@ impl OwnedStdSps {
|
||||
pub fn std(&self) -> &hh::StdVideoH264SequenceParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower `level_idc` to `max` when the stream declares a higher one. The
|
||||
/// declared level is a claim encoders over-state in the wild, and a set above
|
||||
/// the device's `maxLevelIdc` is invalid usage; the stream's real demands are
|
||||
/// enforced by the session's coded extent and DPB depth. The "no mutation"
|
||||
/// contract above is about a LIVE object's blocks — this runs before handover.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH264LevelIdc) {
|
||||
if self.std.level_idc > max {
|
||||
self.std.level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted PPS plus the scaling-list allocation its `pScalingLists` targets.
|
||||
@@ -831,4 +842,24 @@ mod tests {
|
||||
ParamsError::InvalidWeightedBipredIdc(3)
|
||||
);
|
||||
}
|
||||
|
||||
/// The over-declared-level clamp ([`OwnedStdSps::clamp_level`]): lowering
|
||||
/// writes the ceiling into the Std SPS; a ceiling at or above the declared
|
||||
/// level changes nothing.
|
||||
#[test]
|
||||
fn clamp_level_lowers_and_only_lowers() {
|
||||
let sps = full_sps();
|
||||
let declared = level_to_std(sps.level_idc);
|
||||
|
||||
let mut owned = sps_to_std(&sps).unwrap();
|
||||
assert_eq!(owned.std().level_idc, declared);
|
||||
// A ceiling above the declared level is a no-op.
|
||||
owned.clamp_level(hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2);
|
||||
assert_eq!(owned.std().level_idc, declared);
|
||||
// A ceiling below it is written through.
|
||||
let ceiling = hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_1;
|
||||
assert!(ceiling < declared, "fixture declares above 3.1");
|
||||
owned.clamp_level(ceiling);
|
||||
assert_eq!(owned.std().level_idc, ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,19 @@ impl OwnedStdH265Vps {
|
||||
pub fn std(&self) -> &hh::StdVideoH265VideoParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower the profile/tier/level block's `general_level_idc` to `max` when the
|
||||
/// stream declares a higher one. The declared level is a CLAIM, and encoders
|
||||
/// over-claim in the wild (AMF stamps 6.2 — the codec maximum — on streams that
|
||||
/// need 5.2); handing the driver a level above its `maxLevelIdc` is invalid
|
||||
/// usage, while the stream's real demands are enforced by the session's coded
|
||||
/// extent and DPB depth. The "no mutation" ownership contract is about blocks a
|
||||
/// LIVE parameters object points at; this runs before the set is handed over.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
|
||||
if self._ptl_backing.general_level_idc > max {
|
||||
self._ptl_backing.general_level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted SPS plus the heap allocations its embedded pointers target.
|
||||
@@ -229,6 +242,14 @@ impl OwnedStdH265Sps {
|
||||
pub fn std(&self) -> &hh::StdVideoH265SequenceParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower `general_level_idc` to the device ceiling — [`OwnedStdH265Vps::clamp_level`]
|
||||
/// carries the argument.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
|
||||
if self._ptl_backing.general_level_idc > max {
|
||||
self._ptl_backing.general_level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted PPS plus the scaling-list allocation its `pScalingLists`
|
||||
@@ -2000,4 +2021,37 @@ mod tests {
|
||||
"the vector opens with VPS + SPS + PPS"
|
||||
);
|
||||
}
|
||||
|
||||
/// The over-declared-level clamp (the AMF 6.2-on-everything field case):
|
||||
/// lowering writes the ceiling into the PTL backing the driver will read;
|
||||
/// a ceiling at or above the declared level changes nothing.
|
||||
#[test]
|
||||
fn clamp_level_lowers_the_ptl_and_only_lowers() {
|
||||
let sps = full_sps();
|
||||
let declared = level_to_std(sps.profile_tier_level.general_level_idc);
|
||||
|
||||
let mut owned = sps_to_std_h265(&sps).unwrap();
|
||||
// SAFETY: pProfileTierLevel targets `owned`'s boxed backing.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, declared);
|
||||
// A ceiling above the declared level is a no-op.
|
||||
owned.clamp_level(hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_2);
|
||||
// SAFETY: as above.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, declared);
|
||||
// A ceiling below it is written through — and the pointer still targets
|
||||
// the wrapper's own backing (the clamp mutates in place, never re-points).
|
||||
let ceiling = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_1;
|
||||
assert!(ceiling < declared, "fixture declares above 3.1");
|
||||
owned.clamp_level(ceiling);
|
||||
// SAFETY: as above.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, ceiling);
|
||||
|
||||
let mut owned_vps = fallback_vps_from_sps(&sps).unwrap();
|
||||
owned_vps.clamp_level(ceiling);
|
||||
// SAFETY: as above, the VPS wrapper's own backing.
|
||||
let vps_level = unsafe { (*owned_vps.std().pProfileTierLevel).general_level_idc };
|
||||
assert!(vps_level <= ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@ pub struct SessionConfig {
|
||||
/// The Std profile the session was created against (a profile change is a
|
||||
/// renegotiation too).
|
||||
pub std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
/// The device's `maxLevelIdc` for this profile (Std code point). Every SPS
|
||||
/// handed to the parameters object has its declared level clamped to this —
|
||||
/// see `SessionConfigH265::max_level_idc` for the whole argument.
|
||||
pub max_level_idc: hh::StdVideoH264LevelIdc,
|
||||
}
|
||||
|
||||
/// Session creation/parameter failures the decoder maps into its error type.
|
||||
@@ -593,11 +597,14 @@ impl VideoSession {
|
||||
match action {
|
||||
ParamsAction::Current => Ok(()),
|
||||
ParamsAction::Add { add_sps, add_pps } => {
|
||||
let owned_sps = if add_sps {
|
||||
let mut owned_sps = if add_sps {
|
||||
Some(sps_to_std(sps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(s) = owned_sps.as_mut() {
|
||||
s.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
let owned_pps = if add_pps {
|
||||
Some(pps_to_std(pps)?)
|
||||
} else {
|
||||
@@ -643,7 +650,8 @@ impl VideoSession {
|
||||
pps_id = pps.pic_parameter_set_id,
|
||||
"recreating session parameters (content change or capacity)"
|
||||
);
|
||||
let owned_sps = sps_to_std(sps)?;
|
||||
let mut owned_sps = sps_to_std(sps)?;
|
||||
owned_sps.clamp_level(self.config.max_level_idc);
|
||||
let owned_pps = pps_to_std(pps)?;
|
||||
// SAFETY: fn contract — live device + live session. The wrappers
|
||||
// are MOVED IN and come back owned by the fresh object, so they
|
||||
|
||||
@@ -260,6 +260,12 @@ pub struct SessionConfigH265 {
|
||||
/// format / bit depths, all four of which a stream can renegotiate (an SPS
|
||||
/// switching Main→Main 10 mid-stream is a session rebuild, not an update).
|
||||
pub profile: H265ProfileKey,
|
||||
/// The device's `maxLevelIdc` for this profile (Std code point). Every VPS/SPS
|
||||
/// handed to the parameters object has its declared level clamped to this —
|
||||
/// over-declared levels are common (AMF stamps 6.2 on 4K streams) and a set
|
||||
/// above the ceiling is invalid usage, while the stream's real demands are
|
||||
/// already enforced by `max_coded_extent` / `max_dpb_slots`.
|
||||
pub max_level_idc: hh::StdVideoH265LevelIdc,
|
||||
}
|
||||
|
||||
/// A live parameters object **and every Std parameter set it was given**, in one
|
||||
@@ -525,12 +531,18 @@ impl VideoSessionH265 {
|
||||
} => {
|
||||
// Every owned wrapper below stays alive until after the update
|
||||
// call: the Std structs embed pointers into their heap blocks.
|
||||
let owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
|
||||
let owned_sps = if add_sps {
|
||||
let mut owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
|
||||
let mut owned_sps = if add_sps {
|
||||
Some(sps_to_std_h265(sps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(v) = owned_vps.as_mut() {
|
||||
v.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
if let Some(s) = owned_sps.as_mut() {
|
||||
s.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
let owned_pps = if add_pps {
|
||||
Some(pps_to_std_h265(pps)?)
|
||||
} else {
|
||||
@@ -582,8 +594,10 @@ impl VideoSessionH265 {
|
||||
pps_id = pps.pic_parameter_set_id,
|
||||
"recreating H.265 session parameters (content change or capacity)"
|
||||
);
|
||||
let owned_vps = vps.to_std()?;
|
||||
let owned_sps = sps_to_std_h265(sps)?;
|
||||
let mut owned_vps = vps.to_std()?;
|
||||
let mut owned_sps = sps_to_std_h265(sps)?;
|
||||
owned_vps.clamp_level(self.config.max_level_idc);
|
||||
owned_sps.clamp_level(self.config.max_level_idc);
|
||||
let owned_pps = pps_to_std_h265(pps)?;
|
||||
// SAFETY: fn contract — live device + live session. The wrappers
|
||||
// are MOVED IN and come back owned by the fresh object, so they
|
||||
|
||||
@@ -268,7 +268,7 @@ fn flagged(flags: &[bool]) -> Vec<usize> {
|
||||
flags
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &d)| d)
|
||||
.filter(|&(_, &d)| d)
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -830,7 +830,9 @@ fn h264_parity_run_against(
|
||||
|
||||
// The decoder reads this at session creation (first decode call): pool
|
||||
// images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal.
|
||||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||||
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
|
||||
// this variable's only writers and readers, and they run one at a time under that lock.
|
||||
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
|
||||
|
||||
let goldens = golden_hashes(goldens);
|
||||
assert_eq!(
|
||||
@@ -940,7 +942,9 @@ fn h265_parity_run(
|
||||
// As the H.264 leg: one codec at a time, `set_var` under the lock.
|
||||
let _gpu = common::gpu_lock();
|
||||
|
||||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||||
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
|
||||
// this variable's only writers and readers, and they run one at a time under that lock.
|
||||
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
|
||||
|
||||
let goldens = golden_hashes(goldens_file);
|
||||
assert_eq!(
|
||||
@@ -1124,7 +1128,9 @@ fn av1_parity_run_against(
|
||||
// happens only under this lock (see `common::gpu_lock`).
|
||||
let _gpu = common::gpu_lock();
|
||||
|
||||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||||
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
|
||||
// this variable's only writers and readers, and they run one at a time under that lock.
|
||||
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
|
||||
|
||||
let goldens = golden_hashes(goldens_file);
|
||||
// Non-vacuity, before any hardware is touched: the right number of entries, all
|
||||
@@ -1265,7 +1271,9 @@ fn low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec() {
|
||||
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
|
||||
fn av1_frame0_pixels_say_which_plane_and_how_badly() {
|
||||
let _gpu = common::gpu_lock();
|
||||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||||
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
|
||||
// this variable's only writers and readers, and they run one at a time under that lock.
|
||||
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
|
||||
|
||||
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
|
||||
assert_eq!(
|
||||
@@ -1338,7 +1346,9 @@ const MAX_LOOP_FILTER_LEVEL: u8 = 63;
|
||||
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
|
||||
fn av1_frame0_probes_whether_the_driver_reads_the_chroma_deblocking_levels() {
|
||||
let _gpu = common::gpu_lock();
|
||||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||||
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
|
||||
// this variable's only writers and readers, and they run one at a time under that lock.
|
||||
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
|
||||
|
||||
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
|
||||
assert_eq!(aus.len(), FRAME_COUNT);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[package]
|
||||
name = "pf-win-display"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host Windows display-topology helpers: CCD/GDI mode-set + path activation, HDR advanced colour, PnP monitor devnodes, and the display-change event watch."
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
//! AMD ADL connector/EDID emulation — the shared implementation behind the `display-disturb
|
||||
//! adl-emul` probe AND the `edid_lock` display-policy axis (the software equivalent of an
|
||||
//! HPD-holding dummy plug).
|
||||
//!
|
||||
//! Three field cases (ASUS VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI — all
|
||||
//! RX 9070 XT hosts) share one mechanism: a connected-but-asleep sink whose standby HPD/DDC/link
|
||||
//! servicing the KMD performs below every OS lever (CCD deactivation, devnode disable and CRU
|
||||
//! EDID overrides are confirmed no-ops — `design/vdisplay-disturbance-immunity.md` §2a/§3). The
|
||||
//! one software lever that can stop the servicing at its SOURCE is the driver's own connector
|
||||
//! emulation: pin the live EDID with `ADL2_Adapter_ConnectionData_Set`, then
|
||||
//! `ADL2_Adapter_EmulationMode_Set(ADL_EMUL_MODE_ALWAYS)` so the driver stops caring what the
|
||||
//! physical pins report.
|
||||
//!
|
||||
//! [`run`] performs one action across every AMD adapter's connectors and returns the per-op
|
||||
//! [`OpRecord`]s — the probe tool prints them as bench lines, the host tracing-logs them. The
|
||||
//! `edid_lock` axis drives [`lock_for_stream`]/[`unlock_after_stream`] at the Exclusive isolate
|
||||
//! (`pf-vdisplay`'s Windows manager), with a crash journal ([`startup_recover`]) because pinned
|
||||
//! emulation persists across host restarts — and can persist across REBOOTS, so every lock ships
|
||||
//! with its unlock (driver reinstall = the escape hatch of last resort).
|
||||
//!
|
||||
//! Everything is best-effort by design: no AMD driver (`atiadlxx.dll` absent) means the axis is
|
||||
//! inert, and each rc is preserved so a field log answers the consumer-vs-Pro gating question
|
||||
//! (`ADL_ERR_NOT_SUPPORTED(-8)` vs `ADL_OK`).
|
||||
|
||||
// FFI mirrors of ADL's C structs — keep AMD's field names verbatim so the header diff is
|
||||
// mechanical.
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::time::Instant;
|
||||
|
||||
use windows::core::{s, PCSTR};
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||
|
||||
// ---- ADL constants (adl_defines.h, GPUOpen display-library) ----
|
||||
|
||||
const ADL_OK: i32 = 0;
|
||||
const ADL_MAX_PATH: usize = 256;
|
||||
const ADL_MAX_DISPLAY_EDID_DATA_SIZE: usize = 1024;
|
||||
const ADL_MAX_RAD_LINK_COUNT: usize = 15;
|
||||
|
||||
const ADL_EMUL_MODE_OFF: i32 = 0;
|
||||
const ADL_EMUL_MODE_ALWAYS: i32 = 3;
|
||||
|
||||
const ADL_QUERY_REAL_DATA: i32 = 0;
|
||||
const ADL_QUERY_EMULATED_DATA: i32 = 1;
|
||||
|
||||
const ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED: i32 = 0x1;
|
||||
const ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT: i32 = 0x2;
|
||||
const ADL_EMUL_STATUS_EMULATED_DEVICE_USED: i32 = 0x4;
|
||||
|
||||
const AMD_VENDOR_ID: i32 = 1002;
|
||||
|
||||
/// Decode the rc values a field log will actually contain (adl_defines.h) — `-8` vs `-1` is the
|
||||
/// whole consumer-vs-Pro question, so spell them out.
|
||||
pub fn rc_str(rc: i32) -> &'static str {
|
||||
match rc {
|
||||
0 => "ADL_OK",
|
||||
1..=4 => "ADL_OK_(warning-class)",
|
||||
-1 => "ADL_ERR",
|
||||
-2 => "ADL_ERR_NOT_INIT",
|
||||
-3 => "ADL_ERR_INVALID_PARAM",
|
||||
-5 => "ADL_ERR_INVALID_ADL_IDX",
|
||||
-8 => "ADL_ERR_NOT_SUPPORTED",
|
||||
-9 => "ADL_ERR_NULL_POINTER",
|
||||
-10 => "ADL_ERR_DISABLED_ADAPTER",
|
||||
-22 => "ADL_ERR_CALL_TO_INCOMPATIABLE_DRIVER",
|
||||
-23 => "ADL_ERR_NO_ADMINISTRATOR_PRIVILEGES",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_type_str(t: i32) -> &'static str {
|
||||
match t {
|
||||
1 => "VGA",
|
||||
2 => "DVI-D",
|
||||
3 => "DVI-I",
|
||||
8 => "HDMI-A",
|
||||
9 => "HDMI-B",
|
||||
10 => "DP",
|
||||
11 => "eDP",
|
||||
12 => "miniDP",
|
||||
13 => "VIRTUAL",
|
||||
14 => "USB-C",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ADL structs (adl_structures.h, verbatim layouts) ----
|
||||
|
||||
#[repr(C)]
|
||||
struct AdapterInfo {
|
||||
iSize: i32,
|
||||
iAdapterIndex: i32,
|
||||
strUDID: [u8; ADL_MAX_PATH],
|
||||
iBusNumber: i32,
|
||||
iDeviceNumber: i32,
|
||||
iFunctionNumber: i32,
|
||||
iVendorID: i32,
|
||||
strAdapterName: [u8; ADL_MAX_PATH],
|
||||
strDisplayName: [u8; ADL_MAX_PATH],
|
||||
iPresent: i32,
|
||||
// _WIN32 tail — this tool only builds for Windows.
|
||||
iExist: i32,
|
||||
strDriverPath: [u8; ADL_MAX_PATH],
|
||||
strDriverPathExt: [u8; ADL_MAX_PATH],
|
||||
strPNPString: [u8; ADL_MAX_PATH],
|
||||
iOSDisplayIndex: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLMSTRad {
|
||||
iLinkNumber: i32,
|
||||
rad: [u8; ADL_MAX_RAD_LINK_COUNT],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLDevicePort {
|
||||
iConnectorIndex: i32,
|
||||
aMSTRad: ADLMSTRad,
|
||||
}
|
||||
|
||||
impl ADLDevicePort {
|
||||
/// A non-MST port at `connector` (MST RAD all-zero = "DP root / non-DP ignored" per header).
|
||||
fn root(connector: i32) -> Self {
|
||||
Self {
|
||||
iConnectorIndex: connector,
|
||||
aMSTRad: ADLMSTRad {
|
||||
iLinkNumber: 0,
|
||||
rad: [0; ADL_MAX_RAD_LINK_COUNT],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLConnectionProperties {
|
||||
iValidProperties: i32,
|
||||
iBitrate: i32,
|
||||
iNumberOfLanes: i32,
|
||||
iColorDepth: i32,
|
||||
iStereo3DCaps: i32,
|
||||
iOutputBandwidth: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLConnectionData {
|
||||
iConnectionType: i32,
|
||||
aConnectionProperties: ADLConnectionProperties,
|
||||
iNumberofPorts: i32,
|
||||
iActiveConnections: i32,
|
||||
iDataSize: i32,
|
||||
EdidData: [u8; ADL_MAX_DISPLAY_EDID_DATA_SIZE],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct ADLConnectionState {
|
||||
iEmulationStatus: i32,
|
||||
iEmulationMode: i32,
|
||||
iDisplayIndex: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLConnectorInfo {
|
||||
iConnectorIndex: i32,
|
||||
iConnectorId: i32,
|
||||
iSlotIndex: i32,
|
||||
iType: i32,
|
||||
iOffset: i32,
|
||||
iLength: i32,
|
||||
}
|
||||
|
||||
// ---- dynamic binding (atiadlxx.dll ships with every AMD driver; absent elsewhere) ----
|
||||
|
||||
type AdlContext = *mut c_void;
|
||||
type MallocCb = unsafe extern "C" fn(i32) -> *mut c_void;
|
||||
|
||||
type FnMainCreate = unsafe extern "C" fn(MallocCb, i32, *mut AdlContext) -> i32;
|
||||
type FnMainDestroy = unsafe extern "C" fn(AdlContext) -> i32;
|
||||
type FnNumAdapters = unsafe extern "C" fn(AdlContext, *mut i32) -> i32;
|
||||
type FnAdapterInfoGet = unsafe extern "C" fn(AdlContext, *mut AdapterInfo, i32) -> i32;
|
||||
type FnEdidMgmtCaps = unsafe extern "C" fn(AdlContext, i32, *mut i32) -> i32;
|
||||
type FnBoardLayoutGet = unsafe extern "C" fn(
|
||||
AdlContext,
|
||||
i32,
|
||||
*mut i32,
|
||||
*mut i32,
|
||||
*mut *mut c_void,
|
||||
*mut i32,
|
||||
*mut *mut ADLConnectorInfo,
|
||||
) -> i32;
|
||||
type FnConnStateGet =
|
||||
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, *mut ADLConnectionState) -> i32;
|
||||
type FnConnDataGet =
|
||||
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32, *mut ADLConnectionData) -> i32;
|
||||
type FnConnDataSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, ADLConnectionData) -> i32;
|
||||
type FnConnDataRemove = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort) -> i32;
|
||||
type FnEmulModeSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32) -> i32;
|
||||
|
||||
struct Adl {
|
||||
create: FnMainCreate,
|
||||
destroy: FnMainDestroy,
|
||||
num_adapters: FnNumAdapters,
|
||||
adapter_info: FnAdapterInfoGet,
|
||||
edid_caps: FnEdidMgmtCaps,
|
||||
board_layout: FnBoardLayoutGet,
|
||||
conn_state: FnConnStateGet,
|
||||
conn_data_get: FnConnDataGet,
|
||||
conn_data_set: FnConnDataSet,
|
||||
conn_data_remove: FnConnDataRemove,
|
||||
emul_mode_set: FnEmulModeSet,
|
||||
}
|
||||
|
||||
/// ADL's application-provided allocator: it hands buffers (board-layout arrays) back through
|
||||
/// out-pointers and expects the app to own them.
|
||||
unsafe extern "C" fn adl_malloc(size: i32) -> *mut c_void {
|
||||
let size = size.max(1) as usize;
|
||||
// Panic-free: a panic here would cross the extern boundary and abort the host. The Err arm
|
||||
// is unreachable in practice (size ≤ i32::MAX can't overflow the layout), and ADL treats a
|
||||
// null from its allocator as an ordinary failure.
|
||||
let Ok(layout) = std::alloc::Layout::from_size_align(size, 16) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
// SAFETY: non-zero size with a fixed valid alignment; the resulting buffers are deliberately
|
||||
// never freed — ADL's contract wants an ADL_Main_Memory_Free symmetry, and leaking the <1 KiB
|
||||
// of board-layout arrays in a one-shot probe is simpler than proving allocator parity.
|
||||
unsafe { std::alloc::alloc(layout) as *mut c_void }
|
||||
}
|
||||
|
||||
impl Adl {
|
||||
fn load() -> Option<Self> {
|
||||
// SAFETY: plain LoadLibrary of the AMD-driver-installed ADL runtime by its well-known
|
||||
// name; a foreign-DLL search-path attack would require writing to System32.
|
||||
let lib: HMODULE = unsafe { LoadLibraryA(s!("atiadlxx.dll")) }.ok()?;
|
||||
// One unsafe helper: resolve `name` or bail. Every Fn* type above matches the ADL
|
||||
// header's C signature (x64 has a single calling convention, so `extern "C"` is exact).
|
||||
unsafe fn sym<T: Copy>(lib: HMODULE, name: PCSTR) -> Option<T> {
|
||||
debug_assert_eq!(std::mem::size_of::<T>(), std::mem::size_of::<usize>());
|
||||
// SAFETY: caller passes a fn-pointer type T of pointer size (asserted above);
|
||||
// GetProcAddress yields the export's address or None.
|
||||
let f = unsafe { GetProcAddress(lib, name) }?;
|
||||
// SAFETY: reinterpreting one non-null fn pointer as the export's true C signature.
|
||||
Some(unsafe { std::mem::transmute_copy::<_, T>(&f) })
|
||||
}
|
||||
// SAFETY: `lib` is the live module handle from the successful load above.
|
||||
unsafe {
|
||||
Some(Self {
|
||||
create: sym(lib, s!("ADL2_Main_Control_Create"))?,
|
||||
destroy: sym(lib, s!("ADL2_Main_Control_Destroy"))?,
|
||||
num_adapters: sym(lib, s!("ADL2_Adapter_NumberOfAdapters_Get"))?,
|
||||
adapter_info: sym(lib, s!("ADL2_Adapter_AdapterInfo_Get"))?,
|
||||
edid_caps: sym(lib, s!("ADL2_Adapter_EDIDManagement_Caps"))?,
|
||||
board_layout: sym(lib, s!("ADL2_Adapter_BoardLayout_Get"))?,
|
||||
conn_state: sym(lib, s!("ADL2_Adapter_ConnectionState_Get"))?,
|
||||
conn_data_get: sym(lib, s!("ADL2_Adapter_ConnectionData_Get"))?,
|
||||
conn_data_set: sym(lib, s!("ADL2_Adapter_ConnectionData_Set"))?,
|
||||
conn_data_remove: sym(lib, s!("ADL2_Adapter_ConnectionData_Remove"))?,
|
||||
emul_mode_set: sym(lib, s!("ADL2_Adapter_EmulationMode_Set"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the library surface ----
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EmulAction {
|
||||
/// Read-only: caps + layout + per-connector state. Always safe.
|
||||
Probe,
|
||||
/// Pin live EDID + `ADL_EMUL_MODE_ALWAYS` on occupied (or named) connectors.
|
||||
Lock,
|
||||
/// `ADL_EMUL_MODE_OFF` + remove pinned EDID on all (or named) connectors.
|
||||
Unlock,
|
||||
}
|
||||
|
||||
/// One ADL call's outcome — op name, target, duration, rc (decoded via [`rc_str`]) and the
|
||||
/// op-specific fields. The probe tool prints these as its bench correlation lines; the host
|
||||
/// tracing-logs them. The rc IS the deliverable of a field run.
|
||||
pub struct OpRecord {
|
||||
pub op: &'static str,
|
||||
pub target: String,
|
||||
pub took_ms: u128,
|
||||
pub rc: i32,
|
||||
pub extra: String,
|
||||
}
|
||||
|
||||
impl OpRecord {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.rc == ADL_OK
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OpRecord {
|
||||
/// The bench line minus the caller's epoch prefix: `op target took_ms ok rc=N(STR) extra`.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let sep = if self.extra.is_empty() { "" } else { " " };
|
||||
write!(
|
||||
f,
|
||||
"{} {} took_ms={} ok={} rc={}({}){sep}{}",
|
||||
self.op,
|
||||
self.target,
|
||||
self.took_ms,
|
||||
self.ok(),
|
||||
self.rc,
|
||||
rc_str(self.rc),
|
||||
self.extra
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a [`run`] ended: the AMD runtime was absent entirely, died at init (nothing was touched),
|
||||
/// or walked the connectors (each op's rc in the records — a NOT_SUPPORTED driver still `Done`s).
|
||||
pub enum RunOutcome {
|
||||
/// `atiadlxx.dll` not loadable (or an export missing) — not an AMD driver install; the
|
||||
/// emulation lever does not exist on this box.
|
||||
NoAdl,
|
||||
/// `ADL2_Main_Control_Create` / adapter enumeration failed — records hold the failing rc.
|
||||
InitFailed(Vec<OpRecord>),
|
||||
/// The connector walk ran; every op's outcome is in the records.
|
||||
Done(Vec<OpRecord>),
|
||||
}
|
||||
|
||||
impl RunOutcome {
|
||||
pub fn records(&self) -> &[OpRecord] {
|
||||
match self {
|
||||
RunOutcome::NoAdl => &[],
|
||||
RunOutcome::InitFailed(r) | RunOutcome::Done(r) => r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn c_str(buf: &[u8]) -> String {
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..len]).into_owned()
|
||||
}
|
||||
|
||||
/// Perform `action` on every AMD adapter's connectors (or only `connector_filter`), returning the
|
||||
/// per-op records. Read-only for [`EmulAction::Probe`]; [`EmulAction::Lock`] pins occupied
|
||||
/// connectors only (unless the filter names one), matching an HPD dummy on the cables that exist.
|
||||
pub fn run(action: EmulAction, connector_filter: Option<i32>) -> RunOutcome {
|
||||
let mut recs: Vec<OpRecord> = Vec::new();
|
||||
let mut rec = |op: &'static str, target: &str, took_ms: u128, rc: i32, extra: String| {
|
||||
recs.push(OpRecord {
|
||||
op,
|
||||
target: target.to_owned(),
|
||||
took_ms,
|
||||
rc,
|
||||
extra,
|
||||
});
|
||||
};
|
||||
|
||||
let Some(adl) = Adl::load() else {
|
||||
return RunOutcome::NoAdl;
|
||||
};
|
||||
|
||||
let mut ctx: AdlContext = std::ptr::null_mut();
|
||||
let t = Instant::now();
|
||||
// SAFETY: documented init call — our allocator callback, iEnumConnectedAdapters=0 (ALL
|
||||
// adapters: an exclusively-isolated streaming host may report no "connected" display on the
|
||||
// physical GPU), and a valid out-slot for the context.
|
||||
let rc = unsafe { (adl.create)(adl_malloc, 0, &mut ctx) };
|
||||
rec(
|
||||
"adl-init",
|
||||
"atiadlxx",
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
if rc != ADL_OK {
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
|
||||
let mut count = 0i32;
|
||||
// SAFETY: live context; valid out-param.
|
||||
let rc = unsafe { (adl.num_adapters)(ctx, &mut count) };
|
||||
if rc != ADL_OK || count <= 0 {
|
||||
rec("adl-num-adapters", "all", 0, rc, format!("count={count}"));
|
||||
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
|
||||
let _ = unsafe { (adl.destroy)(ctx) };
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
let mut infos: Vec<AdapterInfo> = (0..count)
|
||||
.map(|_| {
|
||||
// SAFETY: AdapterInfo is plain ints + byte arrays — the all-zero pattern is valid,
|
||||
// and ADL fills the array in place.
|
||||
let mut a: AdapterInfo = unsafe { std::mem::zeroed() };
|
||||
a.iSize = std::mem::size_of::<AdapterInfo>() as i32;
|
||||
a
|
||||
})
|
||||
.collect();
|
||||
let bytes = std::mem::size_of_val(infos.as_slice()) as i32;
|
||||
// SAFETY: caller-allocated array of exactly `count` stamped entries, byte size passed as the
|
||||
// API's iInputSize contract requires.
|
||||
let rc = unsafe { (adl.adapter_info)(ctx, infos.as_mut_ptr(), bytes) };
|
||||
rec("adl-adapters", "all", 0, rc, format!("count={count}"));
|
||||
if rc != ADL_OK {
|
||||
// SAFETY: as above — context teardown, nothing ADL-owned used afterwards.
|
||||
let _ = unsafe { (adl.destroy)(ctx) };
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
|
||||
// One GPU surfaces as many logical adapters — probe each bus once, AMD-present only.
|
||||
let mut seen_buses: Vec<i32> = Vec::new();
|
||||
for info in &infos {
|
||||
if info.iPresent == 0
|
||||
|| info.iVendorID != AMD_VENDOR_ID
|
||||
|| seen_buses.contains(&info.iBusNumber)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
seen_buses.push(info.iBusNumber);
|
||||
let idx = info.iAdapterIndex;
|
||||
let name = c_str(&info.strAdapterName);
|
||||
let target = format!("adapter{idx}[{}]", name.trim());
|
||||
|
||||
let mut supported = 0i32;
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context, adapter index from this enumeration, valid out-param.
|
||||
let rc = unsafe { (adl.edid_caps)(ctx, idx, &mut supported) };
|
||||
rec(
|
||||
"adl-edid-caps",
|
||||
&target,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!("supported={supported}"),
|
||||
);
|
||||
|
||||
let (mut valid, mut n_slots, mut n_conn) = (0i32, 0i32, 0i32);
|
||||
let mut slots: *mut c_void = std::ptr::null_mut();
|
||||
let mut connectors: *mut ADLConnectorInfo = std::ptr::null_mut();
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context + adapter index; out-pointers valid; ADL allocates the two arrays
|
||||
// through `adl_malloc` (deliberately leaked, see there).
|
||||
let rc = unsafe {
|
||||
(adl.board_layout)(
|
||||
ctx,
|
||||
idx,
|
||||
&mut valid,
|
||||
&mut n_slots,
|
||||
&mut slots,
|
||||
&mut n_conn,
|
||||
&mut connectors,
|
||||
)
|
||||
};
|
||||
rec(
|
||||
"adl-board-layout",
|
||||
&target,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!("connectors={n_conn} valid_flags={valid:#x}"),
|
||||
);
|
||||
let connector_list: &[ADLConnectorInfo] =
|
||||
if rc == ADL_OK && !connectors.is_null() && n_conn > 0 {
|
||||
// SAFETY: ADL just filled `connectors` with `n_conn` entries via our allocator; the
|
||||
// (leaked) buffer outlives this borrow.
|
||||
unsafe { std::slice::from_raw_parts(connectors, n_conn as usize) }
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
|
||||
for c in connector_list {
|
||||
if connector_filter.is_some_and(|want| want != c.iConnectorIndex) {
|
||||
continue;
|
||||
}
|
||||
let port = ADLDevicePort::root(c.iConnectorIndex);
|
||||
let ctarget = format!(
|
||||
"adapter{idx}.connector{}[{}]",
|
||||
c.iConnectorIndex,
|
||||
connector_type_str(c.iType)
|
||||
);
|
||||
|
||||
let mut state = ADLConnectionState::default();
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context; port is a by-value POD naming a connector this adapter just
|
||||
// enumerated; valid out-param.
|
||||
let rc = unsafe { (adl.conn_state)(ctx, idx, port, &mut state) };
|
||||
let real = state.iEmulationStatus & ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED != 0;
|
||||
rec(
|
||||
"adl-conn-state",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!(
|
||||
"status={:#x} real_connected={} emulated_present={} emulated_used={} mode={} display={}",
|
||||
state.iEmulationStatus,
|
||||
real,
|
||||
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT != 0,
|
||||
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_USED != 0,
|
||||
state.iEmulationMode,
|
||||
state.iDisplayIndex,
|
||||
),
|
||||
);
|
||||
if rc != ADL_OK {
|
||||
continue;
|
||||
}
|
||||
|
||||
match action {
|
||||
EmulAction::Probe => {
|
||||
// SAFETY: ADLConnectionData is plain ints + a byte array; all-zero is valid
|
||||
// and ADL overwrites it.
|
||||
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port as above; REAL query fills `data` in place.
|
||||
let rc = unsafe {
|
||||
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
|
||||
};
|
||||
rec(
|
||||
"adl-conn-data",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!(
|
||||
"type={} edid_bytes={}",
|
||||
data.iConnectionType, data.iDataSize
|
||||
),
|
||||
);
|
||||
}
|
||||
EmulAction::Lock => {
|
||||
if !real && connector_filter.is_none() {
|
||||
continue; // nothing to pin — and pinning an EMPTY connector is a different experiment
|
||||
}
|
||||
// SAFETY: as in Probe — zeroed then driver-filled.
|
||||
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; REAL query first — we pin exactly what the
|
||||
// sink reports today, so the emulated display IS the user's monitor.
|
||||
let mut rc = unsafe {
|
||||
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
|
||||
};
|
||||
if rc != ADL_OK {
|
||||
// Asleep sinks can refuse a live EDID read — fall back to whatever the
|
||||
// driver already has as emulation data (Radeon-Pro-UI parity).
|
||||
// SAFETY: same contract, emulated-data query.
|
||||
rc = unsafe {
|
||||
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_EMULATED_DATA, &mut data)
|
||||
};
|
||||
}
|
||||
rec(
|
||||
"adl-lock-read",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!(
|
||||
"type={} edid_bytes={}",
|
||||
data.iConnectionType, data.iDataSize
|
||||
),
|
||||
);
|
||||
if rc != ADL_OK || data.iDataSize <= 0 {
|
||||
continue;
|
||||
}
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; `data` passed by value per the ADL signature.
|
||||
let rc = unsafe { (adl.conn_data_set)(ctx, idx, port, data) };
|
||||
rec(
|
||||
"adl-lock-set",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; mode constant from the header.
|
||||
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_ALWAYS) };
|
||||
rec(
|
||||
"adl-lock-mode-always",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
}
|
||||
EmulAction::Unlock => {
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; mode constant from the header.
|
||||
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_OFF) };
|
||||
rec(
|
||||
"adl-unlock-mode-off",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; removes emulation data set earlier (harmless
|
||||
// where none exists — the rc says so).
|
||||
let rc = unsafe { (adl.conn_data_remove)(ctx, idx, port) };
|
||||
rec(
|
||||
"adl-unlock-remove",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
|
||||
let _ = unsafe { (adl.destroy)(ctx) };
|
||||
RunOutcome::Done(recs)
|
||||
}
|
||||
|
||||
// ---- the `edid_lock` display-policy axis (host-side) ----
|
||||
|
||||
/// The crash-recovery journal: a marker that a lock was applied and not yet unlocked. Pinned
|
||||
/// emulation outlives the process (and can outlive a reboot), so a host that died mid-stream
|
||||
/// must unlock on its next start ([`startup_recover`]).
|
||||
fn journal_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("edid-lock-active.json")
|
||||
}
|
||||
|
||||
fn tracing_log(prefix: &str, outcome: &RunOutcome) {
|
||||
match outcome {
|
||||
RunOutcome::NoAdl => tracing::info!(
|
||||
"{prefix}: atiadlxx.dll not loadable — not an AMD driver install; the ADL \
|
||||
emulation lever does not exist on this box"
|
||||
),
|
||||
RunOutcome::InitFailed(recs) | RunOutcome::Done(recs) => {
|
||||
for r in recs {
|
||||
if r.ok() {
|
||||
tracing::info!("{prefix}: {r}");
|
||||
} else {
|
||||
tracing::warn!("{prefix}: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the `edid_lock` axis at stream bring-up: pin the live EDID + `ADL_EMUL_MODE_ALWAYS` on
|
||||
/// every occupied AMD connector (the software HPD dummy), journaling first so a crash still
|
||||
/// unlocks on the next host start. Returns whether a later [`unlock_after_stream`] is owed —
|
||||
/// true whenever the ADL runtime exists, because even a partially-failed lock may have pinned
|
||||
/// some connectors (each rc is in the log).
|
||||
pub fn lock_for_stream() -> bool {
|
||||
// Journal BEFORE touching the driver: a crash between the first `ConnectionData_Set` and the
|
||||
// journal write would otherwise leave pinned connectors with no startup unlock owed.
|
||||
if let Err(e) = std::fs::write(journal_path(), b"{\"locked\":true}") {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"edid_lock: crash journal write failed — continuing (the feature degrades to \
|
||||
no-crash-journal)"
|
||||
);
|
||||
}
|
||||
let outcome = run(EmulAction::Lock, None);
|
||||
tracing_log("edid_lock", &outcome);
|
||||
if matches!(outcome, RunOutcome::NoAdl) {
|
||||
tracing::info!("edid_lock: enabled but this is not an AMD driver install — axis inert");
|
||||
let _ = std::fs::remove_file(journal_path());
|
||||
return false;
|
||||
}
|
||||
let locked = outcome
|
||||
.records()
|
||||
.iter()
|
||||
.filter(|r| r.op == "adl-lock-mode-always" && r.ok())
|
||||
.count();
|
||||
tracing::info!(
|
||||
connectors = locked,
|
||||
"edid_lock: connector emulation pinned (software HPD dummy) — unlocked at stream teardown"
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Undo [`lock_for_stream`] at teardown: `ADL_EMUL_MODE_OFF` + remove the pinned EDID on every
|
||||
/// AMD connector, then clear the crash journal. Idempotent and harmless where nothing is pinned.
|
||||
pub fn unlock_after_stream() {
|
||||
let outcome = run(EmulAction::Unlock, None);
|
||||
tracing_log("edid_lock", &outcome);
|
||||
let _ = std::fs::remove_file(journal_path());
|
||||
}
|
||||
|
||||
/// Host-startup crash recovery: a previous host that died holding the lock left connector
|
||||
/// emulation pinned (it persists past the process — and can persist past a reboot). If the
|
||||
/// journal marker exists, unlock everything and clear it — before any new session touches the
|
||||
/// topology, mirroring `monitor_devnode::startup_recover`.
|
||||
pub fn startup_recover() {
|
||||
if !journal_path().exists() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
"edid_lock: a previous host left connector emulation pinned (crash/kill) — unlocking"
|
||||
);
|
||||
unlock_after_stream();
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
// hoist that to the crate root so the smaller modules (`input_desktop`, `monitor_devnode`,
|
||||
// `display_events`) and any future one are covered by default rather than by remembering to opt in.
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod adl_emul;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod display_events;
|
||||
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
|
||||
|
||||
@@ -217,18 +217,32 @@ fn journal_and_disable(targets: Vec<(String, String)>) -> Vec<String> {
|
||||
disabled
|
||||
}
|
||||
|
||||
/// Re-enable `ids` (teardown / recovery) and clear them from the journal.
|
||||
/// Re-enable `ids` (teardown / recovery) and clear the ones that actually re-enabled from the
|
||||
/// journal. A FAILED re-enable must keep its journal entry: it is the only record that the
|
||||
/// devnode is still disabled, and the next host start's [`startup_recover`] is the only thing
|
||||
/// left that will retry it. (The old behavior cleared every requested id unconditionally — a
|
||||
/// mid-life re-enable failure erased its own crash-recovery entry, leaving the operator's
|
||||
/// monitor invisible to Windows AND to every display listing until they re-enabled it by hand
|
||||
/// in Device Manager: the "my displays are gone until I restart everything" field class.)
|
||||
pub fn enable_instances(ids: &[String]) -> u32 {
|
||||
let mut ok = 0u32;
|
||||
let mut reenabled: Vec<&String> = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if set_devnode(id, false) {
|
||||
tracing::info!(id, "PnP-disable: monitor devnode re-enabled");
|
||||
reenabled.push(id);
|
||||
ok += 1;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
id,
|
||||
"PnP-disable: monitor devnode re-enable FAILED — keeping its crash-journal \
|
||||
entry so the next host start retries (until then this monitor stays disabled)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let journal: Vec<String> = read_journal()
|
||||
.into_iter()
|
||||
.filter(|j| !ids.contains(j))
|
||||
.filter(|j| !reenabled.contains(&j))
|
||||
.collect();
|
||||
write_journal(&journal);
|
||||
ok
|
||||
|
||||
@@ -1317,11 +1317,14 @@ pub mod isolate_journal {
|
||||
let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("scratch dir");
|
||||
std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir);
|
||||
// SAFETY: `_g` holds this module's ENV mutex, which serializes every test that
|
||||
// writes or reads `PUNKTFUNK_CONFIG_DIR` in this binary.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir) };
|
||||
clear(); // reset the LAST cache + any leftover marker from a previous run
|
||||
f(&dir);
|
||||
clear();
|
||||
std::env::remove_var("PUNKTFUNK_CONFIG_DIR");
|
||||
// SAFETY: still under `_g` — the same serialization as the set above.
|
||||
unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") };
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1529,10 +1532,14 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
|
||||
// and confirm no non-keep display survived. Only then is the virtual set truly the sole desktop.
|
||||
let survivors = count_other_active(keep_target_ids).unwrap_or(0);
|
||||
if survivors == 0 {
|
||||
tracing::info!("display isolate (CCD): target set {keep_target_ids:?} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})");
|
||||
tracing::info!(
|
||||
"display isolate (CCD): target set {keep_target_ids:?} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})"
|
||||
);
|
||||
return Some(saved);
|
||||
}
|
||||
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
// Name the survivors instead of assuming their kind — the field logs showed this path fire
|
||||
@@ -1932,7 +1939,9 @@ pub fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig> {
|
||||
)
|
||||
});
|
||||
if rc == 0 {
|
||||
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
|
||||
tracing::info!(
|
||||
"display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"display primary (CCD): SetDisplayConfig failed rc={rc:#x}{} (virtual {keep_target_id} primary, physicals kept)",
|
||||
@@ -1962,29 +1971,173 @@ pub fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
isolate_journal::clear();
|
||||
}
|
||||
|
||||
/// Every display target that still EXISTS right now — `(adapter LUID low, high, target id)` keys
|
||||
/// from a full `QDC_ALL_PATHS` sweep, counting a target present when the OS says a monitor is
|
||||
/// attached (`targetAvailable`) OR an active path drives it (the flag reads FALSE transiently
|
||||
/// right after a removal — same rule as [`target_inventory`]). `None` when the CCD query itself
|
||||
/// fails, so the caller can fall back to trusting its snapshot verbatim.
|
||||
fn available_target_keys() -> Option<Vec<(u32, i32, u32)>> {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
// SAFETY: the CCD contract at the top of this file — `&mut np`/`&mut nm` are live locals the
|
||||
// OS fills with the counts it wants for these flags.
|
||||
if unsafe { GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut np, &mut nm) }.is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
// SAFETY: the CCD contract — `paths`/`modes` were just allocated with exactly `np`/`nm`
|
||||
// elements from the sizing call above, and are handed over with those same counts.
|
||||
if unsafe {
|
||||
QueryDisplayConfig(
|
||||
QDC_ALL_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
paths.truncate(np as usize);
|
||||
let mut keys: Vec<(u32, i32, u32)> = Vec::new();
|
||||
for p in &paths {
|
||||
let t = &p.targetInfo;
|
||||
let key = (t.adapterId.LowPart, t.adapterId.HighPart, t.id);
|
||||
let present = t.targetAvailable.as_bool() || p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0;
|
||||
if present && !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
/// Drop every snapshot path whose TARGET no longer exists (`avail` — the live
|
||||
/// [`available_target_keys`] sweep) and rebuild the mode table with only the entries the
|
||||
/// survivors reference, remapping their `modeInfoIdx` slots. Both halves matter:
|
||||
/// `SetDisplayConfig(SDC_USE_SUPPLIED_DISPLAY_CONFIG)` validates the WHOLE submission, so one
|
||||
/// stale path — or one orphaned mode entry left behind by a dropped path — fails the entire
|
||||
/// restore with 0x57 ERROR_INVALID_PARAMETER. Returns `(paths, modes, dropped_path_count)`;
|
||||
/// pure over its inputs so the remap arithmetic is unit-testable without a live CCD.
|
||||
fn prune_saved_config_for_targets(
|
||||
paths: &[DISPLAYCONFIG_PATH_INFO],
|
||||
modes: &[DISPLAYCONFIG_MODE_INFO],
|
||||
avail: &[(u32, i32, u32)],
|
||||
) -> (
|
||||
Vec<DISPLAYCONFIG_PATH_INFO>,
|
||||
Vec<DISPLAYCONFIG_MODE_INFO>,
|
||||
usize,
|
||||
) {
|
||||
let mut kept: Vec<DISPLAYCONFIG_PATH_INFO> = Vec::with_capacity(paths.len());
|
||||
let mut new_modes: Vec<DISPLAYCONFIG_MODE_INFO> = Vec::with_capacity(modes.len());
|
||||
// old mode index → new mode index, memoized: clone configs legitimately share a source mode
|
||||
// entry between paths, and it must land in the rebuilt table exactly once.
|
||||
let mut remap: Vec<Option<u32>> = vec![None; modes.len()];
|
||||
let take =
|
||||
|idx: u32, new_modes: &mut Vec<DISPLAYCONFIG_MODE_INFO>, remap: &mut Vec<Option<u32>>| {
|
||||
if idx == DISPLAYCONFIG_PATH_MODE_IDX_INVALID {
|
||||
return DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
|
||||
}
|
||||
match modes.get(idx as usize) {
|
||||
// An out-of-range index could never have applied — un-pin the mode rather than
|
||||
// shipping a table the whole submission fails on.
|
||||
None => DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
|
||||
Some(m) => match remap[idx as usize] {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let n = new_modes.len() as u32;
|
||||
new_modes.push(*m);
|
||||
remap[idx as usize] = Some(n);
|
||||
n
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
let mut dropped = 0usize;
|
||||
for p in paths {
|
||||
let t = &p.targetInfo;
|
||||
if !avail.contains(&(t.adapterId.LowPart, t.adapterId.HighPart, t.id)) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
let mut p = *p;
|
||||
// SAFETY: POD union reads (CCD header contract) — `modeInfoIdx` overlays a same-sized
|
||||
// bitfield struct, both valid for every bit pattern; used only as bounds-checked indices.
|
||||
let (src_idx, tgt_idx) = unsafe {
|
||||
(
|
||||
p.sourceInfo.Anonymous.modeInfoIdx,
|
||||
p.targetInfo.Anonymous.modeInfoIdx,
|
||||
)
|
||||
};
|
||||
p.sourceInfo.Anonymous.modeInfoIdx = take(src_idx, &mut new_modes, &mut remap);
|
||||
p.targetInfo.Anonymous.modeInfoIdx = take(tgt_idx, &mut new_modes, &mut remap);
|
||||
kept.push(p);
|
||||
}
|
||||
(kept, new_modes, dropped)
|
||||
}
|
||||
|
||||
fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
let (paths, modes) = saved;
|
||||
if paths.is_empty() {
|
||||
let (saved_paths, saved_modes) = saved;
|
||||
if saved_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
|
||||
// slices, so pointer and length cannot disagree, and both outlive this synchronous
|
||||
// call. `retry_set_display_config` binds it to the input desktop, which is the one
|
||||
// precondition a caller of this global-state write could otherwise get wrong.
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
)
|
||||
});
|
||||
if rc == 0 {
|
||||
tracing::info!("display isolate (CCD): restored original topology");
|
||||
} else {
|
||||
// Prune the snapshot against what is STILL ATTACHED before replaying it. A monitor unplugged
|
||||
// mid-session leaves the snapshot referencing an absent target, and SetDisplayConfig rejects
|
||||
// the WHOLE array with 0x57 ERROR_INVALID_PARAMETER — nothing restores, the desk stays dark,
|
||||
// and the next session snapshots that wreckage (the poisoned-snapshot chain's first link;
|
||||
// field 2026-08-12: rc=0x57 across a mid-session unplug, then sessions flipping between
|
||||
// black/working at random). Dropping the stale paths lets the surviving displays restore
|
||||
// normally; when NOTHING survives there is nothing to replay and the dark-desk backstop
|
||||
// below is the whole answer.
|
||||
let (kept, pruned_modes, dropped);
|
||||
let (paths, modes): (&Vec<_>, &Vec<_>) = match available_target_keys() {
|
||||
Some(avail) => {
|
||||
(kept, pruned_modes, dropped) =
|
||||
prune_saved_config_for_targets(saved_paths, saved_modes, &avail);
|
||||
if dropped > 0 {
|
||||
tracing::warn!(
|
||||
dropped,
|
||||
kept = kept.len(),
|
||||
"display isolate (CCD): snapshot references target(s) that are no longer \
|
||||
attached (unplugged mid-session?) — pruned them so the survivors can restore \
|
||||
(a verbatim replay fails whole with rc=0x57)"
|
||||
);
|
||||
}
|
||||
(&kept, &pruned_modes)
|
||||
}
|
||||
// The availability query itself failed — replay verbatim, exactly the old behavior.
|
||||
None => (saved_paths, saved_modes),
|
||||
};
|
||||
let mut apply_rc = 0i32; // 0 also when the replay was skipped (nothing left to apply)
|
||||
if paths.is_empty() {
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
|
||||
sdc_access_denied_hint(rc)
|
||||
"display isolate (CCD): nothing from the topology snapshot is still attached — \
|
||||
skipping the replay (the dark-desk backstop decides what lights up)"
|
||||
);
|
||||
} else {
|
||||
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
|
||||
// slices, so pointer and length cannot disagree, and both outlive this synchronous
|
||||
// call. `retry_set_display_config` binds it to the input desktop, which is the one
|
||||
// precondition a caller of this global-state write could otherwise get wrong.
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
)
|
||||
});
|
||||
apply_rc = rc;
|
||||
if rc == 0 {
|
||||
tracing::info!("display isolate (CCD): restored original topology");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
|
||||
sdc_access_denied_hint(rc)
|
||||
);
|
||||
}
|
||||
}
|
||||
// GUARANTEE the desk is never left all-dark. The saved config can be unappliable (field
|
||||
// rc=0x64a ERROR_BAD_CONFIGURATION: it pinned a virtual target incarnation that was since
|
||||
@@ -2020,7 +2173,7 @@ fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||
"display isolate (CCD): no external physical display active after the restore (rc={apply_rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||
);
|
||||
force_extend_topology();
|
||||
// Measure what the force achieved: a sink still dark AFTER the EXTEND preset can never
|
||||
@@ -2128,3 +2281,124 @@ mod live_tests {
|
||||
tracing::info!("live CCD query: {n} active display path(s)");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prune_saved_config_tests {
|
||||
//! The snapshot-prune remap arithmetic (`prune_saved_config_for_targets`) — pure over its
|
||||
//! inputs, so the 0x57-poisoned-restore fix is testable without a live CCD: a stale target's
|
||||
//! path must vanish, its modes must not orphan (an orphaned entry fails the whole
|
||||
//! SetDisplayConfig exactly like the stale path did), and clone-shared modes must land once.
|
||||
use super::*;
|
||||
|
||||
fn path(
|
||||
luid_low: u32,
|
||||
target_id: u32,
|
||||
src_mode: u32,
|
||||
tgt_mode: u32,
|
||||
) -> DISPLAYCONFIG_PATH_INFO {
|
||||
let mut p = DISPLAYCONFIG_PATH_INFO::default();
|
||||
p.targetInfo.adapterId.LowPart = luid_low;
|
||||
p.targetInfo.id = target_id;
|
||||
p.sourceInfo.adapterId.LowPart = luid_low;
|
||||
p.sourceInfo.Anonymous.modeInfoIdx = src_mode;
|
||||
p.targetInfo.Anonymous.modeInfoIdx = tgt_mode;
|
||||
p
|
||||
}
|
||||
|
||||
fn mode(marker: u32) -> DISPLAYCONFIG_MODE_INFO {
|
||||
DISPLAYCONFIG_MODE_INFO {
|
||||
id: marker,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn indices(p: &DISPLAYCONFIG_PATH_INFO) -> (u32, u32) {
|
||||
// SAFETY: POD union reads — `modeInfoIdx` overlays a same-sized bitfield struct, both
|
||||
// valid for every bit pattern (the same contract the production reads rely on).
|
||||
unsafe {
|
||||
(
|
||||
p.sourceInfo.Anonymous.modeInfoIdx,
|
||||
p.targetInfo.Anonymous.modeInfoIdx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_attached_survives_with_dense_indices() {
|
||||
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
|
||||
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
|
||||
let avail = vec![(1, 0, 100), (1, 0, 200)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert_eq!(new_modes.len(), 4);
|
||||
assert_eq!(indices(&kept[0]), (0, 1));
|
||||
assert_eq!(indices(&kept[1]), (2, 3));
|
||||
assert_eq!(new_modes[3].id, 13, "mode entries follow their paths");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gone_target_drops_its_path_and_modes() {
|
||||
// Target 200 was unplugged mid-session (the field rc=0x57 case): its path AND its two
|
||||
// mode entries must vanish, and the survivor's indices must be remapped dense.
|
||||
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
|
||||
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
|
||||
let avail = vec![(1, 0, 100)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 1);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].targetInfo.id, 100);
|
||||
assert_eq!(
|
||||
new_modes.len(),
|
||||
2,
|
||||
"the dropped path's modes must not orphan"
|
||||
);
|
||||
assert_eq!((new_modes[0].id, new_modes[1].id), (10, 11));
|
||||
assert_eq!(indices(&kept[0]), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clone_shared_source_mode_lands_exactly_once() {
|
||||
// Clone configs share one source mode entry between paths — the rebuilt table must
|
||||
// contain it once, referenced by both survivors.
|
||||
let paths = vec![path(1, 100, 0, 1), path(1, 200, 0, 2)];
|
||||
let modes = vec![mode(10), mode(11), mode(12)];
|
||||
let avail = vec![(1, 0, 100), (1, 0, 200)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(new_modes.len(), 3);
|
||||
let (a_src, _) = indices(&kept[0]);
|
||||
let (b_src, _) = indices(&kept[1]);
|
||||
assert_eq!(a_src, b_src, "shared source mode keeps one table entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpinned_and_corrupt_indices_stay_unpinned() {
|
||||
// The INVALID sentinel must pass through, and an out-of-range index (a corrupt snapshot)
|
||||
// must degrade to unpinned rather than shipping a table the whole apply fails on.
|
||||
let paths = vec![path(1, 100, DISPLAYCONFIG_PATH_MODE_IDX_INVALID, 99)];
|
||||
let modes = vec![mode(10)];
|
||||
let avail = vec![(1, 0, 100)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 0);
|
||||
assert!(new_modes.is_empty());
|
||||
assert_eq!(
|
||||
indices(&kept[0]),
|
||||
(
|
||||
DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
|
||||
DISPLAYCONFIG_PATH_MODE_IDX_INVALID
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_adapters_do_not_alias_the_same_target_id() {
|
||||
// Target ids are only unique per adapter LUID — a survivor on adapter 2 must not keep a
|
||||
// stale path alive on adapter 1 just because the ids match.
|
||||
let paths = vec![path(1, 100, 0, 1)];
|
||||
let modes = vec![mode(10), mode(11)];
|
||||
let avail = vec![(2, 0, 100)];
|
||||
let (kept, _, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!((kept.len(), dropped), (0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[package]
|
||||
name = "pf-zerocopy"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
# Inherit the workspace MSRV: clippy keys MSRV-gated lints off it (without this, e.g.
|
||||
# `manual_is_multiple_of` fires on code the host crate compiles clean).
|
||||
rust-version.workspace = true
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) const GL_LINK_STATUS: u32 = 0x8B82;
|
||||
|
||||
// libglvnd's libGL dispatches these to the NVIDIA driver based on the current EGL/GL context.
|
||||
#[link(name = "GL")]
|
||||
extern "C" {
|
||||
unsafe extern "C" {
|
||||
pub(crate) fn glGenTextures(n: c_int, textures: *mut u32);
|
||||
pub(crate) fn glBindTexture(target: u32, texture: u32);
|
||||
pub(crate) fn glTexParameteri(target: u32, pname: u32, param: c_int);
|
||||
@@ -97,7 +97,7 @@ extern "C" {
|
||||
}
|
||||
|
||||
#[link(name = "gbm")]
|
||||
extern "C" {
|
||||
unsafe extern "C" {
|
||||
pub(crate) fn gbm_create_device(fd: c_int) -> *mut c_void;
|
||||
pub(crate) fn gbm_device_destroy(device: *mut c_void);
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ fn new_handle(session: Session) -> *mut PunktfunkSession {
|
||||
}
|
||||
|
||||
/// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn punktfunk_abi_version() -> u32 {
|
||||
crate::ABI_VERSION
|
||||
}
|
||||
@@ -271,7 +271,7 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
|
||||
/// # Safety
|
||||
/// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL,
|
||||
/// must be a NUL-terminated string.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_wake_on_lan(
|
||||
macs: *const u8,
|
||||
mac_count: usize,
|
||||
@@ -321,7 +321,7 @@ pub unsafe extern "C" fn punktfunk_wake_on_lan(
|
||||
///
|
||||
/// # Safety
|
||||
/// `cfg`, `local`, `peer` must be valid pointers; the strings must be NUL-terminated.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_session_new(
|
||||
cfg: *const PunktfunkConfig,
|
||||
local: *const c_char,
|
||||
@@ -366,7 +366,7 @@ pub unsafe extern "C" fn punktfunk_session_new(
|
||||
///
|
||||
/// # Safety
|
||||
/// All four pointers must be valid; the two out-params receive owned handles.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_test_loopback_pair(
|
||||
host_cfg: *const PunktfunkConfig,
|
||||
client_cfg: *const PunktfunkConfig,
|
||||
@@ -413,7 +413,7 @@ pub unsafe extern "C" fn punktfunk_test_loopback_pair(
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` must be a handle from `punktfunk_session_new`/`punktfunk_test_loopback_pair`, freed once.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_session_free(s: *mut PunktfunkSession) {
|
||||
guard_void(|| {
|
||||
if !s.is_null() {
|
||||
@@ -428,7 +428,7 @@ pub unsafe extern "C" fn punktfunk_session_free(s: *mut PunktfunkSession) {
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` is a valid host handle; `data` points to `len` readable bytes (or `len == 0`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_host_submit_frame(
|
||||
s: *mut PunktfunkSession,
|
||||
data: *const u8,
|
||||
@@ -466,7 +466,7 @@ pub unsafe extern "C" fn punktfunk_host_submit_frame(
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` is a valid client handle; `out` points to a writable `PunktfunkFrame`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_client_poll_frame(
|
||||
s: *mut PunktfunkSession,
|
||||
out: *mut PunktfunkFrame,
|
||||
@@ -510,7 +510,7 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` is a valid client handle; `ev` points to a readable `InputEvent`-sized allocation.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_send_input(
|
||||
s: *mut PunktfunkSession,
|
||||
ev: *const InputEvent,
|
||||
@@ -566,7 +566,7 @@ unsafe fn read_input_event<'a>(ev: *const InputEvent) -> Result<&'a InputEvent,
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` is a valid host handle; `user` is passed back verbatim to `cb`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_set_input_callback(
|
||||
s: *mut PunktfunkSession,
|
||||
// Written as an explicit `Option<fn>` (not the `PunktfunkInputCb` alias) so cbindgen
|
||||
@@ -592,7 +592,7 @@ pub unsafe extern "C" fn punktfunk_set_input_callback(
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` is a valid host handle.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
let mut count = 0i32;
|
||||
@@ -633,7 +633,7 @@ pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) ->
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` is a valid handle; `out` points to a writable `PunktfunkStats`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_get_stats(
|
||||
s: *mut PunktfunkSession,
|
||||
out: *mut PunktfunkStats,
|
||||
@@ -1427,7 +1427,7 @@ const _: () = {
|
||||
/// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes;
|
||||
/// `client_cert_pem`/`client_key_pem` are each NULL or NUL-terminated UTF-8.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connect(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
@@ -1468,7 +1468,7 @@ pub unsafe extern "C" fn punktfunk_connect(
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
@@ -1512,7 +1512,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex(
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex2(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
@@ -1557,7 +1557,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex2(
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex3(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
@@ -1605,7 +1605,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex3(
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex4(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
@@ -1658,7 +1658,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex4(
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
host: *const std::os::raw::c_char,
|
||||
@@ -1711,7 +1711,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex6(
|
||||
host: *const std::os::raw::c_char,
|
||||
@@ -1767,7 +1767,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex6(
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex7(
|
||||
host: *const std::os::raw::c_char,
|
||||
@@ -1828,7 +1828,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`]; `status_out`, when non-null, must point to a writable `i32`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex8(
|
||||
host: *const std::os::raw::c_char,
|
||||
@@ -1889,7 +1889,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8(
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect_ex8`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex9(
|
||||
host: *const std::os::raw::c_char,
|
||||
@@ -2122,7 +2122,7 @@ unsafe fn connect_ex_impl(
|
||||
/// # Safety
|
||||
/// `cert_pem_out` is writable for `cert_cap` bytes; `key_pem_out` for `key_cap`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_generate_identity(
|
||||
cert_pem_out: *mut std::os::raw::c_char,
|
||||
cert_cap: usize,
|
||||
@@ -2165,7 +2165,7 @@ pub unsafe extern "C" fn punktfunk_generate_identity(
|
||||
/// # Safety
|
||||
/// `host` must be a NUL-terminated UTF-8 string.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_probe(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
@@ -2200,7 +2200,7 @@ pub unsafe extern "C" fn punktfunk_probe(
|
||||
/// `host`/`client_cert_pem`/`client_key_pem`/`pin`/`name` are NUL-terminated UTF-8;
|
||||
/// `host_sha256_out` is writable for 32 bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_pair(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
@@ -2264,7 +2264,7 @@ pub unsafe extern "C" fn punktfunk_pair(
|
||||
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls video —
|
||||
/// it may run concurrently with one audio-pulling and one rumble-pulling thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_au(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkFrame,
|
||||
@@ -2329,7 +2329,7 @@ pub struct PunktfunkAudioPacket {
|
||||
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio —
|
||||
/// it may run concurrently with the video/rumble pullers.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_audio(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkAudioPacket,
|
||||
@@ -2380,7 +2380,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_audio_channels(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u8,
|
||||
@@ -2420,7 +2420,7 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_end_reason(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u8,
|
||||
@@ -2478,7 +2478,7 @@ pub struct PunktfunkAudioPcm {
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkAudioPcm,
|
||||
@@ -2544,7 +2544,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
|
||||
/// `buf` is writable for `buf_len` bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
|
||||
c: *mut PunktfunkConnection,
|
||||
out_pad: *mut u8,
|
||||
@@ -2618,7 +2618,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: u8,
|
||||
@@ -2649,7 +2649,7 @@ pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
|
||||
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At
|
||||
/// most one thread pulls rumble — it may run concurrently with the video/audio pullers.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_rumble(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: *mut u16,
|
||||
@@ -2706,7 +2706,7 @@ pub const PUNKTFUNK_RUMBLE_NO_TTL: u32 = 0xFFFF_FFFF;
|
||||
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
|
||||
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_rumble2(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: *mut u16,
|
||||
@@ -2786,7 +2786,7 @@ pub const PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER: u32 = 1;
|
||||
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
|
||||
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: *mut u16,
|
||||
@@ -2867,7 +2867,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
|
||||
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
|
||||
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd2(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: *mut u16,
|
||||
@@ -2933,7 +2933,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd2(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: u16,
|
||||
@@ -2970,7 +2970,7 @@ pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHidOutput`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_hidout(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkHidOutput,
|
||||
@@ -3018,7 +3018,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHdrMeta`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_hdr_meta(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkHdrMeta,
|
||||
@@ -3086,7 +3086,7 @@ pub struct PunktfunkCursorState {
|
||||
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor
|
||||
/// shapes; it may run concurrently with every other plane's puller.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkCursorShape,
|
||||
@@ -3138,7 +3138,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape(
|
||||
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor
|
||||
/// state; it may run concurrently with every other plane's puller.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_cursor_state(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkCursorState,
|
||||
@@ -3187,7 +3187,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_cursor_state(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_set_cursor_render(
|
||||
c: *mut PunktfunkConnection,
|
||||
client_draws: bool,
|
||||
@@ -3218,7 +3218,7 @@ pub unsafe extern "C" fn punktfunk_connection_set_cursor_render(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHostTiming`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_host_timing(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkHostTiming,
|
||||
@@ -3265,7 +3265,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_host_timing(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; each out pointer is NULL or writable for its scalar.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_color_info(
|
||||
c: *mut PunktfunkConnection,
|
||||
primaries: *mut u8,
|
||||
@@ -3315,7 +3315,7 @@ pub unsafe extern "C" fn punktfunk_connection_color_info(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_chroma_format(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u8,
|
||||
@@ -3345,7 +3345,7 @@ pub unsafe extern "C" fn punktfunk_connection_chroma_format(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_codec(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u8,
|
||||
@@ -3375,7 +3375,7 @@ pub unsafe extern "C" fn punktfunk_connection_codec(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u32`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_shard_payload(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u32,
|
||||
@@ -3403,7 +3403,7 @@ pub unsafe extern "C" fn punktfunk_connection_shard_payload(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `ev` points to a readable `InputEvent`-sized allocation.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_input(
|
||||
c: *mut PunktfunkConnection,
|
||||
ev: *const InputEvent,
|
||||
@@ -3437,7 +3437,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_input(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `opus_data` is valid for `len` bytes (or `len == 0`).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_mic(
|
||||
c: *mut PunktfunkConnection,
|
||||
opus_data: *const u8,
|
||||
@@ -3478,7 +3478,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_mic(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `rich` points to a valid [`PunktfunkRichInput`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
|
||||
c: *mut PunktfunkConnection,
|
||||
rich: *const PunktfunkRichInput,
|
||||
@@ -3516,7 +3516,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
|
||||
/// `c` is a valid connection handle; `rich` is null or points to at least its declared
|
||||
/// `struct_size` bytes.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
|
||||
c: *mut PunktfunkConnection,
|
||||
rich: *const PunktfunkRichInputEx,
|
||||
@@ -3566,7 +3566,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
|
||||
/// `c` is a valid connection handle; `samples` is null or points to `count` valid
|
||||
/// [`PunktfunkPenSample`]s.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_pen(
|
||||
c: *mut PunktfunkConnection,
|
||||
samples: *const PunktfunkPenSample,
|
||||
@@ -3609,7 +3609,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_pen(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_mode(
|
||||
c: *const PunktfunkConnection,
|
||||
width: *mut u32,
|
||||
@@ -3650,7 +3650,7 @@ pub unsafe extern "C" fn punktfunk_connection_mode(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `gamepad` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_gamepad(
|
||||
c: *const PunktfunkConnection,
|
||||
gamepad: *mut u32,
|
||||
@@ -3835,7 +3835,7 @@ fn build_clip_event(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `caps` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_host_caps(
|
||||
c: *const PunktfunkConnection,
|
||||
caps: *mut u8,
|
||||
@@ -3866,7 +3866,7 @@ pub unsafe extern "C" fn punktfunk_connection_host_caps(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_control(
|
||||
c: *const PunktfunkConnection,
|
||||
enabled: bool,
|
||||
@@ -3895,7 +3895,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_control(
|
||||
/// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only
|
||||
/// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_offer(
|
||||
c: *const PunktfunkConnection,
|
||||
seq: u32,
|
||||
@@ -3951,7 +3951,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_offer(
|
||||
/// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out`
|
||||
/// is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch(
|
||||
c: *const PunktfunkConnection,
|
||||
seq: u32,
|
||||
@@ -4000,7 +4000,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch(
|
||||
/// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when
|
||||
/// `len == 0`).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_serve(
|
||||
c: *const PunktfunkConnection,
|
||||
req_id: u32,
|
||||
@@ -4039,7 +4039,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_serve(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel(
|
||||
c: *const PunktfunkConnection,
|
||||
id: u32,
|
||||
@@ -4067,7 +4067,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkClipEvent,
|
||||
@@ -4118,7 +4118,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `compositor` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_compositor(
|
||||
c: *const PunktfunkConnection,
|
||||
compositor: *mut u32,
|
||||
@@ -4149,7 +4149,7 @@ pub unsafe extern "C" fn punktfunk_connection_compositor(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `bitrate_kbps` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_bitrate(
|
||||
c: *const PunktfunkConnection,
|
||||
bitrate_kbps: *mut u32,
|
||||
@@ -4184,7 +4184,7 @@ pub unsafe extern "C" fn punktfunk_connection_bitrate(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
|
||||
c: *const PunktfunkConnection,
|
||||
offset_ns: *mut i64,
|
||||
@@ -4218,7 +4218,7 @@ pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
|
||||
c: *const PunktfunkConnection,
|
||||
offset_ns: *mut i64,
|
||||
@@ -4252,7 +4252,7 @@ pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_request_mode(
|
||||
c: *const PunktfunkConnection,
|
||||
width: u32,
|
||||
@@ -4288,7 +4288,7 @@ pub unsafe extern "C" fn punktfunk_connection_request_mode(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
|
||||
c: *const PunktfunkConnection,
|
||||
) -> PunktfunkStatus {
|
||||
@@ -4320,7 +4320,7 @@ pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_request_rfi(
|
||||
c: *const PunktfunkConnection,
|
||||
first_frame: u32,
|
||||
@@ -4355,7 +4355,7 @@ pub unsafe extern "C" fn punktfunk_connection_request_rfi(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `gap_out` is writable or NULL.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
|
||||
c: *const PunktfunkConnection,
|
||||
frame_index: u32,
|
||||
@@ -4373,7 +4373,41 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
|
||||
if !gap_out.is_null() {
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
|
||||
// written once by value.
|
||||
unsafe { *gap_out = gap };
|
||||
unsafe { *gap_out = gap > 0 };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// [`punktfunk_connection_note_frame_index`] with the gap WIDTH instead of a yes/no: writes to
|
||||
/// `gap_width_out` how many frames this arrival revealed as missing (0 = contiguous/straggler).
|
||||
/// A client with a post-loss display freeze passes the width to
|
||||
/// [`punktfunk_reanchor_gate_arm_expecting_drops`] so the reassembler's later `frames_dropped`
|
||||
/// climb for the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm
|
||||
/// race — see the gate function's doc).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
|
||||
#[cfg(feature = "quic")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
|
||||
c: *const PunktfunkConnection,
|
||||
frame_index: u32,
|
||||
gap_width_out: *mut u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let gap = c.inner.note_frame_index(frame_index);
|
||||
if !gap_width_out.is_null() {
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
|
||||
// written once by value.
|
||||
unsafe { *gap_width_out = gap };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
@@ -4389,7 +4423,7 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
|
||||
c: *const PunktfunkConnection,
|
||||
out: *mut u64,
|
||||
@@ -4434,7 +4468,7 @@ pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
|
||||
c: *const PunktfunkConnection,
|
||||
us: u32,
|
||||
@@ -4461,7 +4495,7 @@ pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
|
||||
/// `c` is an opaque handle from a `*_new`/`*_pair` the caller has not yet freed, or null (an
|
||||
/// error, not UB).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_report_phase(
|
||||
c: *const PunktfunkConnection,
|
||||
next_latch_host_ns: u64,
|
||||
@@ -4497,7 +4531,7 @@ pub unsafe extern "C" fn punktfunk_connection_report_phase(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_wants_decode_latency(
|
||||
c: *const PunktfunkConnection,
|
||||
out: *mut bool,
|
||||
@@ -4569,7 +4603,7 @@ pub struct PunktfunkProbeResult {
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_speed_test(
|
||||
c: *const PunktfunkConnection,
|
||||
target_kbps: u32,
|
||||
@@ -4597,7 +4631,7 @@ pub unsafe extern "C" fn punktfunk_connection_speed_test(
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkProbeResult` (NULL is an
|
||||
/// error).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_probe_result(
|
||||
c: *const PunktfunkConnection,
|
||||
out: *mut PunktfunkProbeResult,
|
||||
@@ -4644,7 +4678,7 @@ pub unsafe extern "C" fn punktfunk_connection_probe_result(
|
||||
/// # Safety
|
||||
/// `c` was returned by [`punktfunk_connect`] and remains valid (closed via `punktfunk_connection_close`).
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkConnection) {
|
||||
guard_void(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
|
||||
@@ -4661,7 +4695,7 @@ pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkC
|
||||
/// # Safety
|
||||
/// `c` was returned by [`punktfunk_connect`] and is not used after this call.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_close(c: *mut PunktfunkConnection) {
|
||||
guard_void(|| {
|
||||
if !c.is_null() {
|
||||
@@ -4687,7 +4721,7 @@ pub unsafe extern "C" fn punktfunk_connection_close(c: *mut PunktfunkConnection)
|
||||
/// Create a re-anchor gate seeded with the session's current `frames_dropped` (so the first
|
||||
/// [`punktfunk_reanchor_gate_poll`] doesn't read the baseline as a loss). Free with
|
||||
/// [`punktfunk_reanchor_gate_free`]. Never returns NULL.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn punktfunk_reanchor_gate_new(frames_dropped: u64) -> *mut ReanchorGate {
|
||||
Box::into_raw(Box::new(ReanchorGate::new(frames_dropped)))
|
||||
}
|
||||
@@ -4696,7 +4730,7 @@ pub extern "C" fn punktfunk_reanchor_gate_new(frames_dropped: u64) -> *mut Reanc
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` was returned by [`punktfunk_reanchor_gate_new`] and is not used after this call.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_free(g: *mut ReanchorGate) {
|
||||
guard_void(|| {
|
||||
if !g.is_null() {
|
||||
@@ -4712,7 +4746,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_free(g: *mut ReanchorGate) {
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
|
||||
guard_void(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
|
||||
@@ -4724,6 +4758,31 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
|
||||
});
|
||||
}
|
||||
|
||||
/// [`punktfunk_reanchor_gate_arm`] for a loss detected as a **frame-index gap**, where the caller
|
||||
/// knows how many frames the gap skipped ([`punktfunk_connection_note_frame_index_ex`]). On top of
|
||||
/// arming, the gate pre-credits the reassembler's `frames_dropped` climb those same lost frames
|
||||
/// will produce up to ~120 ms later, so [`punktfunk_reanchor_gate_poll`] does not treat that
|
||||
/// delayed bookkeeping as a SECOND loss — without the credit, a fast LTR-RFI anchor lifts the
|
||||
/// freeze between the two signals and the stale climb re-freezes a healed stream (the double-arm
|
||||
/// race). Use the plain arm for non-gap loss signals (decoder wedge/demotion). NULL is a no-op.
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
|
||||
g: *mut ReanchorGate,
|
||||
expected_drops: u64,
|
||||
) {
|
||||
guard_void(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
|
||||
// not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` here
|
||||
// handles.
|
||||
if let Some(g) = unsafe { g.as_mut() } {
|
||||
g.arm_expecting_drops(std::time::Instant::now(), expected_drops);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
|
||||
/// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
|
||||
/// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
|
||||
@@ -4732,7 +4791,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle; `out_present` is writable or NULL.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_decoded(
|
||||
g: *mut ReanchorGate,
|
||||
flags: u32,
|
||||
@@ -4764,7 +4823,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_on_decoded(
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_no_output(
|
||||
g: *mut ReanchorGate,
|
||||
out_request_kf: *mut bool,
|
||||
@@ -4793,7 +4852,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_on_no_output(
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_poll(
|
||||
g: *mut ReanchorGate,
|
||||
frames_dropped: u64,
|
||||
@@ -4822,7 +4881,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_poll(
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle; `out_holding` is writable or NULL.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
g: *const ReanchorGate,
|
||||
out_holding: *mut bool,
|
||||
|
||||
@@ -881,14 +881,20 @@ impl NativeClient {
|
||||
///
|
||||
/// Call it for EVERY received frame; it is cheap and idempotent, and the
|
||||
/// [`frames_dropped`](Self::frames_dropped)-driven [`request_keyframe`](Self::request_keyframe)
|
||||
/// loop stays the backstop for when the recovery frame itself is lost. Returns `true` when a
|
||||
/// forward gap was detected on this call (whether or not the RFI was throttled), so a client with
|
||||
/// a post-loss display freeze can (re-)arm it on the same signal.
|
||||
/// loop stays the backstop for when the recovery frame itself is lost. Returns the gap WIDTH —
|
||||
/// how many frames this arrival revealed as missing, `0` when none (contiguous or straggler),
|
||||
/// whether or not the RFI was throttled — so a client with a post-loss display freeze can
|
||||
/// (re-)arm it on the same signal AND pre-credit the reassembler's later `frames_dropped` climb
|
||||
/// for the same loss ([`ReanchorGate::arm_expecting_drops`] — without the credit, a fast
|
||||
/// LTR-RFI anchor lifts the freeze before the climb books the loss, and the stale climb then
|
||||
/// re-freezes the healed stream).
|
||||
///
|
||||
/// This centralizes the loss-range detection so every embedder gets identical behavior. (The
|
||||
/// in-process Vulkan session pump keeps its own copy because it gates a display freeze on the same
|
||||
/// signal and shares one throttle across RFI + keyframe requests.)
|
||||
pub fn note_frame_index(&self, frame_index: u32) -> bool {
|
||||
///
|
||||
/// [`ReanchorGate::arm_expecting_drops`]: crate::reanchor::ReanchorGate::arm_expecting_drops
|
||||
pub fn note_frame_index(&self, frame_index: u32) -> u32 {
|
||||
// Decide (and update state) under the lock; fire the request after releasing it.
|
||||
let (gap, ask) = self
|
||||
.rfi
|
||||
|
||||
@@ -176,9 +176,9 @@ impl DataPump {
|
||||
let clock_offset_ns = pump_clock_offset.load(Ordering::Relaxed);
|
||||
// An applied re-sync invalidates the staleness run measured under the OLD offset:
|
||||
// reset the counters and re-arm the clock-based detector if a step had disarmed it.
|
||||
let gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||
if gen != seen_clock_gen {
|
||||
seen_clock_gen = gen;
|
||||
let clock_gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||
if clock_gen != seen_clock_gen {
|
||||
seen_clock_gen = clock_gen;
|
||||
stale_since = None;
|
||||
noop_clock_flushes = 0;
|
||||
// Every OWD reading shifted with the offset — the standing-latency floor and
|
||||
|
||||
@@ -32,14 +32,16 @@ pub(crate) enum RecoveryAsk {
|
||||
impl RfiRecovery {
|
||||
/// Pure decision behind [`NativeClient::note_frame_index`]: fold one received `frame_index` (in
|
||||
/// receive order) observed at `now`, advancing the expectation and returning `(gap, ask)`.
|
||||
/// `gap` is whether this frame revealed a forward gap (the embedder arms its post-loss display
|
||||
/// freeze on it); `ask` is the (throttled) recovery request to fire — an RFI naming the exact
|
||||
/// lost span, or a keyframe when the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is
|
||||
/// hopeless there: no encoder holds references that old, and a huge jump is more likely a
|
||||
/// resync — e.g. the first real AU after an old host's speed test — than a real loss). Split
|
||||
/// out from the connection so the wrapping arithmetic + [`RFI_THROTTLE`] are unit-testable
|
||||
/// without a live session (see the tests below).
|
||||
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (bool, RecoveryAsk) {
|
||||
/// `gap` is how many frames this arrival revealed as missing — 0 for contiguous/straggler; the
|
||||
/// embedder arms its post-loss display freeze on a non-zero gap, and the WIDTH lets it
|
||||
/// pre-credit the reassembler's later `frames_dropped` climb for the same loss
|
||||
/// ([`crate::reanchor::ReanchorGate::arm_expecting_drops`] — the double-arm race). `ask` is the
|
||||
/// (throttled) recovery request to fire — an RFI naming the exact lost span, or a keyframe when
|
||||
/// the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is hopeless there: no encoder holds
|
||||
/// references that old, and a huge jump is more likely a resync — e.g. the first real AU after
|
||||
/// an old host's speed test — than a real loss). Split out from the connection so the wrapping
|
||||
/// arithmetic + [`RFI_THROTTLE`] are unit-testable without a live session (see the tests below).
|
||||
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (u32, RecoveryAsk) {
|
||||
match self.next_expected {
|
||||
Some(exp) => {
|
||||
// Wrapping split at the half-space: a small positive delta is a forward gap
|
||||
@@ -47,10 +49,11 @@ impl RfiRecovery {
|
||||
let ahead = frame_index.wrapping_sub(exp);
|
||||
if ahead == 0 {
|
||||
self.next_expected = Some(frame_index.wrapping_add(1)); // contiguous
|
||||
(false, RecoveryAsk::None)
|
||||
(0, RecoveryAsk::None)
|
||||
} else if ahead < u32::MAX / 2 {
|
||||
// Forward gap: [exp, frame_index-1] lost. Advance past this frame so the same
|
||||
// gap isn't re-detected, then fire a throttled recovery ask for the lost range.
|
||||
// Forward gap: [exp, frame_index-1] lost (`ahead` frames). Advance past this
|
||||
// frame so the same gap isn't re-detected, then fire a throttled recovery ask
|
||||
// for the lost range.
|
||||
self.next_expected = Some(frame_index.wrapping_add(1));
|
||||
let send = self
|
||||
.last_req
|
||||
@@ -65,15 +68,15 @@ impl RfiRecovery {
|
||||
} else {
|
||||
RecoveryAsk::Rfi(exp, frame_index.wrapping_sub(1))
|
||||
};
|
||||
(true, ask)
|
||||
(ahead, ask)
|
||||
} else {
|
||||
// Straggler behind the delivery point — leave the expectation.
|
||||
(false, RecoveryAsk::None)
|
||||
(0, RecoveryAsk::None)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.next_expected = Some(frame_index.wrapping_add(1));
|
||||
(false, RecoveryAsk::None)
|
||||
(0, RecoveryAsk::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +99,7 @@ mod rfi_recovery_tests {
|
||||
fn first_frame_arms_without_a_gap() {
|
||||
let mut r = RfiRecovery::default();
|
||||
// The opening frame only seeds the expectation — there is no prior frame to be missing.
|
||||
assert_eq!(r.observe(100, base()), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(100, base()), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(101));
|
||||
}
|
||||
|
||||
@@ -105,9 +108,9 @@ mod rfi_recovery_tests {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(100, t);
|
||||
assert_eq!(r.observe(101, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(102, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(101, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(102, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(104));
|
||||
}
|
||||
|
||||
@@ -117,7 +120,7 @@ mod rfi_recovery_tests {
|
||||
let t = base();
|
||||
r.observe(100, t); // expecting 101 next
|
||||
// 101..=104 were lost; 105 arrived. The RFI must name exactly the missing span.
|
||||
assert_eq!(r.observe(105, t), (true, RecoveryAsk::Rfi(101, 104)));
|
||||
assert_eq!(r.observe(105, t), (4, RecoveryAsk::Rfi(101, 104)));
|
||||
// The expectation advances past the delivered frame so the same gap can't re-fire.
|
||||
assert_eq!(r.next_expected, Some(106));
|
||||
}
|
||||
@@ -128,7 +131,7 @@ mod rfi_recovery_tests {
|
||||
let t = base();
|
||||
r.observe(100, t);
|
||||
// Exactly one frame (101) lost → range is the single index [101, 101].
|
||||
assert_eq!(r.observe(102, t), (true, RecoveryAsk::Rfi(101, 101)));
|
||||
assert_eq!(r.observe(102, t), (1, RecoveryAsk::Rfi(101, 101)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -137,16 +140,16 @@ mod rfi_recovery_tests {
|
||||
let t0 = base();
|
||||
r.observe(100, t0);
|
||||
// First gap fires the request and stamps the throttle.
|
||||
assert_eq!(r.observe(105, t0), (true, RecoveryAsk::Rfi(101, 104)));
|
||||
assert_eq!(r.observe(105, t0), (4, RecoveryAsk::Rfi(101, 104)));
|
||||
// A second gap 50 ms later is still a gap, but the request is throttled away.
|
||||
assert_eq!(
|
||||
r.observe(110, t0 + Duration::from_millis(50)),
|
||||
(true, RecoveryAsk::None)
|
||||
(4, RecoveryAsk::None)
|
||||
);
|
||||
// Past the window, the request re-opens for the still-accurate lost span.
|
||||
assert_eq!(
|
||||
r.observe(120, t0 + RFI_THROTTLE + Duration::from_millis(1)),
|
||||
(true, RecoveryAsk::Rfi(111, 119))
|
||||
(9, RecoveryAsk::Rfi(111, 119))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,7 +161,7 @@ mod rfi_recovery_tests {
|
||||
r.observe(105, t); // expecting 106 next
|
||||
// A reordered late arrival (103, well behind 106) is neither a gap nor a request, and it
|
||||
// must not rewind the expectation — otherwise the next in-order frame would false-gap.
|
||||
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(106));
|
||||
}
|
||||
|
||||
@@ -167,9 +170,9 @@ mod rfi_recovery_tests {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
|
||||
assert_eq!(r.observe(u32::MAX, t), (false, RecoveryAsk::None)); // contiguous, wraps to 0
|
||||
assert_eq!(r.observe(u32::MAX, t), (0, RecoveryAsk::None)); // contiguous, wraps to 0
|
||||
assert_eq!(r.next_expected, Some(0));
|
||||
assert_eq!(r.observe(0, t), (false, RecoveryAsk::None)); // still contiguous across the wrap
|
||||
assert_eq!(r.observe(0, t), (0, RecoveryAsk::None)); // still contiguous across the wrap
|
||||
assert_eq!(r.next_expected, Some(1));
|
||||
}
|
||||
|
||||
@@ -179,7 +182,7 @@ mod rfi_recovery_tests {
|
||||
let t = base();
|
||||
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
|
||||
// u32::MAX was lost and 1 arrived → the lost span wraps: [u32::MAX, 0].
|
||||
assert_eq!(r.observe(1, t), (true, RecoveryAsk::Rfi(u32::MAX, 0)));
|
||||
assert_eq!(r.observe(1, t), (2, RecoveryAsk::Rfi(u32::MAX, 0)));
|
||||
assert_eq!(r.next_expected, Some(2));
|
||||
}
|
||||
|
||||
@@ -192,14 +195,14 @@ mod rfi_recovery_tests {
|
||||
// reference exists for an RFI, and the jump may be a phantom (an old host's
|
||||
// speed-test burst consuming video indexes) — ask for the IDR resync instead.
|
||||
let jump = 100 + crate::packet::RFI_MAX_RANGE + 2;
|
||||
assert_eq!(r.observe(jump, t), (true, RecoveryAsk::Keyframe));
|
||||
assert_eq!(r.observe(jump, t), (jump - 101, RecoveryAsk::Keyframe));
|
||||
// The expectation still advances past the delivered frame (no re-fire on the next one).
|
||||
assert_eq!(r.next_expected, Some(jump + 1));
|
||||
assert_eq!(r.observe(jump + 1, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(jump + 1, t), (0, RecoveryAsk::None));
|
||||
// A huge gap consumes the shared throttle too — an immediate follow-up gap stays quiet.
|
||||
assert_eq!(
|
||||
r.observe(jump + 10, t + Duration::from_millis(1)),
|
||||
(true, RecoveryAsk::None)
|
||||
(8, RecoveryAsk::None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ impl ErasureCoder for Gf16Coder {
|
||||
.iter()
|
||||
.zip(have)
|
||||
.enumerate()
|
||||
.filter(|(_, (_, &h))| h)
|
||||
.filter(|&(_, (_, &h))| h)
|
||||
.map(|(i, (s, _))| (i, &**s))
|
||||
.collect();
|
||||
let restored = reed_solomon_simd::decode(
|
||||
|
||||
@@ -417,13 +417,21 @@ mod tests {
|
||||
/// --nocapture --test-threads=1`.
|
||||
#[tokio::test]
|
||||
#[ignore = "measurement: sets process env and takes ~15 s of wall clock"]
|
||||
// Test-only exemption from the crate's `deny(unsafe_code)`: mutating the process
|
||||
// environment is `unsafe` in edition 2024, and this measurement's env knob is the
|
||||
// documented single-threaded kind.
|
||||
#[allow(unsafe_code)]
|
||||
async fn mtu_discovery_climbs_only_as_high_as_the_peer_advertises() {
|
||||
async fn climb(server_jumbo: bool, client_jumbo: bool) -> (u16, u128) {
|
||||
let set = |on: bool| {
|
||||
if on {
|
||||
std::env::set_var("PUNKTFUNK_JUMBO", "1");
|
||||
} else {
|
||||
std::env::remove_var("PUNKTFUNK_JUMBO");
|
||||
// SAFETY: this `#[ignore]`d measurement is documented above to run alone with
|
||||
// `--test-threads=1`, so no concurrent thread reads or writes the environment.
|
||||
unsafe {
|
||||
if on {
|
||||
std::env::set_var("PUNKTFUNK_JUMBO", "1");
|
||||
} else {
|
||||
std::env::remove_var("PUNKTFUNK_JUMBO");
|
||||
}
|
||||
}
|
||||
};
|
||||
set(server_jumbo);
|
||||
|
||||
@@ -64,6 +64,26 @@ pub const REANCHOR_MARKS_TO_LIFT: u32 = 2;
|
||||
/// floor fires, so a real stall still recovers.
|
||||
pub const RECOVERY_MARK_PATIENCE: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// How long a frame-index-gap arm's expected `frames_dropped` climb stays pre-credited in
|
||||
/// [`ReanchorGate::poll`]. One loss arms the gate through TWO signals: the frame-index gap the
|
||||
/// instant the AU after the loss is delivered ([`ReanchorGate::arm_expecting_drops`]), and the
|
||||
/// reassembler's `frames_dropped` climb once the lost frame ages out of its loss window (~120 ms
|
||||
/// later, and only when at least one of its packets arrived). Without the credit, a *fast* recovery
|
||||
/// — an LTR-RFI anchor typically lands within ~60 ms — lifts the freeze between the two signals,
|
||||
/// and the stale climb then re-freezes a stream that is already bit-exact healed; the host swallows
|
||||
/// the resulting keyframe request as an echo of the very RFI that healed it, so the picture stays
|
||||
/// frozen until the [`REANCHOR_FREEZE_MAX`] overdue re-ask extracts a full IDR (the field
|
||||
/// "H265 freezes on every loss, AV1 fine" signature — the slower IDR path usually lands after the
|
||||
/// climb and dodged the race).
|
||||
///
|
||||
/// Sized to cover the reassembler's 120 ms loss window plus delivery jitter with a wide margin,
|
||||
/// while staying short enough that a leftover credit (a straggler that filled the gap late, so no
|
||||
/// climb ever came; or a whole-frame vanish the reassembler never saw a packet of) cannot mask a
|
||||
/// genuinely unrelated future climb for long. A masked climb is also never silent in practice:
|
||||
/// every unrecoverable loss reveals itself as a frame-index gap on the next delivered frame, which
|
||||
/// re-arms (and re-credits) through [`ReanchorGate::arm_expecting_drops`] on its own.
|
||||
pub const DROP_CREDIT_WINDOW: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Frames skipped when `got` arrives while `expected` was the next index, or `None` if `got` is
|
||||
/// contiguous (`== expected`) or a straggler we have already passed. Frame indices are u32 counters
|
||||
/// that wrap, so the "ahead" test is a wrapping subtraction split at the half-space: a small positive
|
||||
@@ -185,6 +205,14 @@ pub struct ReanchorGate {
|
||||
/// a client stamps the decoder's decode-order watermark whenever this counter moves and
|
||||
/// discards the local recovery of anything older. Every other client ignores it.
|
||||
arms: u64,
|
||||
/// `frames_dropped` climb still expected from losses that already armed via a frame-index gap
|
||||
/// ([`Self::arm_expecting_drops`]). [`Self::poll`] consumes climbs against this before treating
|
||||
/// them as a NEW loss, so the reassembler's delayed bookkeeping of a gap-armed (and possibly
|
||||
/// already anchor-healed) loss cannot re-freeze the stream — see [`DROP_CREDIT_WINDOW`].
|
||||
drop_credit: u64,
|
||||
/// When the outstanding [`Self::drop_credit`] lapses ([`DROP_CREDIT_WINDOW`] after the latest
|
||||
/// credited arm). `None` when no credit is outstanding.
|
||||
drop_credit_expiry: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ReanchorGate {
|
||||
@@ -199,6 +227,8 @@ impl ReanchorGate {
|
||||
last_dropped: frames_dropped,
|
||||
local_sei_since_arm: false,
|
||||
arms: 0,
|
||||
drop_credit: 0,
|
||||
drop_credit_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +260,20 @@ impl ReanchorGate {
|
||||
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
}
|
||||
|
||||
/// [`arm`](Self::arm) for a loss detected as a **frame-index gap**, where the caller knows how
|
||||
/// many frames the gap skipped. On top of arming, it pre-credits the reassembler's
|
||||
/// `frames_dropped` climb those same lost frames will produce up to ~120 ms later (its
|
||||
/// loss-window age-out), so [`poll`](Self::poll) does not treat that delayed bookkeeping as a
|
||||
/// SECOND loss. Without the credit a fast LTR-RFI anchor lifts the freeze between the two
|
||||
/// signals and the stale climb re-freezes a healed stream — the double-arm race
|
||||
/// ([`DROP_CREDIT_WINDOW`] tells the whole story). Use plain [`arm`](Self::arm) for every
|
||||
/// non-gap loss signal (decoder wedge/demotion), which has no climb to credit.
|
||||
pub fn arm_expecting_drops(&mut self, now: Instant, expected_drops: u64) {
|
||||
self.arm(now);
|
||||
self.drop_credit = self.drop_credit.saturating_add(expected_drops);
|
||||
self.drop_credit_expiry = Some(now + DROP_CREDIT_WINDOW);
|
||||
}
|
||||
|
||||
/// Fold the client's OWN recovery-point observation for one decoded frame, BEFORE handing that
|
||||
/// frame to [`on_decoded`](Self::on_decoded). Returns `true` when it lifted the freeze.
|
||||
///
|
||||
@@ -333,16 +377,36 @@ impl ReanchorGate {
|
||||
}
|
||||
|
||||
/// Periodic fold of the session's `frames_dropped` counter plus the overdue backstop. Returns
|
||||
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed (a
|
||||
/// fresh unrecoverable loss — arm the freeze) or the freeze has held a full [`REANCHOR_FREEZE_MAX`]
|
||||
/// window with no re-anchor (re-ask and keep holding — NEVER resume to the concealed picture; a
|
||||
/// genuinely dead stream is the QUIC idle-timeout watchdog's job, not the gate's).
|
||||
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed by
|
||||
/// more than the outstanding gap-arm credit (a fresh unrecoverable loss — arm the freeze) or the
|
||||
/// freeze has held a full [`REANCHOR_FREEZE_MAX`] window with no re-anchor (re-ask and keep
|
||||
/// holding — NEVER resume to the concealed picture; a genuinely dead stream is the QUIC
|
||||
/// idle-timeout watchdog's job, not the gate's).
|
||||
///
|
||||
/// A climb covered by [`arm_expecting_drops`](Self::arm_expecting_drops)' credit is the
|
||||
/// reassembler's delayed bookkeeping of a loss this gate already armed for — it must neither
|
||||
/// re-arm (an LTR-RFI anchor may have healed the stream in the meantime; re-freezing it is the
|
||||
/// double-arm race) nor ask again (the gap already fired the precise RFI, and if THAT recovery
|
||||
/// was lost the overdue backstop still re-asks at the [`REANCHOR_FREEZE_MAX`] deadline the
|
||||
/// gap-arm set — which is also sooner than the deadline a re-arm here would push out to).
|
||||
pub fn poll(&mut self, frames_dropped: u64, now: Instant) -> bool {
|
||||
let mut want_keyframe = false;
|
||||
if frames_dropped > self.last_dropped {
|
||||
let climb = frames_dropped - self.last_dropped;
|
||||
self.last_dropped = frames_dropped;
|
||||
self.arm(now);
|
||||
want_keyframe = true;
|
||||
if self.drop_credit_expiry.is_some_and(|e| now >= e) {
|
||||
self.drop_credit = 0;
|
||||
self.drop_credit_expiry = None;
|
||||
}
|
||||
let credited = climb.min(self.drop_credit);
|
||||
self.drop_credit -= credited;
|
||||
if self.drop_credit == 0 {
|
||||
self.drop_credit_expiry = None;
|
||||
}
|
||||
if climb > credited {
|
||||
self.arm(now);
|
||||
want_keyframe = true;
|
||||
}
|
||||
}
|
||||
if self.awaiting && self.deadline.is_some_and(|d| now >= d) {
|
||||
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
@@ -542,6 +606,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_rfi_anchor_is_not_refrozen_by_the_same_losss_drop_climb() {
|
||||
// The double-arm race (field: "H265 freezes on every loss, AV1 fine"): a loss arms via
|
||||
// the frame-index gap at T+10ms, the LTR-RFI anchor heals at T+60ms, and the reassembler
|
||||
// books the SAME loss into frames_dropped at ~T+130ms. The credited arm must keep that
|
||||
// stale climb from re-freezing the healed stream (and from re-asking — the host would
|
||||
// swallow the ask as an RFI echo and the picture would freeze until a forced IDR).
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t + Duration::from_millis(10), 1); // gap of one lost frame + RFI
|
||||
assert_eq!(
|
||||
g.on_decoded(ANCHOR, false, t + Duration::from_millis(60)),
|
||||
GateVerdict::Present,
|
||||
"the anchor lifts"
|
||||
);
|
||||
assert!(
|
||||
!g.poll(1, t + Duration::from_millis(130)),
|
||||
"the credited climb must not ask again"
|
||||
);
|
||||
assert!(!g.is_holding(), "and must not re-freeze the healed stream");
|
||||
assert_eq!(
|
||||
g.on_decoded(0, false, t + Duration::from_millis(141)),
|
||||
GateVerdict::Present,
|
||||
"healthy P-frames keep presenting"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_climb_beyond_the_credit_is_a_fresh_loss_and_arms() {
|
||||
// The credit covers exactly the gap's frames; a bigger climb means MORE loss than the gap
|
||||
// accounted for (an interleaved partial-frame loss) — that part must still arm and ask.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t, 2);
|
||||
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50)); // healed the credited loss
|
||||
assert!(
|
||||
g.poll(3, t + Duration::from_millis(130)),
|
||||
"one uncredited drop → ask"
|
||||
);
|
||||
assert!(g.is_holding(), "and re-arm for the uncredited part");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drop_credit_expires_so_a_late_climb_still_arms() {
|
||||
// A straggler can fill the gap late (no climb ever comes) — the leftover credit must not
|
||||
// linger and mask a genuinely NEW loss later. Past DROP_CREDIT_WINDOW the credit is void.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t, 1);
|
||||
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50));
|
||||
let late = t + DROP_CREDIT_WINDOW + Duration::from_millis(1);
|
||||
assert!(
|
||||
g.poll(1, late),
|
||||
"an expired credit no longer absorbs climbs"
|
||||
);
|
||||
assert!(g.is_holding());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credited_climb_keeps_the_unhealed_freezes_original_deadline() {
|
||||
// When the recovery never arrives, consuming the climb must not silence the gate: the
|
||||
// overdue backstop still re-asks — at the deadline the GAP arm set, which is sooner than
|
||||
// the deadline a climb re-arm would have pushed out to.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t, 1); // RFI fired here; assume its anchor is lost in transit
|
||||
assert!(
|
||||
!g.poll(1, t + Duration::from_millis(130)),
|
||||
"credited climb: no early re-ask"
|
||||
);
|
||||
assert!(g.is_holding(), "still frozen — nothing healed it");
|
||||
assert!(
|
||||
g.poll(1, t + REANCHOR_FREEZE_MAX + Duration::from_millis(1)),
|
||||
"the overdue backstop still re-asks on the gap-arm's own deadline"
|
||||
);
|
||||
assert!(g.is_holding(), "and keeps holding, never resuming to gray");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_no_output_streak_trips_at_three() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
|
||||
@@ -72,7 +72,7 @@ const _: () = {
|
||||
};
|
||||
|
||||
#[cfg(target_vendor = "apple")]
|
||||
extern "C" {
|
||||
unsafe extern "C" {
|
||||
/// Darwin batched receive: up to `cnt` datagrams in one syscall; returns the count received and
|
||||
/// sets each `msg_datalen` to its byte length. Present in libSystem on all macOS/iOS.
|
||||
fn recvmsg_x(
|
||||
|
||||
@@ -16,7 +16,7 @@ mod android_mmsg {
|
||||
pub msg_hdr: libc::msghdr,
|
||||
pub msg_len: libc::c_uint,
|
||||
}
|
||||
extern "C" {
|
||||
unsafe extern "C" {
|
||||
pub fn sendmmsg(
|
||||
sockfd: libc::c_int,
|
||||
msgvec: *mut mmsghdr,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[package]
|
||||
name = "punktfunk-encode-worker"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk PyroWave encode worker: owns the priority-elevated Vulkan device so the host never carries a capability."
|
||||
|
||||
@@ -27,15 +27,28 @@
|
||||
//! device pulls per-period) whose prime depth the mic pump sets from measured uplink jitter
|
||||
//! ([`VirtualMic::set_target_depth`]), filling silence when the client isn't talking. WASAPI
|
||||
//! objects are `!Send`, so they live entirely on that thread (mirrors `WasapiLoopbackCapturer`).
|
||||
//!
|
||||
//! **Idle stop (host must be able to SLEEP).** A RUNNING WASAPI stream makes the audio stack
|
||||
//! hold a kernel power request ("An audio stream is currently in use", attributed to the
|
||||
//! target device in `powercfg /requests`) that vetoes system sleep — and this pump is
|
||||
//! host-lifetime, so rendering silence 24/7 kept every idle Punktfunk box awake forever
|
||||
//! (field report 2026-08-12: "doesn't go to sleep anymore; powercfg shows the Steam Streaming
|
||||
//! Microphone"). So after [`IDLE_STOP_AFTER`] of silence-only output the render loop stops the
|
||||
//! stream (`IAudioClient::Stop` — the client stays initialized, the mic ENDPOINT keeps
|
||||
//! existing, only the power request is released) and parks on the queue's condvar; the next
|
||||
//! pushed frame resumes it within one device period. During a session the box is kept awake by
|
||||
//! the session's own `DisplayWakeRequest` (pf-frame), never by this silence.
|
||||
//! `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a virtual audio
|
||||
//! driver misbehaves when its render side pauses.
|
||||
|
||||
use super::{audio_control, MicBackendStats, VirtualMic, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{sync_channel, SyncSender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use wasapi::{Direction, SampleType, StreamMode, WaveFormat};
|
||||
|
||||
const CHANNELS: u32 = 2;
|
||||
@@ -60,9 +73,30 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 120 / 1000) * BLOCK_ALIGN
|
||||
/// Producer-side overflow headroom (~32 ms) over the render loop's prime threshold when the
|
||||
/// adaptive target drives the ring.
|
||||
const CAP_HEADROOM_BYTES: usize = (SAMPLE_RATE as usize * 32 / 1000) * BLOCK_ALIGN;
|
||||
/// Stop the render stream after this long of silence-only output (unprimed, nothing queued), so
|
||||
/// an idle host releases the audio stack's sleep-blocking power request (see the module docs).
|
||||
/// Long enough that mid-conversation pauses never cycle the stream; resume is one condvar
|
||||
/// notify + `IAudioClient::Start`, well under the jitter buffer's prime depth.
|
||||
const IDLE_STOP_AFTER: Duration = Duration::from_secs(10);
|
||||
/// While the stream is idle-stopped the render thread parks on the queue condvar; this timeout
|
||||
/// only bounds how long a host-shutdown `stop` can go unnoticed (same discipline as the pump's
|
||||
/// `drain_sleep`).
|
||||
const IDLE_WAKE_CHECK: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The mic inject ring plus the wake signal for an idle-stopped render thread: `push` notifies
|
||||
/// on the empty→non-empty transition, which is exactly the moment a stopped stream must resume.
|
||||
type MicQueue = (Mutex<VecDeque<u8>>, Condvar);
|
||||
|
||||
/// `PUNKTFUNK_MIC_ALWAYS_ON=1`: never idle-stop the render stream (pre-2026-08 behaviour — the
|
||||
/// host then blocks system sleep for its whole life). Escape hatch in case a virtual audio
|
||||
/// driver's capture side misbehaves while its render side is paused.
|
||||
fn mic_always_on() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("PUNKTFUNK_MIC_ALWAYS_ON").is_some_and(|v| v != "0"))
|
||||
}
|
||||
|
||||
pub struct WasapiVirtualMic {
|
||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||
queue: Arc<MicQueue>,
|
||||
stop: Arc<AtomicBool>,
|
||||
/// False once the render thread has exited (device error or stop) — the pump's reopen signal.
|
||||
alive: Arc<AtomicBool>,
|
||||
@@ -94,7 +128,7 @@ impl WasapiVirtualMic {
|
||||
channels == CHANNELS,
|
||||
"virtual mic is stereo-only (got {channels})"
|
||||
);
|
||||
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
||||
let queue = Arc::new((Mutex::new(VecDeque::<u8>::new()), Condvar::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let ring = Arc::new(RingShared::default());
|
||||
@@ -144,9 +178,11 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
if !self.alive.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut q) = self.queue.lock() else {
|
||||
let (lock, wake) = &*self.queue;
|
||||
let Ok(mut q) = lock.lock() else {
|
||||
return false;
|
||||
};
|
||||
let was_empty = q.is_empty();
|
||||
q.reserve(pcm.len() * 4);
|
||||
for &s in pcm {
|
||||
q.extend(s.to_le_bytes());
|
||||
@@ -171,6 +207,12 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
.overflow
|
||||
.fetch_add((excess / BLOCK_ALIGN) as u64, Ordering::Relaxed);
|
||||
}
|
||||
drop(q);
|
||||
if was_empty {
|
||||
// Empty→non-empty is the resume moment for an idle-stopped stream (the waiter
|
||||
// re-checks the queue under the lock, so this cannot be a lost wakeup).
|
||||
wake.notify_one();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -179,7 +221,7 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
}
|
||||
|
||||
fn discard(&self) {
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
if let Ok(mut q) = self.queue.0.lock() {
|
||||
q.clear();
|
||||
}
|
||||
}
|
||||
@@ -199,7 +241,7 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
if prime == 0 {
|
||||
return None; // render loop hasn't run yet
|
||||
}
|
||||
let q = self.queue.lock().ok()?;
|
||||
let q = self.queue.0.lock().ok()?;
|
||||
Some((q.len() / BLOCK_ALIGN, prime / BLOCK_ALIGN))
|
||||
}
|
||||
|
||||
@@ -387,7 +429,7 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
}
|
||||
|
||||
fn render_thread(
|
||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||
queue: Arc<MicQueue>,
|
||||
stop: Arc<AtomicBool>,
|
||||
shared: Arc<RingShared>,
|
||||
ready: SyncSender<Result<String>>,
|
||||
@@ -461,7 +503,40 @@ fn render_thread(
|
||||
// the target).
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut primed = false;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// Idle stop (see the module docs): `running` mirrors the stream's Start/Stop state, and
|
||||
// `idle_mark` is the queue length last seen while unprimed plus when it was first seen at
|
||||
// that length — IDLE_STOP_AFTER of unprimed output at an UNCHANGED length is the idle
|
||||
// verdict. Keying on the length (not just emptiness) covers a sub-prime tail a vanished
|
||||
// client left behind, while any genuinely new audio moves the length (a push appends a
|
||||
// whole frame and the drop-oldest cap sits above the prime threshold, so an unprimed queue
|
||||
// can never coincidentally return to its marked length) and restarts the window.
|
||||
let always_on = mic_always_on();
|
||||
let mut running = true;
|
||||
let mut idle_mark: Option<(usize, Instant)> = None;
|
||||
'render: while !stop.load(Ordering::Relaxed) {
|
||||
if !running {
|
||||
// Idle-stopped: park on the condvar until mic audio arrives (push notifies on the
|
||||
// empty→non-empty edge); the timeout only keeps `stop` responsive.
|
||||
{
|
||||
let (lock, wake) = &*queue;
|
||||
let mut q = lock.lock().unwrap();
|
||||
while q.is_empty() {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break 'render;
|
||||
}
|
||||
let (guard, _timed_out) = wake.wait_timeout(q, IDLE_WAKE_CHECK).unwrap();
|
||||
q = guard;
|
||||
}
|
||||
}
|
||||
// A resume failure means the endpoint died while we slept — propagate, so the
|
||||
// thread exits, `alive` flips, and the pump reopens (fresh plan) as for any death.
|
||||
audio_client
|
||||
.start_stream()
|
||||
.context("resume render stream")?;
|
||||
running = true;
|
||||
idle_mark = None;
|
||||
tracing::debug!("virtual mic stream resumed (client mic audio arrived)");
|
||||
}
|
||||
// The device signals when it wants more data; finite timeout keeps `stop` responsive.
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
continue;
|
||||
@@ -486,7 +561,7 @@ fn render_thread(
|
||||
// Silence base; overwrite with queued mic PCM once the cushion is primed.
|
||||
buf[..need].fill(0);
|
||||
{
|
||||
let mut q = queue.lock().unwrap();
|
||||
let mut q = queue.0.lock().unwrap();
|
||||
if !primed && q.len() >= prime {
|
||||
primed = true;
|
||||
}
|
||||
@@ -500,6 +575,35 @@ fn render_thread(
|
||||
shared.reprimes.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
if primed {
|
||||
idle_mark = None;
|
||||
} else if !always_on {
|
||||
// Unprimed = this period was pure silence. After IDLE_STOP_AFTER of that at an
|
||||
// unchanged queue length, stop the stream so the box can sleep. Decided under
|
||||
// the queue lock, and only when nothing has arrived for the whole window (see
|
||||
// `idle_mark`'s docs) — a burst landing at the boundary moves the length and
|
||||
// resets the window instead of being cleared. What IS cleared is ≥10 s stale
|
||||
// (the pump's stale-gap discard would drop it before the next real frame
|
||||
// anyway), which keeps "queue empty" ⇔ "nothing to play" for the wait above.
|
||||
match idle_mark {
|
||||
Some((len, since)) if len == q.len() => {
|
||||
if since.elapsed() >= IDLE_STOP_AFTER {
|
||||
q.clear();
|
||||
audio_client
|
||||
.stop_stream()
|
||||
.context("idle-stop render stream")?;
|
||||
running = false;
|
||||
idle_mark = None;
|
||||
tracing::debug!(
|
||||
"virtual mic stream idle-stopped (releases the sleep-blocking \
|
||||
audio power request; next mic frame resumes it)"
|
||||
);
|
||||
continue 'render;
|
||||
}
|
||||
}
|
||||
_ => idle_mark = Some((q.len(), Instant::now())),
|
||||
}
|
||||
}
|
||||
}
|
||||
render_client
|
||||
.write_to_device(space, &buf[..need], None)
|
||||
|
||||
@@ -163,15 +163,20 @@ mod tests {
|
||||
impl EnvGuard {
|
||||
fn set(dir: &std::path::Path) -> EnvGuard {
|
||||
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
|
||||
std::env::set_var("PUNKTFUNK_CONFIG_DIR", dir);
|
||||
// SAFETY: only called by tests that hold CONFIG_DIR_TEST_LOCK, which serializes
|
||||
// every test that writes or reads this variable across the whole test binary.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", dir) };
|
||||
EnvGuard(prev)
|
||||
}
|
||||
}
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(v) => std::env::set_var("PUNKTFUNK_CONFIG_DIR", v),
|
||||
None => std::env::remove_var("PUNKTFUNK_CONFIG_DIR"),
|
||||
// SAFETY: dropped while the owning test still holds CONFIG_DIR_TEST_LOCK — the
|
||||
// same serialization as `EnvGuard::set`.
|
||||
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
|
||||
// SAFETY: as above.
|
||||
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,7 +734,9 @@ mod tests {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
// Confine the proxy to `dir` for the duration of this test.
|
||||
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
|
||||
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
|
||||
// that writes or reads this variable in the binary.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
|
||||
|
||||
// A real image inside the root: served, with the content type SNIFFED from the bytes.
|
||||
let cover = dir.join("cover.png");
|
||||
@@ -810,7 +812,8 @@ mod tests {
|
||||
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
|
||||
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
|
||||
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
|
||||
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
@@ -878,7 +881,9 @@ mod tests {
|
||||
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
|
||||
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
|
||||
// that writes or reads this variable in the binary.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
|
||||
|
||||
let cover = dir.join("cover.png");
|
||||
std::fs::write(&cover, PNG).unwrap();
|
||||
@@ -930,7 +935,8 @@ mod tests {
|
||||
"an out-of-root file:// cover is still refused"
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
|
||||
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
|
||||
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
@@ -56,8 +56,10 @@ mod gamestream;
|
||||
#[path = "linux/gpuclocks.rs"]
|
||||
mod gpuclocks;
|
||||
mod hooks;
|
||||
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below).
|
||||
#[forbid(unsafe_code)]
|
||||
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below). Test
|
||||
// builds carve out like `native`: the identity tests scope `PUNKTFUNK_CONFIG_DIR` by mutating
|
||||
// the process env, which edition 2024 makes an unsafe call; shipped code keeps the forbid.
|
||||
#[cfg_attr(not(test), forbid(unsafe_code))]
|
||||
mod identity;
|
||||
// The input-injection backends live in the `pf-inject` subsystem crate (plan §W6); this shim keeps
|
||||
// every existing `crate::inject::*` path valid (the native/gamestream input planes + devtest consume
|
||||
@@ -81,8 +83,10 @@ mod log_capture;
|
||||
// exposes — are safe Rust by compiler-enforced invariant (rust-safety programme): `forbid`
|
||||
// here means a future edit cannot quietly introduce unsafe into a network-facing module.
|
||||
// (`native` carves out its `#[cfg(test)]` C-ABI roundtrip tests, which exercise the CLIENT
|
||||
// side of punktfunk-core against this host in-process and are unsafe by nature.)
|
||||
#[forbid(unsafe_code)]
|
||||
// side of punktfunk-core against this host in-process and are unsafe by nature; `mgmt` and
|
||||
// `identity` carve out test builds too — their tests scope `PUNKTFUNK_CONFIG_DIR` by mutating
|
||||
// the process env, an unsafe call since edition 2024.)
|
||||
#[cfg_attr(not(test), forbid(unsafe_code))]
|
||||
mod mgmt;
|
||||
#[forbid(unsafe_code)]
|
||||
mod mgmt_token;
|
||||
@@ -396,6 +400,12 @@ fn real_main() -> Result<()> {
|
||||
// restored (crash/kill/power loss) — before any new session touches the topology.
|
||||
#[cfg(target_os = "windows")]
|
||||
monitor_devnode::startup_recover();
|
||||
// The same recovery for the experimental `edid_lock` axis: unpin AMD connector
|
||||
// emulation a previous host locked and never unlocked — pinned emulation outlives
|
||||
// the process (and can outlive a reboot), so this is the only thing standing between
|
||||
// a crash and a permanently-emulated connector.
|
||||
#[cfg(target_os = "windows")]
|
||||
pf_win_display::adl_emul::startup_recover();
|
||||
// The same recovery for the DEFAULT Exclusive path: a previous host that died holding a
|
||||
// CCD isolate left the operator's panels deactivated with nothing to put them back (the
|
||||
// restore snapshot was process memory). Runs AFTER the devnode leg so re-enabled
|
||||
|
||||
@@ -82,6 +82,10 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
|
||||
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
||||
"ddc_power_off".into(),
|
||||
"pnp_disable_monitors".into(),
|
||||
// EXPERIMENTAL, Windows + AMD-driver-only in effect (the ADL connector-emulation lever;
|
||||
// inert wherever `atiadlxx.dll` is absent). The console additionally gates the toggle's
|
||||
// VISIBILITY on an AMD GPU being present, so only the hosts it can act on ever see it.
|
||||
"edid_lock".into(),
|
||||
];
|
||||
// `capture_monitor` routes every session to the MIRROR backend, and that backend exists only on
|
||||
// Linux — `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]`, because `pf-capture`
|
||||
@@ -464,6 +468,7 @@ pub(crate) async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutReques
|
||||
store.game_session(),
|
||||
store.ddc_power_off(),
|
||||
store.pnp_disable_monitors(),
|
||||
store.edid_lock(),
|
||||
store.get().capture_monitor,
|
||||
);
|
||||
if let Err(e) = store.set(policy) {
|
||||
|
||||
@@ -787,8 +787,11 @@ async fn paired_clients_list_and_unpair() {
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(v) => std::env::set_var("PUNKTFUNK_CONFIG_DIR", v),
|
||||
None => std::env::remove_var("PUNKTFUNK_CONFIG_DIR"),
|
||||
// SAFETY: dropped while this test still holds CONFIG_DIR_TEST_LOCK, which
|
||||
// serializes every test that writes or reads this variable in the binary.
|
||||
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
|
||||
// SAFETY: as above.
|
||||
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -797,7 +800,9 @@ async fn paired_clients_list_and_unpair() {
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _env = EnvGuard(std::env::var_os("PUNKTFUNK_CONFIG_DIR"));
|
||||
std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path());
|
||||
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK (taken above), serializing every test that
|
||||
// writes or reads this variable in the binary.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
|
||||
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
@@ -1363,9 +1368,7 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
// 1. Every LIVE route has a classification row. A new route fails here until it gets one.
|
||||
for (method, path) in &live {
|
||||
assert!(
|
||||
expected
|
||||
.iter()
|
||||
.any(|(m, p, _, _)| m == method && p == path),
|
||||
expected.iter().any(|(m, p, _, _)| m == method && p == path),
|
||||
"route {method} {path} has no lane classification — add a row to EXPECTED in this test \
|
||||
and decide, deliberately, whether the plugin token and a paired streaming cert may \
|
||||
reach it"
|
||||
@@ -1542,9 +1545,11 @@ async fn display_settings_surface() {
|
||||
assert!(enforced.contains(&"mode_conflict"));
|
||||
assert!(enforced.contains(&"identity"));
|
||||
assert!(enforced.contains(&"layout"));
|
||||
// The experimental DDC/CI + PnP-disable axes are acted on (Windows exclusive-isolate path).
|
||||
// The experimental DDC/CI + PnP-disable + EDID-lock axes are acted on (Windows
|
||||
// exclusive-isolate path; edid_lock additionally needs an AMD driver to do anything).
|
||||
assert!(enforced.contains(&"ddc_power_off"));
|
||||
assert!(enforced.contains(&"pnp_disable_monitors"));
|
||||
assert!(enforced.contains(&"edid_lock"));
|
||||
}
|
||||
|
||||
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
||||
|
||||
@@ -2058,7 +2058,9 @@ mod tests {
|
||||
"expected the open-loop pin, got {uncapped}"
|
||||
);
|
||||
// With the operator ceiling set, the Automatic pin is capped to the link rate...
|
||||
std::env::set_var("PUNKTFUNK_PYROWAVE_MAX_MBPS", "4500");
|
||||
// SAFETY: this test is the only writer of this variable in the process, so writers can't
|
||||
// race each other; the only reader is `resolve_bitrate_kbps_for` on this same thread.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_PYROWAVE_MAX_MBPS", "4500") };
|
||||
assert_eq!(
|
||||
resolve_bitrate_kbps_for(Codec::PyroWave, 0, &mode, ChromaFormat::Yuv444, 10),
|
||||
4_500_000
|
||||
@@ -2078,7 +2080,8 @@ mod tests {
|
||||
resolve_bitrate_kbps_for(Codec::PyroWave, 6_000_000, &mode, ChromaFormat::Yuv444, 10),
|
||||
6_000_000
|
||||
);
|
||||
std::env::remove_var("PUNKTFUNK_PYROWAVE_MAX_MBPS");
|
||||
// SAFETY: as the set above — single writer, and the readers run on this thread.
|
||||
unsafe { std::env::remove_var("PUNKTFUNK_PYROWAVE_MAX_MBPS") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2434,13 +2437,16 @@ mod tests {
|
||||
struct EnvGuard(&'static str);
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var(self.0);
|
||||
// SAFETY: dropped while SESSION_TEST_LOCK is still held by the owning test, so
|
||||
// env writers stay serialized and only the session path reads this variable.
|
||||
unsafe { std::env::remove_var(self.0) };
|
||||
}
|
||||
}
|
||||
let _env = EnvGuard("PUNKTFUNK_CLIPBOARD");
|
||||
// Operator policy on. Session tests serialize on SESSION_TEST_LOCK, and only the session
|
||||
// path (a session test) reads this env, so the mutation is race-free here.
|
||||
std::env::set_var("PUNKTFUNK_CLIPBOARD", "1");
|
||||
// SAFETY: see the serialization argument directly above.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_CLIPBOARD", "1") };
|
||||
|
||||
let host = std::thread::spawn(|| {
|
||||
run_ephemeral(Punktfunk1Options {
|
||||
|
||||
@@ -212,7 +212,10 @@ fn load_host_env() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
let (k, v) = (k.trim(), v.trim().trim_matches('"'));
|
||||
if !k.is_empty() {
|
||||
std::env::set_var(k, v);
|
||||
// SAFETY: called from the service main before this process spawns any thread —
|
||||
// the network-profile warner and the supervisor's host child both start after
|
||||
// `load_host_env` returns, so nothing reads the environment concurrently.
|
||||
unsafe { std::env::set_var(k, v) };
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -325,11 +328,13 @@ fn run_service() -> Result<()> {
|
||||
console (session 0)"
|
||||
);
|
||||
|
||||
// BEFORE the warner thread below: `load_host_env` mutates the process env, and the warner
|
||||
// spawns a child process (which snapshots the env block) — the write must not run beside it.
|
||||
load_host_env();
|
||||
|
||||
// Best-effort: warn if this network is Public (streaming ports are firewalled off there unless
|
||||
// the operator opted in). Own thread — a slow `Get-NetConnectionProfile` never delays the host.
|
||||
std::thread::spawn(warn_if_public_network);
|
||||
|
||||
load_host_env();
|
||||
let result = supervise(stop, session);
|
||||
|
||||
// Report STOPPED regardless of how supervise returned.
|
||||
|
||||
@@ -372,7 +372,9 @@ fn notify_on_connect(hwnd: HWND) {
|
||||
nid.hBalloonIcon = unsafe {
|
||||
LoadImageW(
|
||||
Some(GetModuleHandleW(None).unwrap_or_default().into()),
|
||||
PCWSTR(1usize as *const u16),
|
||||
// `without_provenance`, not `ptr::dangling()`: this is MAKEINTRESOURCE(1) — the
|
||||
// ADDRESS is the resource ordinal, and dangling() would yield align_of::<u16>() = 2.
|
||||
PCWSTR(std::ptr::without_provenance(1)),
|
||||
IMAGE_ICON,
|
||||
sm,
|
||||
sm,
|
||||
|
||||
@@ -3323,6 +3323,21 @@ PunktfunkStatus punktfunk_connection_note_frame_index(const PunktfunkConnection
|
||||
bool *gap_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// [`punktfunk_connection_note_frame_index`] with the gap WIDTH instead of a yes/no: writes to
|
||||
// `gap_width_out` how many frames this arrival revealed as missing (0 = contiguous/straggler).
|
||||
// A client with a post-loss display freeze passes the width to
|
||||
// [`punktfunk_reanchor_gate_arm_expecting_drops`] so the reassembler's later `frames_dropped`
|
||||
// climb for the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm
|
||||
// race — see the gate function's doc).
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
|
||||
PunktfunkStatus punktfunk_connection_note_frame_index_ex(const PunktfunkConnection *c,
|
||||
uint32_t frame_index,
|
||||
uint32_t *gap_width_out);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
// rebuild them). A video loop polls this and calls [`punktfunk_connection_request_keyframe`]
|
||||
@@ -3446,6 +3461,18 @@ void punktfunk_reanchor_gate_free(ReanchorGate *g);
|
||||
// `g` is a valid gate handle.
|
||||
void punktfunk_reanchor_gate_arm(ReanchorGate *g);
|
||||
|
||||
// [`punktfunk_reanchor_gate_arm`] for a loss detected as a **frame-index gap**, where the caller
|
||||
// knows how many frames the gap skipped ([`punktfunk_connection_note_frame_index_ex`]). On top of
|
||||
// arming, the gate pre-credits the reassembler's `frames_dropped` climb those same lost frames
|
||||
// will produce up to ~120 ms later, so [`punktfunk_reanchor_gate_poll`] does not treat that
|
||||
// delayed bookkeeping as a SECOND loss — without the credit, a fast LTR-RFI anchor lifts the
|
||||
// freeze between the two signals and the stale climb re-freezes a healed stream (the double-arm
|
||||
// race). Use the plain arm for non-gap loss signals (decoder wedge/demotion). NULL is a no-op.
|
||||
//
|
||||
// # Safety
|
||||
// `g` is a valid gate handle.
|
||||
void punktfunk_reanchor_gate_arm_expecting_drops(ReanchorGate *g, uint64_t expected_drops);
|
||||
|
||||
// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
|
||||
// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
|
||||
// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# This workspace has been edition 2024 (and formatted under the 2024 style edition) since
|
||||
# before the main workspace migrated. The repo-root rustfmt.toml pins style_edition = "2021"
|
||||
# to keep that migration's diff reviewable; without this file, config discovery would walk up
|
||||
# to the root pin and demand a 2021-style reformat of this already-2024 tree.
|
||||
style_edition = "2024"
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "pf-vkhdr-layer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
description = "punktfunk Vulkan implicit layer: inject HDR10/scRGB surface formats on the virtual display so Vulkan games (id Tech, etc.) detect HDR over an IddCx virtual display"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
@@ -380,7 +380,7 @@ mod hdr {
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn MonitorFromWindow(h: HWND, flags: u32) -> HMONITOR;
|
||||
fn GetMonitorInfoW(h: HMONITOR, mi: *mut MonitorInfoExW) -> i32;
|
||||
fn GetDisplayConfigBufferSizes(flags: u32, np: *mut u32, nm: *mut u32) -> i32;
|
||||
@@ -513,7 +513,7 @@ fn should_inject(surface: vk::SurfaceKHR) -> bool {
|
||||
/// `p` must be null or point to a live `NegotiateLayerInterface` the caller has exclusive access
|
||||
/// to for the duration of the call. The Vulkan loader — the only intended caller of this
|
||||
/// export — guarantees exactly that for the negotiate handshake.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn vkNegotiateLoaderLayerInterfaceVersion(
|
||||
p: *mut NegotiateLayerInterface,
|
||||
) -> vk::Result {
|
||||
@@ -767,12 +767,12 @@ unsafe extern "system" fn destroy_instance(inst: vk::Instance, p_alloc: *const c
|
||||
// SAFETY: `inst` is non-null, and vkDestroyInstance requires a live instance handle —
|
||||
// still live during this call — whose first word is the dispatch key.
|
||||
.and_then(|mut g| g.remove(&unsafe { key(inst.as_raw()) }));
|
||||
if let Some(d) = data {
|
||||
if let Some(f) = d.destroy_instance {
|
||||
// SAFETY: `f` is the down-chain vkDestroyInstance resolved for this very instance at
|
||||
// create time; forwarding the caller's own arguments unchanged.
|
||||
unsafe { f(inst, p_alloc) };
|
||||
}
|
||||
if let Some(d) = data
|
||||
&& let Some(f) = d.destroy_instance
|
||||
{
|
||||
// SAFETY: `f` is the down-chain vkDestroyInstance resolved for this very instance at
|
||||
// create time; forwarding the caller's own arguments unchanged.
|
||||
unsafe { f(inst, p_alloc) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Pin the STYLE edition independently of the language edition. Migrating the workspace to
|
||||
# edition 2024 would otherwise flip rustfmt to the 2024 style edition in the same commit and
|
||||
# reformat ~370 files nobody touched — noise that buries the real migration diff and makes a
|
||||
# bisect useless. Adopting the 2024 style is fine, but it is its own one-line-plus-reformat
|
||||
# commit, not a side effect of this one. (packaging/windows/drivers pins 2024 in its own
|
||||
# rustfmt.toml — it was already formatted under that style; config discovery would otherwise
|
||||
# walk up to this file.)
|
||||
style_edition = "2021"
|
||||
@@ -22,12 +22,14 @@
|
||||
# above the fn.
|
||||
#
|
||||
# C. Safe-but-process-global APIs: `env::set_var`/`remove_var`, `sigaction`, `setlocale`,
|
||||
# `set_current_dir`. Each is safe to call and unsound (or racy) from a live multithreaded
|
||||
# process — the 972af299 environ data race lived in a file with ZERO occurrences of the word
|
||||
# `unsafe`, invisible to the census. Edition 2024 makes `env::set_var` unsafe; until that
|
||||
# migration this count-ratchet is the control. The baseline below enumerates today's debt
|
||||
# per file; ANY increase (or a new file) fails. Shrink a file's count? Lower its baseline in
|
||||
# the same commit.
|
||||
# `set_current_dir`. Each is (or was) callable without `unsafe` and unsound (or racy) from a
|
||||
# live multithreaded process — the 972af299 environ data race lived in a file with ZERO
|
||||
# occurrences of the word `unsafe`, invisible to the census. Since the edition-2024
|
||||
# migration the env pair is `unsafe fn` (compiler-enforced, SAFETY proof per site, counted
|
||||
# by the census); this ratchet stays for the still-safe APIs (`sigaction`, `setlocale`,
|
||||
# `set_current_dir`) and as a growth brake on env mutation generally. The baseline below
|
||||
# enumerates today's debt per file; ANY increase (or a new file) fails. Shrink a file's
|
||||
# count? Lower its baseline in the same commit.
|
||||
#
|
||||
# All three gates were shown to FAIL on deliberately planted instances before being made blocking
|
||||
# (the gate-of-the-gate rule that caught cd72f77a's `0 * SLOT`).
|
||||
@@ -167,8 +169,8 @@ clients/linux/src/app.rs:1
|
||||
clients/linux/src/spawn.rs:1
|
||||
clients/session/src/main.rs:4
|
||||
crates/pf-console-ui/src/screens/settings.rs:1
|
||||
crates/pf-console-ui/src/shell/tests.rs:2
|
||||
crates/pf-encode/src/enc/linux/nvenc_cuda.rs:49
|
||||
crates/pf-console-ui/src/shell/tests.rs:1
|
||||
crates/pf-encode/src/enc/linux/nvenc_cuda.rs:2
|
||||
crates/pf-encode/src/enc/linux/worker.rs:1
|
||||
crates/pf-encode/src/enc/windows/nvenc.rs:4
|
||||
crates/pf-inject/src/inject/linux/steam_gadget.rs:5
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user