Chore/rust safety programme #164
Merged
enricobuehler
merged 26 commits from 2026-08-11 20:47:17 +00:00
chore/rust-safety-programme into main
26
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bc70a58fb1 |
Merge main into chore/rust-safety-programme
windows-drivers / probe-and-proto (pull_request) Successful in 22s
ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m17s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m58s
ci / rust-arm64 (pull_request) Successful in 3m18s
ci / docs-site (pull_request) Successful in 3m55s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 4m46s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust (pull_request) Failing after 10m11s
nix / flake (pull_request) Successful in 15m6s
Two conflicts: the test-module import list in gamescope.rs (union — the branch's takeover-state tests and main's WSI opt-out tests both stay), and next_frame_timed_out in pf-capture, where the branch still carried the pre-#168 else-if chain — resolved to main's match-based refactor, which already embeds the same arm semantics plus the provisional-budget latch gate. |
||
|
|
fcf4c9fd63 |
fix(mgmt): unpair now revokes a LIVE session on both planes
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m38s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / docs-site (pull_request) Successful in 2m32s
windows-drivers / driver-build (pull_request) Successful in 1m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 6m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m54s
ci / rust (pull_request) Failing after 10m42s
nix / flake (pull_request) Canceled after 8m26s
windows-drivers / probe-and-proto (pull_request) Canceled after 0s
An unpair removed the certificate but left the revoked client's running
session streaming until the client chose to leave. Now it is a complete
revocation:
- GameStream: when the removed certificate owns the active launch, the
session is quit_session'd — the ENet control thread's ended-session arm
gives the client the standard TERMINATION+disconnect. (An owner-less
launch cannot be attributed and is left to the WP0 port teardown when the
last pairing goes.) The endpoint docstring's long-standing caveat
('removes the client from the listing without severing its ability to
reconnect') is retired: TLS handshakes complete by design, authorization
is per-request, and a live session no longer survives its own revocation.
- Native: session_status::stop_by_fingerprint signals the unpaired
client's live session(s) to tear down deliberately (quit+stop), matched
by the registry's client label — the fingerprint's 12-hex-char prefix for
every pairable client; anonymous/TOFU sessions carry IP labels and are
never touched (they have no pairing to revoke).
(The unpair-didn't-PERSIST half of 'unpairing was broken' was already fixed
in
|
||
|
|
9c6e06d3b9 |
feat(host): GameStream is now a cargo feature — WP19, compile-time isolation
A new 'gamestream' feature (default ON — every stock package is behaviorally identical, and GameStream stays runtime-opt-in via --gamestream / PUNKTFUNK_GAMESTREAM) gates the whole Moonlight-protocol surface: control (the ENet plane), rtsp, nvhttp, pairing, serverinfo, the _nvstream mDNS advert, the compat media path (stream/video/audio), pen/gamepad/input decode, apps, crypto, cert (the RSA identity), and tls's Moonlight-client-cert leniency. AppState keeps the shared vocabulary unconditional and cfg-gates the Moonlight-only fields; the mgmt API's PIN endpoints (routes, handlers, OpenAPI entries, lane classifications, tests) exist only under the feature. Building --no-default-features --features pyrowave yields the hardened NATIVE-ONLY host: no rusty_enet (the c2rust-transpiled C ENet stack, 158 unsafe sites) and no rsa (the identity split's legacy fallback became a pem-only read — rustls/ring serves an existing RSA cert without the crate — so the accepted Marvin advisory no longer applies to native-only builds). Both claims are ASSERTED, not assumed: a new CI leg keeps the native-only flavor clippy-clean and fails if cargo tree finds either crate in its graph. serve --gamestream (or the env knob) against such a binary refuses to start with a clear error rather than serving less than the operator configured. En route: the logs-paging test assumed a quiet process-global log ring between its cursors and raced other tests' legitimate log lines (the identity tests added new emitters) — it now asserts on its own markers within the page. Gates: Linux amd64 — BOTH flavors clippy --all-targets -D warnings clean; default tests identity 3/3, mgmt 37/37, gamestream 59/59; native-only tests identity 3/3, mgmt 35/35, residue 4/4; rusty_enet+rsa absent native-only, present default. .133 Windows — both flavors clippy clean (clean-first, sentinel-checked), tree claims hold, and the WP0 port-lifecycle functional gate PASSES on the default build. |
||
|
|
e658ad726b |
feat(host): the identity split — the native planes get their own P-256 identity
One RSA-2048 identity served every plane, because Moonlight mandates RSA and the planes grew out of the GameStream host. The native punktfunk/1 QUIC plane and the management API now share a separate ECDSA P-256 identity (native-cert.pem/native-key.pem, src/identity.rs): ring-generated via rcgen (no rsa crate on the native path — the accepted Marvin advisory stops applying once WP19 gates the compat planes), real SANs (localhost, loopback, machine hostname — the legacy cert had none), and browser-compatible on purpose: Ed25519 was rejected because no mainstream browser accepts an Ed25519 server cert and /api/docs is opened in one. GameStream keeps the RSA identity untouched (Moonlight pins it; its pairing hashes bind its X.509 signature bytes). Migration is pin-preserving by construction. Clients TOFU-pin ONE leaf-DER SHA-256 for both QUIC and the mgmt/library API, so the identity is resolved ONCE in serve (the planes cannot race the first-run mint) under the rule: identity files exist → use them; else the native trust store is EMPTY → mint P-256 (fresh installs); else keep presenting the legacy RSA cert the paired clients pinned, and log the migration path (unpair all, restart, re-pair). Fingerprint pinning is algorithm-agnostic — existing shipped clients pair against P-256 hosts unchanged. Followers updated: the tray's loopback pin and the plugin SDK's mgmt CA prefer native-cert.pem → cert.pem; the Windows runner ACL grant lists both (the grant loop tolerates absent files). The in-process native tests now run on an EPHEMERAL identity — they previously read, and would newly have MINTED, identity files in the real config dir, which on a dev box that is also a live host would have switched its identity and stranded every pinned client. Gates: Linux amd64 clippy --all-targets -D warnings clean (host+tray); identity 2/2, mgmt 37/37, control 6/6, native 68/68 (C-ABI roundtrips over the ephemeral identity). .133 Windows clippy clean; the port-lifecycle gate re-run PASSES with the split live — the fresh host minted P-256 and served mgmt over it (curl 200/204), ports tracked the paired list as before. |
||
|
|
23d0452157 |
feat(host): GameStream opt-in on every route; the native plane is deny(unsafe_code)-enforced
The user direction after WP0: ENet exists only for Moonlight, so the native
plane must be provably safe and the compat planes a deliberate choice.
Opt-in, everywhere. Windows already was (unchecked installer task). The three
opt-out surfaces are flipped: the shipped systemd user unit (deb/RPM/Arch/
sysext) no longer bakes --gamestream into ExecStart — a new
PUNKTFUNK_GAMESTREAM=1 host.env knob (pf-host-config, OR-ed with the CLI
flag) is the packaged opt-in; the NixOS module default goes true→false, with
a module-check assertion that unset = native-only; the Deck installer takes
--gamestream to opt in (--no-gamestream kept as explicit-off). Docs
(quickstart, running-as-a-service, moonlight, ubuntu/fedora/arch firewall
sections, gnome/sway, how-it-works) rewritten to the opt-in shape; the
CHANGELOG carries the upgrade note.
Enforced-safe. punktfunk-core is #![deny(unsafe_code)] crate-wide — every
module that parses network bytes is safe Rust as a compile error, not a
census result. Carve-outs are exactly two documented classes, neither of
which interprets attacker bytes: the client surface (abi, client) and the
transport syscall-batching shims (udp/{apple,linux,windows}, qos_windows).
In punktfunk-host, the modules a secure-default host exposes — native
(cfg-not-test: its tests exercise the client C ABI on purpose),
native_pairing, mgmt, mgmt_token, discovery, wol — are #[forbid(unsafe_code)].
Gates: Linux amd64 container clippy --all-targets -D warnings clean over
core+host-config+host; core 204 tests green under the deny; mgmt 46/46,
control 6/6. .133 Windows clippy (shipped features, clean-first,
sentinel-checked) clean — covers the qos_windows/udp-windows carve-outs.
macOS + iOS cargo check green (the apple.rs carve-out compiles for real).
|
||
|
|
13d5721049 |
feat(gamestream): the ENet control port exists only while a pairing does (WP0)
rusty_enet — a c2rust-style transpile of C ENet, 158 unsafe sites — parsed unauthenticated UDP on 47999 from GameStream startup, before any client had ever paired: the host's entire pre-auth-reachable unsafe surface. Pairing itself is HTTPS on nvhttp and never touches the port, so it now binds only while the paired-client list is non-empty: a Gate in control.rs reconciles the port to the list (armed only under --gamestream), pairing phase 4 brings it up before the new client can /launch, and removing the last pairing tears it down — a live client gets the same termination+disconnect farewell as a host-side session end. A never-paired host on a hostile LAN exposes no ENet. En route: the management API's unpair never called save_paired, so a restart resurrected the client — and would now have silently re-opened the port; it persists (the test now runs against a throwaway PUNKTFUNK_CONFIG_DIR so it can't clobber a real paired.json). rusty_enet is pinned =0.4.0 per the WP, left to the cargo-audit job to flag advisories against it. Gate (amd64 container): clippy --all-targets -D warnings clean; gamestream::control 6/6; mgmt::tests 37/37 incl. the regenerated api/openapi.json. On-box .133 verification (ports/pair/stream) still owed. |
||
|
|
d4366e7464 |
fix(pf-encode): the Vulkan extension probe walked a driver-filled array with no bound
ci / web (pull_request) Successful in 1m20s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m49s
windows-drivers / probe-and-proto (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / bun-nix (pull_request) Successful in 24s
android / android (pull_request) Successful in 4m37s
ci / rust (pull_request) Successful in 10m25s
`ext_advertised` did `CStr::from_ptr(e.extension_name.as_ptr())` over a
driver-filled `[c_char; VK_MAX_EXTENSION_NAME_SIZE]`, and `vk_build.rs` open-coded
the identical call a second time. Neither had an in-Rust bound: a driver that
fills all 256 bytes without a NUL runs the walk into the NEXT
`ExtensionProperties`, and on the LAST element past the allocation.
The SAFETY comment asserted the spec guarantee ("a spec-guaranteed NUL-terminated
byte array") instead of enforcing it. That is the defect class this programme
keeps finding: a proof that restates what the other side promised rather than
checking it. Vulkan drivers are exactly the other side.
The bounded answer already shipped in the same crate — `pyrowave.rs:210` uses
ash's `extension_name_as_c_str()` for the identical job. It stops at
VK_MAX_EXTENSION_NAME_SIZE and returns Err when there is no terminator, so a
malformed entry is a non-match instead of an overrun. Both sites now route
through the one helper, which is no longer unsafe at all.
Deletes 2 unsafe operations and one duplicated walk.
⚠ The pre-existing test could not have caught this: it only ever built
well-formed, NUL-terminated entries. Added a case whose LAST element is 256
non-NUL bytes — the exact shape that used to leave the array — and a
prefix-match case, so the bound is now asserted rather than assumed.
Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain):
cargo check -p pf-encode --features vulkan-encode,pyrowave --locked ok
cargo test -p pf-encode --features vulkan-encode,pyrowave ext_advertised
2 passed / 0 failed
cargo clippy -p pf-encode --all-targets --locked
--features vulkan-encode,pyrowave -- -D warnings clean
Linux-only code (`enc/linux/`), so the Windows leg is unaffected.
|
||
|
|
cd72f77a3c |
fix(pf-encode): the AMF layout guards broke Windows clippy — 0*SLOT and 1*SLOT
windows-drivers / probe-and-proto (pull_request) Successful in 29s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 2m10s
ci / rust-arm64 (pull_request) Successful in 2m53s
ci / web (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 4m44s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Canceled after 5m25s
ci / docs-site (pull_request) Canceled after 1m7s
`27f08340` wrote every vtable offset assertion as `offset_of!(T, f) == N * SLOT`
so the slot INDEX stays visible in the assertion. For N=0 and N=1 that is
`0 * SLOT` and `1 * SLOT`, which clippy rejects as `erasing_op` and
`identity_op` — six errors, and windows-host.yml runs clippy with `-D warnings`,
so the branch as pushed would have turned the Windows leg red.
This is the blind spot the programme document names in §1.5, demonstrated on the
programme's own first code commit: 44% of the host's unsafe is `#[cfg(windows)]`,
no Linux or macOS check compiles it, and `cargo fmt`/`cargo check` on a Mac are
all clean. Only the .133 gate sees it.
Fixed with a `const fn slot(i: usize) -> usize` rather than by writing the two
offending cases as bare `0` and `SLOT`: that would have made those two the only
assertions where the slot index is invisible, and the index is the entire point.
Also records the cheap local gate that would have caught this without a Windows
round-trip: `amf_sys.rs` depends on nothing but `c_void`, so copying it into a
throwaway one-file crate and running `cargo clippy -- -D warnings` reproduces the
exact error on any host. Verified by reintroducing `0 * SLOT` and watching the
harness fail with the same message the runner gave.
Verified on 192.168.1.133 (Windows CI runner, the box with the WDK), after a
`cargo clean -p pf-encode` that reported `Removed 47 files, 135.5MiB` so the
recompile is real and not a cached green:
cargo check -p pf-encode ok
cargo check -p pf-encode --all-targets --features nvenc,amf-qsv,qsv ok
cargo clippy -p pf-encode --all-targets --features nvenc,amf-qsv,qsv
-- -D warnings exit 0 (was 101)
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings
exit 0 (was 101)
The gate also greps the extracted tree for the assertions before building, so a
stale upload cannot produce a passing run.
|
||
|
|
cd3f5474bf |
fix(pf-driver-proto): a layout test read an align-8 struct out of an align-1 buffer
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m48s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 20s
windows-drivers / driver-build (pull_request) Successful in 2m14s
windows-drivers / probe-and-proto (pull_request) Successful in 40s
android / android (pull_request) Successful in 4m11s
ci / rust-arm64 (pull_request) Successful in 3m7s
ci / rust (pull_request) Successful in 7m4s
`control_structs_roundtrip_through_bytes` built the legacy-size wire form in a
stack `let mut legacy = [0u8; 40]` (align 1) and then called
`bytemuck::from_bytes::<control::AddRequest>`. `AddRequest` opens with
`session_id: u64`, so it is align 8, and `from_bytes` hands back a REFERENCE
into the buffer — it panics unless the buffer happens to be 8-aligned.
A stack `[u8; 40]` usually is, which is why this passed on every machine and
every CI leg since it was written. Under Miri it fails outright: Miri does not
let an accidentally-favourable stack slot stand in for a guarantee.
Switched to `pod_read_unaligned`, which reads by value and has no alignment
precondition. That is not a new idea here — `ChannelProof::parse` at lib.rs:1013
already carries a comment saying "`pod_read_unaligned`, NOT `from_bytes`" for
exactly this reason. This site is the only other one in the crate that reads a
POD out of a stack byte array; every other `from_bytes` call in the tests reads
from `bytes_of(&x)`, which is aligned by construction.
Test-only, so no shipped defect — but the crate is `#![forbid(unsafe_code)]` and
is path-dep'd by BOTH the main workspace and the driver workspace, so it is the
layout oracle for every frame and IOCTL that crosses that boundary. A test that
cannot be trusted to fail is worth fixing there more than anywhere else.
Found by the first Miri run ever performed against this repo.
Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0):
cargo +nightly miri test -p pf-driver-proto 21/21
cargo +nightly miri test -p pf-driver-proto --target x86_64-pc-windows-msvc
21/21
cargo test -p pf-driver-proto --locked ok
cargo clippy -p pf-driver-proto --all-targets --locked -- -D warnings clean
The cross-target run is the interesting one: it interprets the crate at MSVC
layout on a Linux box with no Windows anywhere. Nothing else in CI does that.
|
||
|
|
972af2992f |
fix(pf-capture): the gamescope cursor fallback rewrote environ under a live multithreaded host
`connect_via_env_swap` did set_var("XAUTHORITY", …) / connect / restore, guarded
by a mutex that serialised this source against itself and against nothing else.
`getenv` takes no lock. setenv/unsetenv rewrite the process-global `environ`, and
glibc REALLOCATES that array when a variable is added — while, at that exact
moment, the PipeWire thread is inside pw_init()'s dlopen making bare getenv()
calls and EGL/CUDA init is running alongside. The file's own doc already called
the pattern "unsound from a live multithreaded host"; it stayed as a fallback.
Three things made it worse than the comment suggested:
- The damaging branch is the one where XAUTHORITY is ABSENT and therefore gets
ADDED (the realloc case). scripts/punktfunk-host.service deliberately does not
import the login shell's environment, so absent is the DOCUMENTED NORMAL
configuration for the shipped unit, not an edge case.
- `rediscover` re-runs this every 2 s for the whole session. A display whose
connect fails is never pushed into `displays`, so the dead-display skip never
covers it — the race is not once at startup, it repeats forever.
- It is unfixable in place. Sharing pf_vdisplay's ENV_LOCK is the wrong layer: it
cannot make C `getenv` take a lock.
The fix is to stop writing `environ` at all. Connecting with an explicitly empty
auth token is what the swap actually achieved: we only reach the fallback when
our own lookup found no usable MIT-MAGIC-COOKIE-1 entry, and x11rb's internal
lookup reads the same file with a STRICTER matcher (it matches family/address
too, which we deliberately do not), so where we find nothing it finds nothing
either and connects unauthenticated. That is exactly why the swap "worked"
against a nested Xwayland started without -auth.
Gives up one case: an .Xauthority using an auth family we decline to guess at but
x11rb would have handled. A gamescope Xwayland writes a single-entry
MIT-MAGIC-COOKIE-1 file, so it is not reachable here, and declining to attach a
cursor overlay beats tearing `environ` out from under a live session.
Also removes XAUTH_LOCK, whose only user this was.
Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain, pipewire
dev headers present): `cargo check -p pf-capture --locked` and
`cargo clippy -p pf-capture --all-targets --locked -- -D warnings` both clean.
Not verified on glass: the fallback is only reached when the cookie parse fails,
so a normal gamescope session does not enter it. Forcing it needs a nested
Xwayland started without -auth, or a mangled cookie file, on .181/.136.
|
||
|
|
df6f270e7b |
chore(safety): forbid unsafe on the crates that are already at zero
Five permanent ratchets, all free today — the point is that they cannot regress
tomorrow. Each crate was re-measured at the commit, not taken from a survey.
`forbid(unsafe_code)`:
punktfunk-encode-worker the binary that carries cap_sys_nice. Its header
claims "no Wayland, no D-Bus, no network, no
plugins"; this makes the memory-safety half of that
claim mechanical. `forbid`, not `deny`, so it cannot
be re-opened by an #[allow] further down.
pf-update-check parses a signed, network-fetched manifest and its own
header says it "owns the part where being wrong is a
security bug". Signature checking is worthless if the
parser around it can be walked out of bounds.
pf-vaadec its header states the design constraint outright — it
links no libva and compiles on macOS, "which is the
point". The crate is full of hand-declared libva
repr(C) mirrors; one raw deref and it stops being the
CPU-testable half.
tools/cursor-probe free, and a probe is where "just deref it to see" is
most tempting.
`deny(unsafe_code)` + one localized allow:
pf-update root runs this. Its single unsafe operation, a bare
geteuid, moves into a named `effective_uid()` helper
carrying the crate's one #[allow(unsafe_code)].
Deliberately NOT rewritten to rustix, contrary to the programme document's first
draft: pf-update's Cargo.toml states that its zero-dependency posture IS a
security invariant of a root helper ("no HTTP client, no TLS, no argument
parsing"), and the extern block says the same. Pulling a general-purpose syscall
crate into a root helper to delete one `unsafe` would trade a real property for
a cosmetic one. The localized allow keeps the ratchet: any NEW unsafe anywhere
in the crate is a build error.
Verified: `cargo check -p pf-vaadec -p pf-update-check` and
`cargo check -p pf-update -p cursor-probe` clean on macOS, plus
`cargo check -p pf-update --target x86_64-unknown-linux-gnu` — pf-update's whole
body is behind `cfg(target_os = "linux")`, so the macOS check does not reach the
line that changed. punktfunk-encode-worker is not built here (pf-encode's C
dependencies do not cross-compile from macOS) and needs the Linux CI leg.
|
||
|
|
27f0834025 |
fix(pf-encode): const-assert the AMF vtable and POD layouts
amf_sys.rs mirrors five AMF COM vtables by hand and amf.rs dispatches through them BY SLOT POSITION — 18 distinct slots across the five tables. The mirrors carried 118 `Slot` placeholders whose only job is to hold the following slots at their C offsets, and not one layout assertion of any kind. A slot inserted, removed or reordered in an AMF header bump calls an arbitrary function pointer through a mismatched signature: no compile error, no runtime signal. `AMF_MIN_VERSION` does not defend against this. It checks a version NUMBER, not a layout, and it is a floor with no ceiling. The three POD checks that did exist (`AmfVariant`, `AmfGuid`, `AmfHdrMetadata`) lived in amf.rs's `#[cfg(test)]` module, so they were verified only when someone ran pf-encode's tests, on Windows, with AMF enabled — and NEVER in a release build, which is exactly where a mis-mirrored `AMFVariantStruct` does its damage: it crosses the FFI BY VALUE on every SetProperty. This is the same hole `a8dd348b` closed for the cuda.h mirrors and missed here. Adds ~40 `const _: () = assert!(...)` guards next to the mirrors: size of each of the five vtables, the byte offset of every slot amf.rs actually calls, the three POD layouts promoted out of the test module, and the AMFData/AMFBuffer shared-prefix agreement that `create_surface_from_dx11_native`'s AMFSurface-through-AMFData reinterpretation silently depends on. Verified by compiling amf_sys.rs standalone (it needs only `c_void`, and a repr(C) struct of code pointers has the same layout on any 64-bit target, so a macOS const-eval proves the Windows arithmetic), and by deliberately breaking one offset to confirm the guard actually fires rather than silently passing. That check earned its keep immediately: `alloc_buffer` sits at slot 43, not 42. Counting AMFInterface(3) + AMFPropertyStorage(10) + the AMFContext block by hand is exactly the error these assertions exist to catch. Zero runtime behaviour change. The `AMF_MIN_VERSION` ceiling is deliberately NOT part of this commit: a ceiling would make the next AMF driver release refuse encode on every AMD box, so it needs a warn-and-continue policy plus an env override and a real AMF session to gate it. |
||
|
|
db6683a585 |
chore(safety): commit the unsafe census, fix its two bugs, record the baseline
Founding commit for a host-focused Rust safety programme. Adds the census tool
that measures the programme, the 2026-08-11 baseline it produces, and the
programme document itself.
The metric is SHIPPED NON-FFI UNSAFE OPERATIONS: 713. Raw `unsafe {}` block
count is the wrong target and the workspace manifest already says why — 63.3%
of unsafe operations in host scope (1542 of 2435) are a single third-party FFI
call that ash/windows-rs/ffmpeg mark unsafe on our behalf. A block count also
rewards merging blocks, ignores SAFETY comments, and IMPROVES when code moves
from Linux to Windows, because no local check can see the Windows half.
The tool shipped here had two defects, both fixed:
- `in_test_mod` cached parsed `#[cfg(test)]` spans in a dict keyed on `id(src)`,
the memory ADDRESS of the source string. CPython recycles addresses, so once
one file's source was collected the next file's string could be allocated at
the same address and silently inherit the previous file's test spans. Ten
consecutive runs over an unchanged tree produced 694, 695, 696, 701, 703,
709, 710, 713, 714 and 721. Fixed by holding a strong reference to the string
beside its spans, which makes the address un-recyclable while the entry is
live. Five consecutive runs now agree exactly.
- The layout-assertion regex matched `const _: () = assert!(...)` but not the
`const _: () = { ... };` block form, which 18 files use — including abi.rs,
pf-inject/linux/gamepad.rs and pf-capture/.../idd_push/probes.rs. It reported
102 unguarded repr(C) declarations across 25 files where the true figure is
60 across 22, defaming three well-guarded files.
A metric that is not reproducible is not a ratchet. The acceptance gate for
this commit is therefore five consecutive identical runs, not one.
Baseline: 713 shipped non-FFI unsafe operations; 60 unguarded repr(C)
declarations across 22 files; unsafe reachable pre-authentication by an
unpaired peer = 0 first-party.
|
||
|
|
7ffafb5ef3 |
chore(api): regenerate openapi.json after merging main
`main` gained the launcher brand tokens (`f62a48d4`) while this branch was open, and both sides touch the generated document — so it was regenerated from the MERGED source rather than text-merged. Verified to carry both: the 18 launcher-token entries from main, and this branch's corrected schema descriptions. No `required` array changed, so no client regeneration is needed. |
||
|
|
4b686f026a |
Merge branch 'main' into worktree-vd-sweep-2
# Conflicts: # api/openapi.json |
||
|
|
d6132f7523 |
chore(api): regenerate openapi.json for the pf-vdisplay policy doc corrections
The sweep rewrote doc comments on `ToSchema` types (`KeepAlive`, `Topology`, `ModeConflict`, `Identity`, `LayoutMode`, `Layout`, `DisplayPolicy`, `EffectivePolicy`), and utoipa emits those verbatim as schema descriptions — so the checked-in snapshot went stale and `mgmt::tests::openapi_document_is_complete_and_checked_in` would have failed. Several of the corrected descriptions were shipping outright falsehoods to API consumers. The worst: `KeepAlive::Forever` documented itself as "**Not honored until the display-lifecycle stage**" while the mgmt handler honors it end-to-end and the `gaming-rig` preset selects it (sweep item 11.7). Diff is descriptions only — the `required` arrays are unchanged, so no SDK or client regeneration is needed. Generated with `cargo run -p punktfunk-host -- openapi` in `ci/rust-ci.Dockerfile` under `--platform linux/amd64`, and confirmed by running the host's own drift test there (37 mgmt tests). `docs-site/public/openapi.json` is deliberately untouched: it is already ~34 KB behind `api/` from earlier work, and refreshing it here would sweep in unrelated changes. |
||
|
|
dc4d8d6832 |
fix(pf-vdisplay): correct the regressions this sweep introduced
An adversarial review of the sweep's own diff raised 39 claims; 23 survived independent verification. This commit fixes them. Several are cases where the sweep traded one bug for another. **The display budget was enforced in the wrong place.** The new Linux `max_displays` ceiling sat in `registry::acquire` — which runs again on every mid-stream rebuild. All three create-before-drop paths hold the old lease while acquiring the new display, and only the mode-switch path passes `supersedes`, so a session at the ceiling counted itself against the budget and could never recover from capture loss or a Game↔Desktop switch. At `max_displays = 1` that is a single streaming client. Moved to `admission::admit`, which is where Windows has always applied it and which is reached once per connect — so a rebuild cannot hit it. **"Cannot tell" was collapsed into "wrong mode".** `unanimous_output_size` returning `None` for two disagreeing gamescopes was compared with `== Some(target)`, so ambiguity took the destructive branch: a nested per-title gamescope — the normal Game Mode shape — made every connect restart the box's session and kill the running game. Now a three-state `BoxOutputSize`, where `Ambiguous` mirrors the live node instead of re-moding, and the post-restart wait asks "did what we asked for come up" rather than demanding unanimity. **Decide-then-act lost its mutual exclusion.** Re-scoping the `MANAGED_SESSION` guard fixed the shutdown restore but let two concurrent creates at the same mode both relaunch, the second stopping the unit the first was polling. A separate `MANAGED_LAUNCH` mutex restores the exclusion without putting launch progress back into the lock the restore samples. **Per-axis policy salvage was applied to a selector.** `preset` chooses the other axes, so salvaging it to the default silently re-pointed the whole document; it now refuses the document instead. A file whose every axis is unreadable also reported `configured() == Some(default)` — flipping Linux identity from Shared to PerClient — and now correctly reports unconfigured. **The six `#[serde(default)]` on `EffectivePolicy` are reverted**: they loosened `POST/PUT /display/presets` (an omitted axis defaulted where it used to 400), which nobody asked for. The catalog salvage they were added for now lives in a private Deserialize-only mirror type, so the read path stays lenient and the wire contract stays strict. Also: the Windows create path stored the OS-committed refresh in the field `acquire` uses as its resize discriminator, so a same-mode re-acquire looked like a hotplug — the requested and committed modes are now separate fields; `output_within`'s timeout arm detached both reader threads (now bounded by a drain grace, capped at 16 MiB, and logged honestly — a `systemd-run --pipe` unit escapes the process group and cannot be reached); `reenable_outputs_kscreen` abandoned the mode restore whenever kscreen-doctor hit its budget even though the enable may have landed (now tri-state); `write_atomic` replaced a symlinked portal config with a regular file, severing dotfiles management; several new budgets were too short for the helper they bound (`steam -shutdown` was being killed before it could deliver the request; `linger_enabled` read a 300 ms timeout as "not lingering" and hard-failed a correctly configured box); and a restore logged an operator-facing error for a `systemctl` call that had merely outlived its budget while systemd still owned the queued job. Verified: 107 tests on macOS, 202 on Linux (executed in a container, not merely type-checked), Linux and Windows clippy clean at `-D warnings`, fmt clean. |
||
|
|
8b98d0b3ec |
fix(pf-capture): a sweep found nine real defects behind comments that asserted the opposite
Reviewed the whole crate (15.6 kloc) for bugs, safety, structure and comment truth. Both compile gates are green: `scripts/xcheck.sh windows clippy` and `cargo clippy -p pf-capture --all-targets --locked -- -D warnings` in the amd64 CI image (the Linux half needs libpipewire, so it cannot ride xcheck). Code defects, each one contradicted by a comment sitting next to it: * `pipeline_depth` clamped to `OUT_RING` (3) while both `repeat_last` and `OUT_RING` state the safe maximum is 2. `d` frames in flight need `d + 1` textures, so `PUNKTFUNK_IDD_DEPTH=3` rotated onto the slot NVENC was still reading and the convert overwrote it in place — torn frames, silently. Now `OUT_RING - 1`. * The GDI cursor poller published `visible: true` for a NULL `hCursor` carrying `CURSOR_SHOWING` — how an app hides the pointer for its own window. The last rasterised arrow was then blended into a game that had hidden its cursor. Every rasterise gate already tested `handle != 0`; the published verdict now agrees. * The ETW event callback did `RING.lock().unwrap()`. That is an `extern "system"` fn, so a poisoned lock panicked across an FFI boundary and ABORTED the host — a diagnostic taking down capture. Poison-tolerant now, which also makes the poison unreachable. * `ChannelBroker::send` bounded the ring with `debug_assert`, so a release build instead panicked mid-`duplicate_and_deliver`, unwinding past the reap and leaking every handle already planted in the driver's WUDFHost. Refuses before the first duplication. * `set_active(false)` did not clear `stall_since`, so a pooled capturer carried a stale stall clock into its next stream and reported capture loss microseconds in. * `attach_gamescope_cursor` evaluated `spawn` before dropping the old source: two readers published into one slot, and a failed spawn destroyed a working reader. Idempotent now. * `PUNKTFUNK_FORCE_SHM` used a bare `== "1"` compare, silently ignoring `=true`/`=on`. * `spa_meta_bitmap.offset == 0` is SPA's "no image data" signal, distinct from the `bitmap_offset == 0` position-only case. Unhandled, it decoded the header's own words as cursor pixels and cached them. * A `VideoInfoRaw::parse` failure was swallowed, so a malformed Format pod surfaced as the generic "no acceptable format" timeout. It is logged, and parsed once, not twice. Comment corrections, all verified against the code they describe: four claims that a failed open falls back to DDA (removed — the caller drops the keepalive under "no fallback"); three comparisons to the removed WGC path; "we do NOT gate HDR on the client's VIDEO_CAP_10BIT" (it does, in three places); the P010 sampler's "4 explicit taps / 2x2 box" (two taps, left-cosited — the box was the bug it replaced); the cursor meta cap quoted as 256x256 (1024, and 256 is the value that cost the whole Linux cursor channel on-glass); the poller's "~60 Hz" (4 ms, ~250 Hz); "several minutes of coverage" (~26 s); "8 frames in 400 ms >= 20 fps" (7 intervals, so 17.5); three "process-wide" HDR latch claims (per-source, which is why HdrSource exists); a SAFETY proof claiming a view is "unmapped never" (Drop unmaps it); the Linux module header describing a bounded channel and BGRx-only frames (one-deep overwriting slot, several formats); and a doc line stranded on `DisplayDescriptor` by an earlier split, restored to `IddPushCapturer`, which had none. |
||
|
|
6b33750edc |
fix(pf-vdisplay): one non-UTF-8 byte in a portal config destroyed the whole file — in the module written to prevent exactly that
`portal_config::ensure_key` folded EVERY read failure into an empty string
(`read_to_string(path).unwrap_or_default()`). `upsert("", …)` then produced a file containing only
our block, the one-time backup was skipped because `!existing.is_empty()` was false, and the write
replaced the user's config — returning `Ok(true)`.
So a single Latin-1 character in a comment in `~/.config/hypr/xdph.conf` or
`~/.config/xdg-desktop-portal-wlr/config` destroyed the operator's entire portal configuration, with
no backup and no warning. The module doc says flat-writing these files "destroyed [everything else]
on first connect, silently and permanently" and that this module exists so it cannot happen; that
one line re-opened the door. The same shape hit a transient EIO on an NFS or overlay config dir.
Now: bytes are read with an explicit match, only `NotFound` may mean "empty", a non-UTF-8 config is
refused by name rather than replaced, the backup is taken by BYTES, and the write is atomic
(temp + `sync_all` + rename in the same directory, permissions carried over). Five new tests, all
running on macOS — `a_non_utf8_config_is_refused_not_replaced` fails against the old code.
Also in the wlr/Mutter family:
* **Mutter's `Primary` rebuilt kept physicals from scratch** — scale forced to 1.0, transform to 0,
disabled heads re-enabled — so a rotated, 2x-scaled or deliberately-disabled monitor came back
wrong, while the code went to real trouble to preserve refresh. Each head now carries its
pre-connect scale and transform, and x advances by the LOGICAL width.
* Three availability probes read session env (`SWAYSOCK`, `XDG_CURRENT_DESKTOP`,
`HYPRLAND_INSTANCE_SIGNATURE`) with no `ENV_LOCK` while `apply_session_env` `set_var`s the same
keys from another thread — the glibc setenv/getenv race this crate's own lib.rs documents as UB.
* `wlroots::create_output` ran a statement before its `OutputGuard` existed, so a raced
`wait_new_output` orphaned the output permanently — hyprland takes the guard first. The
before/after name diff also ran outside any lock, so two concurrent creates could adopt each
other's output. Both now run under a create lock, with a stray sweep on the failure path.
* `select_and_cast`'s timeout arm dropped the portal thread's `stop` flag un-set — the same leak
Mutter was already fixed for. The guard is now built before the wait, in both copies.
* The xdpw chooser file was written per session and never removed, permanently shadowing the
config's fallback with the name of an already-unplugged output. Its lifetime is now the handshake,
not the session — scoped deliberately, because tying removal to the keepalive would let one
session delete another's selection hours later.
* Hyprland's headless outputs are now named `PF-<pid>-<n>` and reconciled at startup, so a crashed
host's leftovers are reclaimed while a live sibling host's outputs cannot be pulled out from under
it. `set_monitor_rule` no longer discards hyprctl's rejection text and then hard-codes a
GBM/dmabuf diagnosis it never verified.
* Both wlr backends silently dropped the `topology` policy axis: `Primary`/`Exclusive` was accepted,
echoed by the mgmt API, applied on three backends and a no-op on two. They now say so.
Item 8.1: `swaymsg`, `hyprctl` and the portal `systemctl --user try-restart` calls are bounded
through `proc` with named budgets.
|
||
|
|
ef72d102b6 |
fix(pf-vdisplay): KWin's re-enable reported success when it matched no outputs at all, leaving a physical monitor dark
* **`reenable_outputs` returned `true` when it matched NONE of the requested outputs.** Unresolvable outputs were `continue`d and the return was the apply verdict alone — but an empty `kde_output_configuration_v2` still gets an `applied` event. So a total no-op suppressed the `reenable_outputs_kscreen` backstop and the operator's physical monitor stayed dark. Now counts staged outputs and returns `ok && matched == outputs.len()`, and refuses to apply an empty configuration at all. * **The kscreen restore logged "restored the physical/bootstrap outputs" unconditionally**, with both call results discarded — including when `kscreen_ok` returned false on its 5 s budget, which is exactly the wedged state that fallback exists for. * **`Session::open` swallowed every failure reason** — connect error, barrier timeout, missing global — and three of four callers degraded to kscreen-doctor with zero log. This is the class that hid the KWin >= 6.7 registry regression: a shipped fallback firing silently on every machine. It now logs at warn with the reason and the caller's operation name. * `last_name` was seeded with a name kscreen-doctor can never resolve (KWin's address is `Virtual-punktfunk…`), so the intended default was guarded by an `is_none()` that could never hold and `apply_position` ran against no output. `our_uuid` was never reset per `create` and only assigned under `outcome.handled`, so a supersede positioned the *previous* output and never fell back. * `probe()`'s `roundtrip` was the only unbudgeted compositor wait in the crate — every sibling path is budgeted — and it is reached from an async mgmt handler. Now bounded at 3 s. The pre-`created` dispatch loops gained deadlines and now set `stop` on the timeout arm. * Every `wl_output` global was bound for the session's life with no `GlobalRemove` arm and no `release()`, on the virtual-output path too, which never reads them: unbounded growth on a hotplugging session. * `monitors::list` was the one KWin call site with no kscreen fallback at all, despite `list_monitors` failing on exactly the condition the other four fall back for. It has one now. * `CVT_H_GRANULARITY` and `MANAGED_PREFIX` existed as two literals under prose asserting they match; the second copy now imports the first. The wider facade extraction (item 9.1) is deliberately not in this commit, but its two prerequisites are — a comment at the restore seam records why they had to come first: a fallback arm that returns a value the helper never checked re-introduces the silent success, behind a seam whose selling point is one honest log per decline. Also corrects the `PhysicalMonitor` type doc, which claimed "logical geometry throughout" while `width`/`height` are the mode's PIXELS and `x`/`y` are logical, and adds the `logical_size()` helper that is the only correct way to compare an extent against a position. |
||
|
|
b2c03f1904 |
fix(pf-vdisplay): a managed launch blocked the shutdown restore that was meant to rescue it, and re-moding could flip the operator's own screen
The gamescope subsystem — the crate's largest and fastest-churning area, and the one the 2026-07-28
sweep predates most of.
* **`MANAGED_SESSION` was held across the ~90 s managed launch**, and the shutdown/idle restore
blocks on that same lock — *after* it has already stopped our unit. So the display-manager restore
never ran and the box was left with no session at all. `create_managed_session` now decides under
the guard and acts outside it, re-acquiring only to store the result; `do_restore_tv_session`
consumes the record in a short scope at the top. Same shape the SteamOS twin already used.
* **The physical-display guard was bypassed whenever no gamescope node happened to be published.**
`if physical_display_connected() { if let Some(node) = find_gamescope_node() { … } }` fell through
to `set-environment SCREEN_WIDTH/HEIGHT/CUSTOM_REFRESH_RATES` + `restart` when the node was
momentarily absent — gamescope restarting between titles, or built without PipeWire — flipping the
operator's own screen to the client's resolution and bouncing a DM-driven login session. The guard
now refuses instead of falling through, and the forced `SCREEN_*` values (which were never unset,
so every later session on the box inherited them) are tracked and `unset-environment`ed on restore.
* **`current_gamescope_output_size()` reported an arbitrary gamescope's `-W`/`-H`** — whichever
`/proc` enumerated first — and four consumers treated it as this session's output size. It now
answers only when every gamescope on the box agrees, and `None` ("cannot tell") when they differ.
`heads.rs` no longer takes it at all: it reads the size off the DRM-backed argv it already
selected. Its test previously passed `None`, which is why the hazard was invisible.
Resource and honesty fixes: the ATTACH path armed the box's own session-unit bind drop-in and no
in-process path ever removed it (now tracked and disarmed on both restore arms); `wait_for_node`
never called `try_wait`, so a gamescope that died at `vkCreateDevice` was polled for the full 15 s
and the error then blamed headless capture support; `do_restore_tv_session` deleted its crash-recovery
state *before* the unbounded work that state records, so a grace-period expiry in that window left
the DM down with nothing on disk to heal it; the SteamOS takeover's two failure arms never armed the
TV restore though the session-plus twin does; the TV-session restore logged success with the
`systemctl` status discarded; the `steam -shutdown` child was dropped un-reaped; and a managed
session that took nothing over was never persisted, so a host crash orphaned the transient unit.
Item 8.1: the unbounded `pw-dump`, `systemctl`, `loginctl` and `pkexec` calls in this subsystem now
go through `proc::{status_within, output_within}` with per-call budgets. `pw-dump` is polled from
three separate 45 s loops against the very daemon this file documents gamescope as head-blocking,
and until now a hang there pinned the session's stream thread forever.
|
||
|
|
db65980979 |
fix(pf-vdisplay): the ghost-monitor reap fed live devices to pnputil, and two unsafe fns had no unsafe in them
Windows half of the sweep — the reap bug, a panic that poisons two locks, and a round of unsafe reduction. * **The ghost reap selected the wrong devices.** It filtered `Status -ne 'OK'`, a HEALTH field: that matches devices that are PRESENT but in Error/Degraded/Unknown, not the ABSENT ones the reap is for — and it handed them to `pnputil /remove-device`, contradicting its own documented contract. It runs from `add_monitor`'s mid-session slot-exhaustion recovery, so the blast radius is a live session. Now filters on `-not $_.Present`. * **`ensure_pinger` still used the panicking `thread::spawn` while holding two locks**, poisoning both — the un-fixed twin of a fix that already landed for `ensure_exclusive_watch`. Same shape applied. Unsafe reduction, continuing the program that made pf-win-display's CCD helpers safe fns: * `resolve_target_gdi` and `reisolate_after_swap` were `unsafe fn`s containing zero unsafe operations, and the three call-site SAFETY proofs described FFI they no longer perform. Both are now safe fns and those blocks are gone. * `VdisplayDriver::open`'s `# Safety` section named no caller obligation — the same empty shape an earlier phase already removed from `open_device`. * `(*detail).DevicePath.as_ptr()` derived a pointer from a `[u16; 1]` field and handed it to `CreateFileW`, which reads the whole flexible-array path beyond it. Now taken with `&raw const` from the full struct, so the pointer carries the provenance of the bytes actually read — the same correction already made for `MONITORINFOEXW` in ddc.rs. Comment fixes, all verified against the code: three intra-doc links to a type this crate does not have; a doc-comment run merged so that `shrink_action` — the gate that keeps a `Primary` group's physical panels lit — read as undocumented while its rationale sat on an unrelated polling helper; and the backend module header, which documented itself against a `sudovda` module that does not exist and a fallback the crate says was removed. Adds the first tests for `knobs.rs`, `instance.rs` and `driver.rs` — including `is_privileged_sid`, the security-relevant predicate that decides whether an existing single-instance name is another host or a squat, which had no coverage on any platform. |
||
|
|
a1ff0dde0c |
fix(pf-vdisplay): the host promised HDR and cursor forwarding for gamescope sessions it did not start
`gamescope_ours_and` answered "did WE spawn this gamescope?" by reading `PUNKTFUNK_GAMESCOPE_NODE`. Phase 2.3 deleted the code that published that key — routing.rs's own doc says "Nothing is written back to the two knobs" — but this consumer was never migrated, so the read now returns "not attaching" for every attach. Both consumers then answer for a session this host has no flags on. On a plain box with a foreign gamescope already running, `pick_gamescope_mode` resolves Attach at its fifth rung while the env key stays unset, and the probe half only inspects the resolved BINARY, which is our patched build: * `gamescope_composites_cursor()` returns true, so the host attaches no XFixes reader and blends nothing — while the stock gamescope actually running was never given `--pipewire-composite-cursor`, so the stream carries no pointer at all. * `gamescope_hdr_available()` returns true, so the Welcome fixes `bit_depth` at 10 and the session negotiates BT.2020/PQ over an 8-bit SDR composite. The Welcome cannot take that back. The same two failures hit the `capture_monitor` mirror route on any Bazzite or SteamOS box, where the running Game Mode gamescope is by definition not one this host spawned. The question is now asked of the resolved route rather than the environment, via a pure `session_is_a_foreign_gamescope` that runs — and is tested — on every platform. The residual gap is named in the doc rather than papered over: `create_managed_session`'s create-time degrade to a foreign attach is still invisible to a ladder re-run. Also in this commit: * Two unguarded session-env reads now take `ENV_LOCK` (`detect()`'s `XDG_CURRENT_DESKTOP` fallback and `effective_topology()`'s legacy pins). `apply_session_env` `set_var`s those same keys from another thread, which is the glibc setenv/getenv race this crate's own lib.rs documents as UB. * `mirror.rs`'s `names_ours_conclusively` was a `matches!` whose omitted default was the UNSAFE direction — a new backend would silently get its own virtual displays mirrored. Now exhaustive, so adding a `Compositor` is a compile error at the one site where the answer is a safety decision. * `MirrorDisplay` overrides `poolable_now() -> false`; its `create` always reports `External`, so the trait's `true` default was a pre-create claim contradicting the post-create fact. The trait doc now says plainly that the default is a default and not a fact. * The crate front-door doc listed 3 of 7 backends and quoted line counts half the size of the current crate; `routing.rs`'s summary was attached to the wrong item and described a published env channel that no longer exists; `available()` is no longer documented as cheap when it forks `gamescope --version` and does an unbudgeted Wayland roundtrip per call. |
||
|
|
9d58f4c170 |
fix(pf-vdisplay): one unreadable byte reverted the host to built-in display defaults, and one bad preset dropped the whole catalog
The policy layer folded every failure into "unconfigured", then wrote that emptiness back. * **Any parse error reverted the WHOLE policy.** An unknown enum variant, a mistyped scalar, an EACCES or EIO — all became `Err(_) => None`, i.e. the host silently ran on built-in defaults with the operator's `display-settings.json` still sitting on disk. Parsing is now layered: strict first, then per-axis salvage so one unreadable axis costs only that axis, and only `NotFound` is quiet — EACCES/EIO warn loudly that the host is on defaults. `version` is read instead of being blindly rewritten to 1. * **One malformed entry dropped the entire custom-preset catalog**, and the next CRUD atomically renamed the empty vector over the file. Entries are parsed one at a time now; a lossy load is flagged and refuses to overwrite. * `sanitized()` clamped `max_displays` but never `KeepAlive::Duration.seconds`, so a PUT could pin a display for ~136 years — a deadline the reaper never reaches and a nonsense `expires_in_ms` in `/display/state`. Clamped to a day, in both `sanitized()` and `sanitize_preset_fields`, and sanitization now runs on LOAD as well as on write. * The two stores' temp files had fixed names and no write lock, so concurrent saves could interleave serialize -> rename -> in-memory update. Unique suffixes, a lock, and the in-memory update ordered after the rename. * `new_preset_id` never consulted the loaded entries for collisions. * **Manual layout could place an unpinned display exactly on top of a pinned one**: the fallback was the unconditional auto-row prefix sum, blind to where prior members were pinned. Unpinned members now pack clear of the pins. Layout keys are canonicalized and unusable ones dropped at write time rather than persisted-and-ignored. Adds 20 tests, all running on macOS: a 20k-round randomized property test asserting no unpinned member ever overlaps a sibling (verified to fail against the pre-fix `arrange_manual`), the salvage and quarantine paths, the clamps, and a field-count guard that fails the moment a 13th policy axis appears without being wired into the merge path. Note: `partial_json_fills_defaults` was renamed to `serde_defaults_fill_a_partial_document` with no assertion weakened — it pins the FILE contract (an old settings file must still load), which is not the mgmt PUT contract that sweep item 11.1 is about. |
||
|
|
61ff543acc |
fix(pf-vdisplay): a new client could be handed a streaming client's display, and a blind /proc scan tore every backend down
Five defects in the registry/identity half, plus the restructure that finally makes them testable.
* **A new client could be assigned a LIVE client's identity slot.** `DisplayIdentityMap::resolve`
LRU-evicted purely on its `seen` stamp, with no knowledge of which ids are streaming. On Windows
that id keys the manager's slot map, so the newcomer took the plain-JOIN branch and inherited the
other client's monitor, capture target and stop flag. `resolve` now takes the live set, never
evicts a live id, and REFUSES rather than hand one over — degrading to the shared/auto identity.
* **A transient `ActiveKind::None` invalidated every backend entry, including live streaming ones.**
A `read_dir("/proc")` that happened to fail satisfied the change test and bumped the session
epoch. A `None` observation is no longer evidence a desktop went away, and no longer overwrites
the baseline (which would have bumped the epoch on the next poll anyway).
* **The Linux pool had no display ceiling at all** — `max_displays` was enforced only on Windows,
while the pool keys on the CLIENT-SUPPLIED mode, so each distinct requested resolution minted a
new display. Now capped in `linux::acquire`, gated on `poolable_now` so a gamescope attach or
managed session (which consumes no pool slot) is not refused.
* **Two different definitions of "display group"** — `group_key` and a bare backend-name compare —
and only one separated gamescope spawns. Unified as `pool::in_group`. The `position_for_new`
collection also lacked the supersede exclusion the topology check 70 lines earlier had, so a
mid-stream resize auto-rowed the replacement past its own dying predecessor, walking the display
one width to the right on every mode switch.
* **Lifecycle events were wrong in both directions**: `Created` fired on keep-alive reuse, and
`Released` fired only from the mgmt endpoint — never from a lease drop, the linger reaper,
`mark_failed`, `retire` or `invalidate_backend`. All six now emit.
Also: `Release::Noop` no longer runs a full teardown (the one outcome the state machine defines as
"do nothing"); a failed linger-reaper spawn logs and retries instead of consuming its `Once` and
never tearing a kept display down again; group ids are a monotonic per-key counter instead of an
index into the currently-live sorted set, so an unrelated group appearing no longer renumbers a
display; and a corrupt `display-identity.json` is renamed to `.bad` with a warning rather than
silently overwritten, which used to reset every client's stable id and its saved DPI.
The pure half of the pool (`Entry`, `group_key`, `epoch_matches`, `take_expired`, `at_display_budget`,
`position_for_new`, `assign_group_ids`, `assemble_displays`) is now a non-cfg'd `mod pool`, so the
registry's decisions are exercised on every platform's CI instead of only on a Linux box. Crate test
count 53 -> 94.
|
||
|
|
dd9bbaf1c5 |
fix(pf-vdisplay): a helper that outran the pipe buffer had its output thrown away as a timeout
`output_within` read stdout/stderr only after the child exited, and its doc justified that with "these helpers emit at most a few hundred KiB, well under any real pipe pressure". A pipe holds 64 KiB. Anything past that blocks the helper in `write()`, so it never exits, the budget kills it, and a successful query is reported to the caller as `TimedOut` with its answer discarded. The busiest caller is the one that trips it: `pw-dump` on a populated PipeWire graph clears 64 KiB routinely and is polled from the 45 s gamescope loops. Confirmed empirically — a child writing 1 MiB into an undrained pipe never exits. Both pipes are now drained on their own threads, concurrently with the wait. That makes the joins load-bearing, which exposed the second half: the Unix `tree::Guard` was an empty stub whose doc claimed `Child::kill` "already ends the only process there is". It never did for this crate's Linux helpers — `pkexec`, `systemd-run`, `systemctl --user` and the `sh -c` wrappers all fork — and a surviving grandchild holds the pipes' write ends, so a reader would wait for an EOF that never arrives. The child is now the leader of its own process group and the guard `killpg`s it, which is the Unix shape of the Job object the Windows half already used. Also gates `pf-frame`, `pf-gpu` and `pf-encode` to Windows: every use site of all three is `cfg(windows)`, and between them they dragged FFmpeg, ash and openh264 into the Linux build for nothing (sweep item 13.19). |