`packaging/windows/drivers/*` has run `deny(unsafe_op_in_unsafe_fn)` +
`deny(clippy::undocumented_unsafe_blocks)` for a while, with `forbid(unsafe_code)`
on the modules that need no unsafe at all. The main workspace had no lint config
whatsoever, so nothing stopped a clean crate from quietly growing an `unsafe`, and
nothing distinguished the handful of genuinely-unsafe lines inside a 600-line
`unsafe fn` from the safe ones surrounding them.
Three things, all mechanical:
* `#![forbid(unsafe_code)]` on the eight crates that already contain zero unsafe
(`pf-driver-proto`, `pf-host-config`, `pf-paths`, the three clean clients, both
tools). These were clean by accident, not by contract; now they are clean by
contract.
* `unsafe_op_in_unsafe_fn = "warn"` workspace-wide. `unsafe fn` states a contract
the CALLER must uphold — it was never meant to switch off checking for the whole
body. Measured fallout is 300 sites on Linux, and they are concentrated: six
files carry all of them, while `punktfunk-core`, `pf-frame`, `pf-clipboard` and
`pf-vdisplay` are already at zero. `warn` (not `deny`) so the build stays green
while those six are worked down; it flips to `deny` once they are. This is also
the Rust 2024 default, so it pays off the edition migration early.
* `proc::current_uid()` replaces eight `unsafe { libc::getuid() }` blocks. Each
site had copied out the same SAFETY note verbatim, which is the tell: `getuid()`
is parameterless, always succeeds and touches no memory, so there is no contract
for a caller to uphold and no reason for the unsafe to be visible eight times.
One `unsafe` behind a safe wrapper, none at the call sites.
Verified: `pf-vdisplay` builds clean on Linux (Nobara) at zero E0133; the
macOS-buildable crates build clean locally. No behaviour change.
The startup speed-test probe deliberately overloads the link (2 Gb/s for
800 ms) to measure capacity. The report tick is suppressed while it runs
and the window anchors are rebased when it ends — but two residues leak
past the rebase: probe frames still pending in the reassembler age out as
frames_dropped for another ~120 ms (the loss-window fuse), and the burst
can latch flush_in_window (a jump-to-live it caused itself). Either one
reads as a SEVERE window: the controller backs off ×0.7 and — worse —
slow start ends at the first congestion signal, permanently.
That is precisely the 2026-07 field report's Automatic session: 20→14
Mb/s exactly one report tick after the probe, then a purely additive
+6 %/4.5 s crawl that took the whole 4.5-minute match to reach 308 Mb/s
on a link the probe had just measured at ~1.2 Gb/s. Slow start (doubling
per clean window to the probed ceiling, 'seconds instead of minutes')
never got to run.
The first post-probe report window is now discarded outright: no
LossReport (probe residue would also spike the host's adaptive FEC), the
standing-latency detector closes it as not-loss-free (its clean-run just
resets), and the ABR is fed nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-decode FrameChannel is strictly FIFO because H.26x reference
chains forbid mid-stream drops — falling behind is only recoverable via
the coarse jump-to-live thresholds (depth ≥6 sustained, or 400 ms behind
the capture clock) at the cost of a keyframe round-trip.
PyroWave has no reference chains: every AU decodes independently, so a
slow consumer can skip straight to the newest queued AU with zero
recovery cost. The pump now flags the channel all-intra for PyroWave
sessions and pop() drains to the newest, which caps any standing
pre-decode queue at ~1 frame structurally — the 2026-07 field report's
780M client could otherwise ratchet a real multi-frame backlog (and with
it the OSD latency) between those coarse thresholds.
Skips are counted separately from losses (they were delivered, just
superseded) and surfaced at debug on the report tick — the OSD 'lost'
line keeps meaning wire loss.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mid-stream clock re-sync starved on high-bitrate LAN sessions (2026-07
PyroWave-sawtooth field report, RX 9070 XT -> 780M @ 550 Mb/s): every batch
was judged against the CONNECT-TIME RTT, measured before the video data
plane existed, with a 2 ms floor — mid-stream control RTTs on a loaded GbE
link sit above that almost permanently, so batches were rejected for
minutes while the wall clocks drifted apart and the OSD e2e figure ramped
19->150 ms before snapping back on a lucky batch.
Three changes:
- Rounds are spaced 7 ms apart (stamped at send time, so the spacing never
lands in the RTT). An 8-round batch used to complete inside ONE ~6 ms
video burst — all rounds sampled the same congestion state; spacing walks
them across the frame cycle so the min-RTT round finds a quiet gap.
- ResyncGuard replaces the static baseline: the guard band follows the best
RTT the session has evidenced (connect RTT, then min over every completed
batch — rejected ones included).
- Rejection streaks are bounded: after 3 consecutive rejections the best
(min-RTT) batch of the streak is applied anyway. Its queueing bias is at
most ~half its RTT; unbounded wall-clock drift costs more per minute.
Rejections now log at warn with the streak and floor — the starvation
signature is grep-able in exactly the logs users send.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`resetCursorRects` wore the host shape only when `hostCursors[st.serial]` hit,
and fell through to `invisibleCursor` on a miss. But a miss is the ROUTINE case,
not a degenerate one, and it is not a reason to hide the pointer:
* State (0xD0) is a per-frame datagram and announces the new serial the moment
the host QUEUES the bitmap on the reliable control stream, so on every single
shape change the client knows a serial before it holds the pixels.
* The shape ring drops the NEWEST under burst (CURSOR_SHAPE_QUEUE = 8) and the
host never re-sends it — it only sends on a serial CHANGE — so a dropped
serial stays un-backed until the pointer next changes shape. Crossing a
toolbar flips arrow/I-beam/hand/resize several times a second, and each flip
mints a fresh serial and a fresh bitmap, so bursts are ordinary.
Either way the pointer BLINKED OUT rather than lagging, and in the dropped case
it stayed gone for as long as the pointer held that shape — reported on glass as
"the I-beam never appears over text fields, every other cursor is fine".
Hold the last worn shape across the gap: only `st.visible == false` (the host
says the pointer is hidden) may hide it now, never a missing bitmap. Worst case
is a briefly stale pointer. This also makes two comments that already claimed
this behaviour true — the shape-rejected warning's "keeping the previous cursor"
and CURSOR_SHAPE_QUEUE's healing claim, both of which were fiction.
Host side is exonerated: on .221 the poller sees the I-beam handle (0x10005,
CURSOR_SHOWING set), and the monochrome AND-over-XOR conversion renders a correct
glyph — black beam, white outline — so the bitmap that goes on the wire is good.
swift build PunktfunkKit + cargo check punktfunk-core green; on-glass owed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`c_char` is i8 on x86_64 and u8 on aarch64, so `cert_pem_out as *mut u8`
is a REQUIRED conversion on one target and a no-op on the other — where
clippy::unnecessary_cast then denies it. `.cast::<u8>()` is correct and
lint-clean on both.
Caught by the new aarch64 clippy leg on its first run. A sweep of the
remaining `as *mut u8` / `as *const u8` sites in the client crates found no
other c_char-derived casts; the rest convert from c_void or u16.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two client-side halves of the §ABR-overdrive fix (the 4K120 sessions that
climbed to 1 Gbps against a 794 Mbps encoder and a 8.33 ms budget):
- Short-ack cap learning: the host now resolves a climb it can't serve to what
the encoder actually runs at (its codec-level ceiling, or the current rate
while encode is behind cadence). Two consecutive identical short acks latch
that value as a host cap the climb logic folds into its ceiling — one short
ack stays a transient (a failed rebuild also acks short once). The cap is
mode-scoped (cleared on an accepted mode switch, tracked via a mode
generation counter the control task bumps) and re-probes one step after ~60 s
parked clean, so a heavy-scene refusal can't quietly cap the whole session.
- Host-encode-latency down-driver: the per-AU 0xCF `encode_us` the host
already ships (and the overlay already draws) now feeds the controller
through its own window accumulator — the overlay channel is lossy and
embedder-drained, so the ABR gets a dedicated mirror of the decode-latency
path. Baseline-relative like the decode signal (an escalated host reports
encode_us inflated by ~a frame of queue depth; an absolute budget threshold
would read permanently red), with the baseline rebased after our own
decreases so one backoff doesn't train-fire into the floor. This is the only
signal that can push an already-too-high rate back under the encoder's
compute knee — host climb refusal stops the climb, but nothing else descends
on a clean LAN.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`punktfunk-core` emitted `unused import: super::super::*` for
client/pump/handshake.rs and client/pump/input_task.rs — but only when
built for a tvOS target, where the cfg set leaves the glob contributing
nothing. Nobody saw them because CI never builds tvOS: that needs
BUILD_TVOS=1 and the Tier-3 build-std nightly path, and the Apple
workflow only runs swift build/test (macOS).
Both files also `use super::*`, i.e. the parent `pump` module, which
itself globs `super::*` (`client`) unconditionally — so every name stays
reachable by that path and the inner glob was pure duplication. Their
sibling datagram_task.rs already imports only `use super::*`, so this
also makes the module consistent. control_task.rs and data.rs keep
theirs: those genuinely resolve names through it and never warned.
Verified warning-free AND compiling on every slice the xcframework
ships — aarch64/x86_64-apple-darwin, aarch64-apple-ios,
aarch64-apple-ios-sim, aarch64-apple-tvos, aarch64-apple-tvos-sim,
x86_64-apple-tvos — plus Linux with and without the `quic` feature. A
full xcframework rebuild now reports 0 warnings (was 6: two per tvOS
slice), and swift build, swift test (170 tests), and xcodebuild for the
macOS, iOS and tvOS schemes all pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native sessions could survive long after the client was gone, in two
independent ways.
HOST: `stop` was only advisory. The one thing that ends a session is the
stream thread returning — every teardown (conn.close, the joins, and the
RAII drops of the session permit, admission entry and stream marker)
sits after that await, and nothing forced the issue. The encode loop
checks `stop` between iterations, so any unbounded call INSIDE one never
reaches the check, and one stuck syscall became a permanent zombie: it
held its semaphore slot (four of those and the host stops accepting
QUIC entirely), its admission entry (a later client gets "host busy"
forever), and not even the console's Stop button could clear it — that
button sets this same flag.
* Bound the wait: once the session has been told to stop, the thread
gets STREAM_STOP_GRACE (90 s, well past the 40 s capture-rebuild
budget) to return, then teardown runs anyway. The thread is detached,
not killed — Rust can't cancel a blocking thread — so it keeps its
capturer/encoder until the stuck call returns, but the session's slot
and admission entry come back and the host keeps serving. It logs at
ERROR as the host wedge it is.
* Bound the audio/input joins too — the last unbounded await in
teardown.
* Take the session permit AFTER the QUIC handshake instead of before
`accept()`, so a host at its concurrency cap still accepts and the
waiting client sees a live path instead of a silent dial timeout.
* Bound the compositor helpers that caused the wedge in the first
place: new pf-vdisplay `proc::{status_within, output_within}` kill a
child that outlives its budget. `kscreen-doctor` is a Wayland client
of the very compositor it configures, so against a wedged KWin it
never returned; same for systemctl/dbus against a stuck session bus.
CLIENTS: the connection was never closed, so the host was right to keep
the session — it still had a live, keep-alive-answering peer.
* Android: backgrounding did no teardown at all, and Android doesn't
suspend the process, so the worker kept answering keep-alives until
the OS reclaimed it (on a TV box, never). End the session on ON_STOP,
via the existing onDispose path; a plain close, not a quit, so the
host lingers the display for a fast return.
* Apple: the .background arm was iOS-only AND gated on an opt-in that
defaults off, so backgrounding did nothing — while the `audio`
background mode kept the app (and its connection) alive indefinitely.
Act unconditionally, and cover tvOS.
* Core: `conn.close()` only queues the frame, and run_pump is the body
of a block_on whose runtime is dropped the instant it returns, so the
driver could never put it on the wire — a deliberate quit reached the
host as silence (8 s idle timeout, no quit code, and the linger meant
for an unwanted disconnect). Carry the endpoint out of the handshake
and flush with wait_idle(), the same discipline the pairing and probe
paths already use.
Linux check/clippy/tests green: 262 host, 71 pf-vdisplay (incl. new
bounded-process tests), 231 core.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pen P0 work added a new embeddable C entry point (punktfunk_connection_send_pen)
but left ABI_VERSION at 12 — the value 0.18.0 shipped. Per the project's convention
(0.18.0 bumped to 12 for the additive cursor channel), additive C-surface growth bumps
the ABI so embedders can gate on the new symbol. Regenerated include/punktfunk_core.h
via cbindgen (cargo build -p punktfunk-core); WIRE_VERSION is unchanged (pen is
capability-gated on HOST_CAP_PEN).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Linux injection leg of design/pen-tablet-input.md: 0xCC/0x05 pen batches now
route through the per-session PenTracker into a lazily-created 'Punktfunk Pen'
uinput tablet (BTN_TOOL_PEN/RUBBER, pressure, tilt-from-polar, ABS_Z barrel
roll, hover distance, INPUT_PROP_DIRECT) — compositors pick it up via libinput
and hand apps zwp_tablet_v2 with full fidelity. The host now advertises
HOST_CAP_PEN when /dev/uinput is accessible (PUNKTFUNK_PEN=0 kill-switch);
transitions group into SYN frames so proximity-enter carries its position.
Stroke failsafe: clients heartbeat ≤100ms while in range (documented wire
contract — capture APIs are silent for a stationary pen); 200ms of silence
force-releases. 'punktfunk-host pen-test' draws a pressure-ramped sine stroke
through the real tracker→uinput chain, no client needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pen plane from design/pen-tablet-input.md, protocol side only (the P1 uinput
tablet injector will consume it): 0xCC kind 0x05 carries batches of state-full
PenSamples (pressure, polar tilt + azimuth, barrel roll, hover distance, eraser
tool, barrel buttons); PenTracker diffs samples into injector transitions so a
lost datagram self-heals; HOST_CAP_PEN (0x10) gates sending and stays
unadvertised until a real backend exists. C ABI: PunktfunkPenSample +
punktfunk_connection_send_pen, documented in docs/embedding-the-c-abi.md §8.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cursor channel excluded the host pointer from the video for the
whole session, but the client only draws it under the DESKTOP mouse
model — flipping ⌃⌥⇧M to capture mid-stream left the session
pointer-blind (user-found). The fix makes render ownership LIVE state:
- wire: CursorRenderMode 0x51 (client→host, control stream) —
client_draws: true = desktop model (host excludes + forwards),
false = capture model / released (host composites, full fidelity —
DWM on Windows incl. real XOR inversion, encoder blend on Linux).
Sessions start client_draws=true (the pre-message behavior).
- host: control task stores the flag; the encode loop edge-detects it —
forwarding + frame.cursor strip while the client draws, quiet
forwarder + overlay-into-blend while composited. SessionPlan now
grants blend CAPABILITY wherever the capture has a pointer (dropping
the !cursor_forward gate) so the composite side needs no encoder
rebuild; per-tick frame.cursor decides what is drawn.
- Windows: Capturer::set_cursor_forward → retained
IOCTL_SET_CURSOR_FORWARD sender (proto v6) → driver (un)declares the
IddCx hardware cursor on the LIVE monitor. Un-declare re-issues the
setup with EMPTY caps (candidate mechanism — the DDI has no
documented un-setup); resetup-on-commit is skipped while off, so a
mode commit's software-cursor default is the fallback path. Failure
against a pre-v6 driver logs and keeps declared-at-ADD behavior.
- SDL client: one edge-detected reconciler at the cursor pump site —
desktop-active = client draws; capture model or released = host
composites. Covers the chord, the M3 auto-flip, and engage/release.
- C ABI v12: punktfunk_connection_set_cursor_render (additive; wire
version unchanged — pre-§8 hosts ignore the unknown message type).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Mac client now exercises the full cursor channel against capable
hosts: desktop-mode sessions advertise CLIENT_CAP_CURSOR, the host
stops compositing the pointer, and StreamView draws the forwarded
shapes as the real NSCursor — plus the M3 host-driven model flip.
- ABI v11: punktfunk_connect_ex9 (adds client_caps; ex7/ex8 pass 0 —
Android untouched), PunktfunkCursorShape/State + the two
next_cursor_* poll fns (audio-style borrow contract), and
PUNKTFUNK_CLIENT_CAP_CURSOR. Fixed in passing: the first insertion
split next_host_timing's cfg/no_mangle attributes off the fn, which
silently dropped the symbol from the header AND dylib — caught by
the Swift build, reattached with its docs.
- PunktfunkConnection: clientCaps connect param, hostSupportsCursor,
CursorShapeEvent/CursorStateEvent + nextCursorShape/nextCursorState
(one cursor thread drains both planes; cursorLock joins the close()
ladder).
- SessionModel: desktop-mode sessions (DefaultsKey.mouseMode) connect
with the cursor cap on macOS.
- StreamView: shape cache (serial → NSCursor, straight-alpha RGBA via
CGImage), resetCursorRects wears the HOST shape while the desktop
model is engaged (hidden host pointer ⇒ invisible), latest-wins
state pump, and the M3 auto-flip — edge-triggered on relative_hint,
⌃⌥⇧M sets an override latch cleared by the next host edge, leaving
relative warps the pointer to the host position via the inverse
letterbox mapping (CGWarpMouseCursorPosition, CursorCapture's
coordinate convention).
Verified: swift build + full test suites green (xcframework rebuilt at
ABI v11, signed); core 218 tests + clippy -D warnings on Linux (.21).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host cursor stops riding the video and becomes a real OS cursor on
the client (the Parsec/RDP model): pointer feel no longer pays the
capture→encode→network→decode→present round trip.
Wire (M2a):
- Hello grows a client_caps trailing byte (CLIENT_CAP_CURSOR) after the
fixed display_hdr block — presence disambiguated by remaining length,
which caps the post-HDR tail at 27 bytes (documented); Welcome answers
HOST_CAP_CURSOR (capable-and-asked, the 444/clipboard precedent).
- CursorShape (0x50, control stream): serial + dims + hotspot + straight
RGBA, ≤120px/side so the u16 frame always fits (128² would overshoot);
client caches by serial — re-showing a known shape costs 14 bytes, not
a bitmap (RDP pointer-cache for free).
- CursorState (0xD0 datagram): serial + visible/relative_hint flags +
position, sent once per encode-loop tick — latest-wins, self-healing
under loss, no refresh timer. relative_hint is reserved for M3.
- Client core: two new planes (control-task + datagram-task arms) →
next_cursor_shape/next_cursor_state; connect() grows client_caps
(C ABI passes 0 until the v11 cursor poll fns exist).
Host (M2b, Linux portal only):
- handshake::cursor_forward is THE predicate (client asked ∧ Linux ∧
compositor ≠ gamescope) — Welcome bit and session wiring both read it.
- SessionPlan.cursor_blend goes false for a forwarding session; the
encode loop ticks a CursorForwarder every iteration: shape-serial diff
→ control-task bridge (mirrors probe_result), state datagram → conn.
- CursorOverlay/capture CursorState carry the hotspot through
(nearest-neighbor downscale backstop for XL cursors, unit-tested).
Presenter:
- CursorChannel drains both planes per loop iteration; shapes become
SDL color cursors (from_surface + hotspot), applied while the desktop
mouse model is engaged; visibility follows the host; capture/released
hands back the system cursor. Sessions advertise the cap when they
START in desktop mode.
Verified on .21: fmt + clippy -D warnings (7 crates) + tests green
(core 218 incl. new wire roundtrips, host 245 incl. e2e + forwarder
downscale tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The VK key-event vocabulary cannot express text an input method COMMITS
(autocorrect, gesture typing, non-Latin scripts, emoji). Add a first-class
text event and negotiate it:
- punktfunk-core: InputKind::TextInput (= 15) carries one Unicode scalar per
event in `code`; HOST_CAP_TEXT_INPUT (0x04) in Welcome::host_caps.
- Host advertises the cap only where the session's inject backend can type
text: Windows SendInput (KEYEVENTF_UNICODE, surrogate-pair aware) and the
Linux wlroots backend — a dedicated second zwp_virtual_keyboard whose xkb
keymap grows Unicode keysyms on demand (the wtype model), so keymap
re-uploads never disturb the main device's layout/modifier state. The
KWin-fake-input/libei/gamescope backends can only press layout keycodes, so
those sessions don't set the bit and clients keep the VK fallback.
- GameStream plane: Moonlight's UTF-8 text packet (MAGIC_UTF8, previously
recognized-and-dropped) now decodes to the same TextInput events.
- Android: KeyCaptureView picks a real editable InputConnection when the host
has the cap — the IME runs its full machinery, mirrored to the host live via
common-prefix diffs of the composition (backspaces + new suffix), with
setComposingRegion adopting committed text so autocorrect-revert flows diff
instead of retyping; newline→Enter, deleteSurroundingText→Backspace/Delete.
Older hosts keep the TYPE_NULL raw-key path unchanged.
- keymap: media VKs (0xB0-0xB3) → evdev so the Android media keys land on
Linux hosts too.
Verified: punktfunk-core + host gamestream + pf-inject tests green on Linux
(Ubuntu box), clippy clean; Android app+native builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- scripts/steamdeck/install.sh (§2b) + update.sh: build and install the
scripting runner user-scoped (~/.local wrapper + pinned bun + bundle) —
SteamOS's read-only /usr can't take the .deb layout, so the console's
plugin store reported "the plugin runner isn't installed" on every
SteamOS host. update.sh retrofits it onto existing installs.
- plugins.rs runner discovery: check the ~/.local layout after the /usr
ones; mention the SteamOS path in the not-installed error.
- update.sh: define warn() — it was used but never defined, so under
`set -e` the first warn aborted the update before services restarted.
- pf-client-core gamepad: a Steam Input virtual pad forwarded on a real
Deck (the only-pad case) now declares the DECK kind in its per-pad
arrival instead of the wrapper's Xbox 360 identity — the session-level
auto_pref already resolved SteamDeck, but the arrival overrides it on
current hosts.
- reject tests: cover SetupFailed; move the foreign-code probe off 0x68.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three fixes for the "most connects simply fail" trap on a SteamOS host
with no physical display (VM testbox, panel-less mini PC):
- restore path: skip the restore-to-physical-panel when no DRM connector
reports a connected display — removing the headless drop-in and
restarting gamescope-session.target on such a box just crash-loops
gamescope and strands every later connect on "no usable compositor".
Keep the headless session (and the takeover state) instead.
- connect path: when no live graphical session exists but the MANAGED
gamescope infra is present (SteamOS gamescope-session / Bazzite
session-plus), route to the gamescope takeover — which rebuilds the
session at the client's mode — instead of failing the connect.
- error delivery: a session-setup failure used to just drop the QUIC
connection, reaching the client as "control stream finished mid-frame"
(indistinguishable from transport trouble). Close with a typed
setup-failed code (0x68, RejectedSetupFailed = -29) carrying the error
text, and give the client handshake/pairing error paths a short grace
for the CONNECTION_CLOSE to beat the stream error it caused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lifts the ~100 Mbps decrypt ceiling on clients without hardware AES — the
armv7 soft-AES targets (webOS TVs), where AES-128-GCM resolves to fixsliced
software AES + software GHASH (~50-100 cpb) while ChaCha20-Poly1305's ARX
construction runs ~10-17 cpb portable, a 4-7x lift that PyroWave-on-TV needs
(design/chacha20-session-cipher.md).
Phase 1 (core crypto, no wire change): SessionKey merges cipher choice and
key material (invalid combinations unrepresentable, zeroize + redacted-Debug
discipline kept); SessionCrypto dispatches both aead-0.5 ciphers per call —
the salt||seq nonce scheme, per-direction salts, seq-as-AAD and replay
window carry over verbatim (same 96-bit nonce / 16-byte tag, const-asserted).
Config.key becomes SessionKey; validate's zero-key rejection follows the
active variant. The C ABI keeps its fixed 16-byte key mapped to AES — no
ABI_VERSION bump.
Phase 2 (negotiation): VIDEO_CAP_CHACHA20 (0x40) — support-plus-request in
one bit, the VIDEO_CAP_444 precedent. Welcome grows cipher@68 +
key_chacha@69..101, emitted only when non-zero so an AES session's Welcome
stays byte-identical to the pre-cipher form; decode is fail-closed (short
key or unknown id -> Err, never a silent AES fallback). No WIRE_VERSION
bump; downgrade resistance inherited from the pinned-TLS control channel.
Phase 3 (host): grant only when the client advertised the bit and the
PUNKTFUNK_CHACHA20 kill-switch (default on, documented) allows; fresh
32-byte per-session key from the same RNG discipline, legacy key field
stays independently random; resolved cipher logged at session start.
Verification: seal/open suites parameterized over both ciphers + a
cross-cipher tamper case; Welcome roundtrip/truncation/fail-closed tests;
ChaCha lossy-loopback soak (loss/replay is cipher-independent); bench
gains _chacha20 series (AES ids unchanged for CI history) — host-side
sealing line-rate-trivial on both x86 (~640 MiB/s) and Apple Silicon
(~535 MiB/s). punktfunk-probe drives the interop matrix via
PUNKTFUNK_CLIENT_CHACHA20=1 and logs the negotiated cipher.
Phase 4 (pf-webos pin bump + unconditional cap bit) follows the next
core release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Launching from the Decky plugin against a sleeping host fired the
Wake-on-LAN packet and dialed immediately — but the dial was a single
quinn attempt that gave up after the transport's 8 s idle window, far
shorter than a suspend-to-RAM resume. The host woke to find the client
already gone (its retransmitted Initials show up host-side as
"QUIC accept failed error=timed out"), the stream shortcut exited, and
the launch looked cancelled.
- punktfunk-core: dial in a loop until the embedder's connect budget is
spent — short 3 s attempts keep the Initial cadence dense, so the
connect lands within ~a second of the host's network returning. Only
silence is retried: pin mismatches, typed rejections, and any real
answer surface immediately, and the shutdown flag stops the loop.
- decky: stretch the budget to 75 s when a magic packet actually went
out (PF_CONNECT_TIMEOUT → --connect-timeout via the wrapper), so the
retry window covers the resume; an awake host still connects in <1 s.
- host: a clean close before the control handshake is a reachability
probe (hosts-page online pips), not a failed session — log it at
debug instead of WARN "session ended with error", which buried the
real failures in the reporter's log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review verdict: no exploitable memory-safety / amplification / splice /
resurrection issue; nonce order and two-lane seal byte-identical for the
legacy path. Actionable findings applied:
- explicit per-block data_shards cross-check next to the recovery_shards
one (defense-in-depth — the geometry invariants make it unreachable
today, but have_data indexing assumes it)
- partial delivery skips an UNPINNED streamed frame (frame_bytes still 0)
instead of truncating its max-sized buffer to an empty "partial"
- regression tests for the two untested load-bearing guards: the
final-first out-of-range-sentinel drop (the exact-sized-buffer guard)
and second-final-with-different-totals rejection
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wire half of sub-frame slice output (latency §7 LN1, planning
design/nvenc-subframe-slice-output.md Phase 2): toward a client that
advertises the new cap bit (0x20), a chunked-poll encoder session ships each
AU's completed FEC blocks while the tail of the frame is still encoding —
the AU's last packet leaves the host as the encode finishes instead of
after it.
Wire semantics (negotiated; zero change for anyone else):
- Non-final blocks ride SENTINEL headers: block_count = 0 (a value no legacy
sender emits) + frame_bytes = 0 + exactly max_data_per_block data shards,
so the receiver's shard-offset formula needs no total.
- The final block's headers carry the real frame_bytes/block_count (+
FLAG_EOF) and RETRO-VALIDATE the whole frame: totals under which a
received sentinel block is out of range or not full-K kill the frame
wholesale (no spliced delivery) and the index can't be resurrected.
- Firewall: sentinels are bounded by the negotiated limits (full-K exactly,
never the last block the limits allow, no total to lie about); the exact
derived-geometry check runs unchanged on every non-sentinel packet and
retroactively at pinning. Sentinel opens commit a max_frame_bytes buffer,
bounded by the existing IN_FLIGHT_BUF_FACTOR budget (amplification test).
- Order-agnostic like legacy: a reversed frame (final block first) opens
legacy-shaped and still accepts its sentinels against the pinned totals.
- Small/empty streamed AUs degenerate to byte-identical legacy headers.
Host: Packetizer::{begin,push,finish}_streamed seal full-K blocks (data +
parity per block) as chunks arrive; Session::seal_streamed_* share the
pooled-wire + two-lane seal machinery via the new seal_run; the send thread
paces each flush under the frame's existing deadline (pace_sealed split out
of paced_submit) and runs the whole-AU accounting at the last chunk; the
encode pump forwards poll_chunk output as ChunkMsg when the client has the
cap AND the encoder chunks (re-queried per AU — an escalation falls back
seamlessly). Probes never run mid-AU. PUNKTFUNK_STREAMED_AU=0 = host escape
hatch. Client core ORs the cap into Hello (the shared reassembler carries
the support). Sampled first_slice_us vs encode_us PERF log measures the
overlap; the 0xCF stage-field extension stays a follow-up.
Core tests: streamed round-trip (clean/loss/reorder/duplicate, both orders),
sentinel firewall bounds, lying-final wholesale kill + no-resurrect,
open-amplification budget, header-shape pins. Gates still owed before
default-on: security review pass, loss-harness curve, GameStream smoke
(plane untouched structurally), bitrate A/B.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second and final batch from the 2026-07-20 core-sweep lows:
client:
- connect() timeout now sets `quit` before shutdown, so a handshake that
completes after the deadline closes with QUIT_CLOSE_CODE instead of
leaving the host lingering (virtual display up) for a reconnect that
never comes.
- probe_result: saturating_add on the wire-supplied wire_packets +
send_dropped counters (debug-build overflow panic / release wrap).
- the standing-latency bleed no longer sets flush_in_window: that flag
is the ABR's SEVERE (×0.7) verdict, and the bleed fires only on
provably loss-free windows the controller itself scores as fine.
clipboard:
- fetch_cancels pruned on every new fetch (was: one dead oneshot per
paste for the session).
- serve chunks gated on a parked waiter + capped at CLIP_FETCH_CAP with
an Error event (was: unbounded silent accumulation under any req_id).
- serve_inbound park bounded by FETCH_STALL_SECS + send.stopped() (was:
an unanswered FetchRequest parked the task, waiter, and bi-stream
forever; ~100 of them exhaust the connection's bidi budget).
C ABI (ABI_VERSION 9 → 10, header regenerated, additive only):
- new punktfunk_connection_clock_offset_now_ns — the LIVE re-synced
offset (Swift/Kotlin latency math read the frozen connect-time value
~40ms wrong after a wall-clock step).
- to_config: checked u64→usize narrowing of max_frame_bytes (32-bit
armeabi truncated >4GiB to a plausible residue).
- host_poll_input: no &mut held across the embedder callback (re-entry
aliased it — UB under noalias); mid-drain callback clears now stick.
- next_audio_pcm: DTX (empty) payloads skipped — decode synthesized
120ms of concealment per 5ms slot and grew the playout ring forever.
- next_clipboard releases the parked payload on an empty poll (a one-off
50MiB paste stayed resident all session).
- frames_dropped / wants_decode_latency write their documented 0/false
defaults before the NULL-handle check.
- gamepad constant docs match pick_gamepad() reality (DualSenseEdge/
SwitchPro landed; DualSense/DS4 honored on Windows UMDF too).
FEC:
- gf8 reconstruct/reconstruct_into reuse the (k,m) codec cache like
encode_into (was: fresh 230×200 generator + decode inversion per
lossy block on the pump thread).
- vendored fec-rs: the 8 safe wrap_mul_slice shims assert equal lengths
(x86 SIMD callees bound stores on input.len() — safe-code OOB write);
ReconstructShard's safety contract gains the len()==get().len()
clause and reconstruct_internal sizes raw slices from the slice.
transport/GTK:
- Linux GSO super-buffer capped at the real UDP payload ceiling (65487)
not 65535 — seg sizes ≥1024 could EMSGSIZE and latch GSO off
process-wide, blamed on the network.
- GTK settings dialog no longer rewrites an unlisted-but-valid stored
gamepad preference to "auto" on close.
Already fixed on main (verified stale, skipped): the reassembler FEC
ceiling, wants_decode_latency's third term, request_probe rollback +
the generalized probe watchdog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From the 2026-07-20 punktfunk-core quality sweep's adjudicated lows
(~/punktfunk-sweeps/punktfunk-core-2026-07-20.json), each with a
regression test:
- session: the two-lane seal's dead-worker fallback dropped the frame's
back half and returned Ok with half an access unit; the corpse lane
was also never respawned. The send-failure arm now reclaims the tail
(single-lane seals the WHOLE frame) and both failure arms drop the
lane so the next large frame respawns it. A recv-side death now
surfaces as an error instead of silent truncation.
- reassemble: Reassembler::reset() left pending_partial parked, so a
pre-flush stale partial survived Session::flush_backlog and was
delivered as the first "frame" after a jump-to-live.
- caps: resolve_codec echoed a non-conformant multi-bit preferred byte
verbatim (downstream from_wire folds it to HEVC — possibly outside
the shared set). It now isolates one bit of the intersection.
- endpoint: stream_transport_idle only floored the value; an absurd
operator-supplied idle timeout blew past QUIC's VarInt ms ceiling and
panicked host startup through the expect. Clamped to 1s..1h.
- pairing: PairRequest::encode cut the device name at a raw byte-64
boundary, splitting multi-byte UTF-8 (host showed U+FFFD forever);
it now shares Hello's char-boundary truncate_to.
The frozen-FEC-ceiling finding (reassemble.rs:109) was already fixed on
main by the sweep's high-severity commit — skipped as stale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- build.rs: drop the stale `rerun-if-changed=src/client.rs` (the file
became client/ in W7; the path never fires).
- lib.rs: the crate doc's module list named 6 of ~19 modules — add the
missing ones (config/error/stats/input/reject/reanchor/render_scale/
audio/wol + the quic-gated control-plane group), without intra-doc
links to feature-gated modules.
- quic/mod.rs: the module doc still described the deleted `msgs`
module; list the actual post-split module set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
client/pump.rs was a single 1192-line async fn stacking six concerns.
It is now a 190-line orchestrator (same spawn order, same channel
wiring) over five focused modules:
- pump/handshake.rs — connect + Hello/Welcome/Start + skew handshake +
hole punch + Session construction (HandshakeOut)
- pump/input_task.rs — the gamepad snapshot/removal/arrival re-send
state machine + passthrough input forwarding
- pump/control_task.rs— the control-stream select loop (renegotiation,
probes, bitrate acks, clock re-sync, clip
metadata), bundled as ControlTask
- pump/datagram_task.rs — the datagram tag demux + rumble reorder gate
- pump/data.rs — the blocking data-plane pump (loss reports, ABR
+ capacity probe, jump-to-live detectors,
standing-latency bleed), bundled as DataPump
Every body moved verbatim (dedent + arg-struct destructures aliasing
the original binding names); no per-frame indirection added — the pump
loop, its locals, and the hot-path shape are byte-equivalent. The
Ctrl/DataPump arg structs exist only to stay under too_many_arguments.
196 lib tests pass; clippy clean on --features quic AND
--no-default-features (--all-targets); include/punktfunk_core.h
byte-identical; fmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
session.rs (1203) sharpens down to the two hot-path state machines +
lifecycle (887): ReplayWindow + seq_of + their tests -> session/replay.rs;
the PUNKTFUNK_PERF trio PumpPerf/SealPerf/TimedCoder -> session/perf.rs;
the Phase-1.5 lane machinery SealLane/SealJob/seal_wire_slice/
TWO_LANE_MIN_PACKETS -> session/seal.rs. Facade pattern (session.rs stays
the parent file); pub use keeps session::{PumpPerf,SealPerf} stable and
lib.rs re-exports are untouched. Pure code motion + pub(super) bumps —
seal_frame_inner/poll_frame/poll_input bodies unchanged; the
wire-equivalence tests stay co-located with the seal path they pin.
196 lib tests pass, clippy --features quic --all-targets clean, fmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
quic/tests.rs (1813 lines, 43 tests) was the W7 split's leftover: the
source moved into handshake/caps/control/clock/pairing/pake/datagram/
endpoint/clipstream/io but every test stayed in one monolithic file.
Each test now lives in a #[cfg(test)] mod tests at the foot of the
module it exercises, verbatim. The two CompositorPref/GamepadPref
wire/name tests moved to config.rs (where those enums live), so they
now also run under --no-default-features. The clip_loopback and
ctrl_framing integration mods share connect_pair via a cfg(test)-only
quic/test_util.rs.
Test-only motion: 196 lib tests pass unchanged on macOS, clippy
--features quic --all-targets clean, include/punktfunk_core.h
byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`ci.yml`'s clippy gate has been failing on main since the pre-0.16.0 sweep landed, and
because clippy stops at the first crate it can't compile, the visible error was only
ever the first of four. `ci.yml` is not tag-triggered, so v0.16.0 cut and shipped over
a red main; none of this reaches the release artifacts (the two E0308s are in a test
binary and a dev tool, and the rest are lints) but the gate has been blind since.
Fixed, in the order clippy surfaced them:
- clients/probe failed to COMPILE (E0308, x2). `810d918d` moved `clock_sync` onto the
resumable `io::MsgReader` but only updated the client pump, leaving the probe passing
a bare `&mut RecvStream`. The probe now wraps the control stream in a `MsgReader` at
`open_bi` and threads that everywhere — Welcome, the --remode and --bitrate watchers,
and the speed-test result read — which is also what the refactor was for: those reads
sit behind timeouts and `select!`, exactly where a straddling frame would desync the
stream for the rest of the run.
- pf-capture: `SPA_META_Cursor as u32` is a `u32 -> u32` no-op
(`clippy::unnecessary_cast`). Line 1274 already passes the same constant uncast, so
the type is not in question.
- pf-inject: `noop as usize` on the SIGUSR1 wake handler added in `986402f7` trips
`clippy::function_casts_as_integer`; goes via `*const ()` as the lint asks. Same value
in the `usize`-typed `sa_sigaction` slot.
- punktfunk-core: the `ctrl_framing` test module's `use super::*` is unused, which
`-D warnings` promotes to an error.
Verified with CI's own commands on a Linux box (this is all Linux-gated code, so a Mac
cannot check it): `cargo clippy --workspace --all-targets --locked -- -D warnings`
finishes clean, `cargo build --workspace --locked` succeeds, and
`cargo test --workspace --locked` exits 0 with no failures — including punktfunk-core's
196-test suite, which covers the control-stream framing the probe change touches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There is one `ProbeState` per session and no correlation id, and two independent
requesters share it: the pump's startup link-capacity probe and the embedder's
`NativeClient::request_probe` (the shipped "Test connection" in the Windows GUI
and the Linux GTK app). The startup path had a busy check, a watchdog and a
window rebase; the embedder path had none of them, and the two collide by
default — both shipped speed tests call `request_probe` on the statement right
after `connect()`, and the pump's probe fires 2 s later, inside that burst.
Four defects, one cluster:
- The pump overwrote the shared slot unconditionally. Mid-burst it drops
`base_packets`/`base_bytes`, the pump re-snapshots them against the host's
full-burst denominator, and the user's speed test reports roughly an order of
magnitude low; if the result lands first instead, the reset wipes `done` and
the embedder's poll loop never sees its own measurement. Now it defers and
retries rather than stealing the slot.
- An unanswered embedder probe never timed out. `probe_active` gates the entire
report tick — LossReport, the ABR window feed, the standing-latency ladder and
a pending ClockResync all live inside it. A host that ignores ProbeRequest is
an anticipated configuration (the startup path was given a 6 s timeout for
exactly that) so the embedder path could latch `active` forever and silently
switch off every adaptation mechanism for the rest of the session. Now a
watchdog covers a probe of either origin.
- `request_probe` latched `active` before `try_send`, so a full or closed ctrl
channel returned `Closed` to the caller while leaving the session wedged in
the state above. It now rolls back, mirroring the startup path.
- Only the startup path rebased the ABR window past the burst. Probe filler is
counted into `bytes_received` for every accepted datagram but never reaches
the decoder, and the tick is suppressed for the whole burst, so the first
post-burst window read the burst rate as `actual_kbps`. That poisons
`proven_kbps`, a monotone high-water mark that is never decayed, which
disables the x1.5 evidence-gated climb guard for the session and authorizes a
climb into a rate the decoder has never carried. The rebase now happens on the
falling edge of any probe, and covers the loss/packet anchors too so the first
post-burst LossReport isn't divided by a filler-inflated packet count.
Also: `wants_decode_latency` advertised on two of the three terms the pump
actually requires to arm ABR, omitting `resolved_bitrate_kbps > 0`, so against
an old host that reports no rate an embedder fed decode latency to a controller
that never runs.
No wire or ABI change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`io::read_msg` frames a message with two `quinn::RecvStream::read_exact` calls,
and quinn documents `read_exact` as explicitly NOT cancel-safe: the bytes it has
already taken out of the stream live only in the future's own buffer and nothing
puts them back on drop. Both long-lived control loops drive that read from a
`tokio::select!` arm — the client pump alongside `ctrl_rx.recv()` and the resync
tick, the host alongside probe/reconfig/clip-offer channels — and neither uses
`biased;`, so any sibling that becomes ready ends the iteration and drops a
partially-progressed read. `clock_sync` has the same shape via
`tokio::time::timeout`, which can fire mid-frame before the session even starts.
A control frame only has to straddle two wakeups for this to bite: a ClipOffer
carries up to 16 kinds x 128 bytes of MIME, ~2 KB, which exceeds one QUIC packet
and is subject to the pacer; any frame whose second half is lost or reordered
does it too. Losing the consumed length prefix misaligns the stream permanently
— the next read takes two payload bytes as a length, so Reconfigured,
ProbeResult, BitrateChanged, ClockEcho and ClipState all decode as garbage and
are silently dropped, and a bogus length up to 64 KiB parks the read forever.
Mode switches, adaptive bitrate, mid-stream clock resync and clipboard are dead
for the rest of the session; only a reconnect recovers, and the log shows at
most one `warn!`.
Add `io::MsgReader`, which keeps the frame in progress in the reader rather than
the future and reads via quinn's cancel-safe `read`, and switch the three
cancelling sites to it (client control loop, host control loop, clock_sync).
The sequential handshake/pairing callers keep the plain `read_msg`, whose doc
comment now states the constraint. No wire bytes and no ABI change — only how
the same length-prefixed frames are assembled.
Tests: a frame split across two wakeups with the read cancelled in between must
resume and leave the following frame correctly framed (confirmed to fail — it
hangs on the desynced stream — against the old behavior), plus a zero-length
frame round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four defects from the punktfunk-core quality sweep, all in the data plane.
transport/udp: the hole-punch adopted the source address of ANY datagram whose
first 8 bytes matched PUNCH_MAGIC — a fixed public constant with no key, nonce
or session id — and the authenticated QUIC peer was passed only as the
no-punch fallback, so it was never used to validate. Hole-punch is the default
bring-up path (it is skipped only for a fixed --data-port), and the data socket
is an OS ephemeral, so spraying the ephemeral range during the 2500 ms punch
wait let anyone steal the video plane: the legitimate client is then filtered
out by the connect() and gets nothing, while QUIC stays healthy so no reconnect
fires. With a spoofed source the same 8 bytes aim a full-rate stream at a third
party. Take the authenticated peer IP and require the punch to match it — only
the PORT is in question (that is what a NAT remaps and what the punch exists to
discover); the client dials the same host IP as its QUIC connection, so a NAT
presents one source IP for both planes. Also budget each read from the REMAINING
window, so off-peer datagrams cannot stretch the wait past punch_timeout.
transport/udp: the punch keepalive treated every send error as fatal and broke
out of its loop permanently and silently. It holds a clone of the connected,
non-blocking data socket — exactly the socket whose transient conditions this
module defines and documents in is_transient_io. One ENOBUFS from a full wlan tx
queue or an ENETUNREACH during an AP roam killed the only thing holding the NAT
mapping open; the path recovers, video keeps flowing, and the stream dies later
when the idle timer expires the mapping during a static scene. Route it through
is_transient_io like every other send site in the file.
packet: adaptive FEC moved fec_percent live (host bands it 1..=50 while Welcome
advertises 10) but the receiver's per-block acceptance ceiling was computed once
from the negotiated percentage and never re-derived. Once FEC ramped, every
packet of a maximal block failed `total > max_total_shards`, the block never
accumulated a shard, the frame aged out, and the resulting loss drove FEC higher
still — large frames wedged at 100% loss exactly when FEC was meant to rescue
the link. Fixed on both sides, because hosts and clients update independently:
the sender clamps per-block parity to the ceiling the peer negotiated, and the
receiver sizes that ceiling from the whole clamp range rather than a stale
snapshot of it.
No wire bytes and no C ABI signature change; WIRE_VERSION and ABI_VERSION are
unchanged. Regression tests cover all three (the punch tests were confirmed to
fail without the fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two-pair investigation (wired Mac clients stuck at a rock-steady
~18-19 ms "network" that survived the load ending and cleared only on
reconnect) exposed two structural gaps, one of measurement and one of
recovery:
- Receipt was stamped at the hand-off PULL (Swift nextAU, pf-client-core,
Android decode loops), not at reassembly completion — so any client-side
standing state between the reassembler and the pull read as NETWORK
latency, undiagnosable from the HUD. ABI v9: `PunktfunkFrame`/`Frame`
grow `received_ns`, stamped by `Session::poll_frame` as the AU crosses
the session boundary. Every embedder now uses the core stamp; the Apple
client keeps the pull instant as `AccessUnit.pulledNs` and shows the
receipt→pull wait as its own "client queue" term (detailed HUD tier from
2 ms + a `queue_p50` stats-log field). Decode stages keep their pull
anchor on all platforms, so no historical stage shifts meaning.
- The jump-to-live detectors deliberately ignore anything under 6 queued
frames / 400 ms behind — so a small, constant, loss-free elevation (a
sub-frame standing backlog, or a stale clock offset after a wall-clock
step/slew) is carried for the rest of the session. New third detector
(`StandingLatency`, unit-tested ladder): window-MIN one-way delay
≥ 10 ms above the session floor with zero loss for ~4.5 s escalates
gently — a free clock re-sync first (an applied re-sync re-bases the
floor), then at most 3 flush+keyframe bleeds sharing the jump-to-live
cooldown, then a loud disarm naming what it means. Loss windows reset
the run: congestion belongs to FEC/ABR, not this detector.
Also: mid-stream re-sync apply/discard logs debug→info — they are the
forensic trail for the stale-offset case and were invisible in the field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`is_transient_io` only recognized `ENOBUFS` on unix; on Windows the
`#[cfg(not(unix))]` arm returned `false`, so `WSAENOBUFS` (10055) — which
Rust maps to `ErrorKind::Uncategorized`, missing the `WouldBlock` arm —
propagated out of the USO `send_gso` path through `Session::send_sealed`
and killed the session with `native::stream` "send failed — stopping
stream". A high-bitrate keyframe burst (one `WSASendMsg` USO super-buffer
is up to ~512 segments) momentarily exhausts the socket send buffer / AFD
non-paged pool; it's a lossy drop that FEC + the next frame recover,
exactly like the unix `ath11k` `ENOBUFS` case the classifier already
handles. Fires independently of client platform (Windows/macOS clients
both saw the crash) because it's the Windows *host's* send socket.
Add a `#[cfg(windows)]` arm matching `WSAENOBUFS` plus the
`WSAENET*`/`WSAEHOST*` network-path family (the Windows counterparts of
the droppable unix set), and extend the classifier test with per-platform
raw-errno coverage so the Windows CI runner exercises the 10055 path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
design/latency-reduction-2026-07.md tier 1, remaining halves:
- T1.1: the native encode loop wakes on the capture's ACTUAL arrival instead of
sampling at a free-running tick — deletes the sample-and-hold (~half a frame
interval on average, a full one worst-case: ~8ms avg @60fps). New
Capturer::supports_arrival_wait/wait_arrival pair (IDD-push waits its
frame-ready event against the shared-header token; the PipeWire portal blocks
its channel with a pending stash); backends without an arrival signal — and
PUNKTFUNK_FRAME_DRIVEN=0 — keep the legacy tick bit-identically. A
0.9x-interval rate floor caps encode at ~1.11x target when the compositor
outruns the session; a +0.5x-interval keepalive keeps static desktops
re-encoding at 1.5x-interval cadence. Pacing deadlines re-anchor to the
actual submit so they can't drift against the arrival clock. GameStream
plane untouched.
- T1.4: the jump-to-live detectors run on WALL-CLOCK now (STANDING_TIME /
FLUSH_AFTER = 250ms) instead of 30-frame counts whose meaning scaled with
fps (500ms @60 but 125ms @240 — and stretching further under T1.1's slower
static-scene repeats). The queue trip also requires depth still >= high, so
a hysteresis-band hover can't fire on elapsed time alone.
Validated: .21 Linux 185 core + 177 host + pf-capture tests, clippy
-D warnings; .133 Windows cargo check of pf-capture + punktfunk-host green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
design/latency-reduction-2026-07.md T0.1/T0.2/T1.2/T1.3:
- T1.2 rate-capped front-loaded pacing: the paced overflow's budget is now
min(0.9x slack, overflow wire time at ~3x the live encoder bitrate)
(PUNKTFUNK_PACE_FACTOR, 0 = legacy deadline-only spread). A 300 KB-1 MB
frame's tail leaves in ~2-5 ms instead of smearing across ~15 ms at 60 fps;
GameStream schedule byte-identical (pins unchanged).
- T1.3 data-first wire order: packetize emits every block's data shards before
any parity (per-block parity pools keep all blocks' parity alive for the
second pass), so lossless completion stops waiting behind the parity tail.
EOF = last emitted packet; receiver already order-agnostic.
- T0.1 staged 0xCF: HostTiming gains an append-extensible per-stage tail
(queue/encode/pace us; seal+channel-wait derived as residual) - no cap bit
needed, old peers read the 13-byte prefix. Joined client-side into
Stats::host_{queue,encode,xfer,pace}_ms, the OSD detailed tier, and the
probe's report.
- T0.2 true on-glass present timing: VK_KHR_present_id/present_wait enabled
when supported; a PresentTimer waiter thread resolves each present id to
real visibility, replacing the submit-time display stamp (which undercounts
by up to a refresh and hides a silent-FIFO standing queue).
Validated on .21: core 185 + host 185 tests, pf-presenter 19, clippy
-D warnings across all five touched crates; loss-harness recovery curve
unchanged; C ABI harness round-trips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Client-side supersampling/downscaling: the client asks the host to render
and encode at chosen-resolution × scale (the host does no scaling) and the
presenter rescales the decoded frame to the display. >1 supersamples for
sharpness; <1 lightens the host GPU and the link. Default 1.0 = Native, the
prior behavior.
The geometry lives once in punktfunk_core::render_scale (multiply, preserve
aspect ratio, floor to even, clamp to the codec's per-axis ceiling — 4096
for H.264, 8192 otherwise), the Rust twin of the Apple client's
RenderScale.swift, consumed by the native session client, the presenter's
match-window path, the Windows/Linux settings UIs, Decky, and Android
(settings + host connect + unit test).
Implemented and platform-verified by the Apple-client-features session
(Linux+Android+Apple green there); the punktfunk-core wiring
(pub mod render_scale) is restored here after being lost in a working-tree
reconciliation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
origin/main landed the shared clipboard (design/clipboard-and-file-transfer.md) while
this branch split quic/msgs.rs -> quic/{caps,control,...} and client.rs ->
client/{mod,control,worker,pump,planes,...} (W7) and deleted the two monoliths. The
feature had modified both deleted files, so its delta is re-applied onto the split
instead of resurrecting the monoliths:
- HOST_CAP_CLIPBOARD -> quic/caps.rs
- MSG_CLIP_* / CLIP_* consts, the six Clip*
structs, and their encode/decode impls -> quic/control.rs (beside the clock codecs)
- CtrlRequest::{ClipControl,ClipOffer} +
Negotiated.host_caps -> client/control.rs
- WorkerArgs.{clip_event_tx,clip_cmd_rx} -> client/worker.rs
- CLIP_EVENT_QUEUE -> client/planes.rs
- NativeClient clip fields, the 7 clip_* /
host_caps / next_clip methods, connect()
channel wiring -> client/mod.rs
- the control-task encode/decode arms and
the clipboard-task spawn -> client/pump.rs
Cargo.lock reconciled (adds pf-clipboard), punktfunk-host/Cargo.toml unions the W6
pf-* subsystem deps with pf-clipboard, and include/punktfunk_core.h is the cbindgen
union (clipboard + rumble C-ABI). punktfunk-core builds --all-features and its 174
lib tests pass, including quic::tests::clip_loopback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
punktfunk-core client/rumble.rs: a per-connection policy engine consumes seq-gated wire
updates and emits EFFECTIVE actuator commands — re-emits on renewals (duration APIs stay
re-armed), self-silences at the v2 lease, a UNIFORM 1 s legacy-host staleness replacing the
per-platform zoo (Apple 1.6 s / Android 60 s / SDL 1.5 s / Deck 1 s), quirk-declared
actuator keepalives (Deck 40 ms + LSB dedupe-defeat jitter), and one stop per buzzing pad
on connection close. Per-pad mailbox semantics: a stalled embedder wakes to ONE current
command, and a stop can structurally never be the update an overflowing queue drops.
New API/ABI: NativeClient::{next_rumble_command,set_rumble_quirks} +
punktfunk_connection_next_rumble_cmd/_set_rumble_quirks (next_rumble/next_rumble2 stay for
un-migrated embedders; both consumers are fed). Migrations DELETE the platform forks:
pf-client-core loses RumbleState + the Deck keepalive loop + LEGACY_RUMBLE_CEILING_MS and
physically silences a slot at close; Android loses the 60 s legacy one-shot (backstop
repack, cancel-on-zero); Apple loses envelopeDeadline + sessionStaleSeconds + both tick
watchdogs (CoreHaptics realization untouched; mac xcframework rebuilt locally).
design/rumble-root-fix.md par. D. Engine 10/10 unit tests; core tests 176 Linux / 175
Windows + clippy -D warnings; swift build + RumbleTuningTests; Kotlin + android-native
compile green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Turn the 2674-line client.rs into a client/ directory module (mod.rs facade +
8 submodules) behind glob/`use self::` re-exports, so crate::client::X paths
(NativeClient, ProbeOutcome, AudioPacket, display_hdr_env_override) stay
byte-stable. Leaf lifts: frame_channel.rs (the FIFO hand-off + jump-to-live
consts + DecodeLatAcc), recovery.rs (RfiRecovery loss-range detector),
probe.rs (ProbeState/ProbeOutcome), planes.rs (side-plane queues + AudioPacket),
control.rs (CtrlRequest/Negotiated), worker.rs (WorkerArgs + reject_from_close),
pairing.rs (NativeClient::pair). The per-frame pump moves WHOLE as a plain
`pub(super) async fn run_pump` (was worker_main) — the only edit is the
signature line: no trait object, no Box, no per-frame allocation or indirection.
NativeClient + its public impl + Drop + the cfg-gated thread-pin/hot-tid helpers
stay in the facade. Visibility bumps are pub(crate) (struct + each field for
WorkerArgs/Negotiated/ProbeState; FrameChannel + each method); reject_from_close
is pub(crate) (sibling access). No behavior change.
Verified: Linux clippy (quic + no-default, -D warnings) + full cargo test;
Windows clippy (both) + test --lib; macOS clippy (apple thread-pin variant) +
165 lib tests. On-glass jump-to-live + ABR smoke still owed (pump is a pure
relocation, so this is a formality) per the plan's pump gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Break the 1302-line quic/msgs.rs into four flat sibling modules behind the
quic facade's glob re-exports, so every crate::quic::X path stays byte-stable:
handshake.rs (Hello/Welcome/Start + codecs), caps.rs (video-cap bits, codec &
chroma negotiation, ColorInfo), control.rs (typed CTL_MAGIC messages + frame),
pairing.rs (SPAKE2 ceremony messages). msgs.rs is deleted; quic/mod.rs gains the
four `mod`/`pub use` lines and the `pub use crate::reject::*` hoist (moved up from
msgs.rs). Pure move; no wire-format or behavior change. Private helpers
(truncate_to, put_bytes, get_bytes) stay with their sole callers; no visibility
changes.
Verified both platforms from clean HEAD snapshots: Linux clippy (quic +
no-default, -D warnings) + full cargo test (157 lib + integration); Windows
clippy (both) + test --lib (156).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn transport/udp.rs into a udp/ directory module: the cross-platform core
(UdpTransport, is_transient_io, spawn_data_punch, the Transport trait impl) stays
in mod.rs; the platform batched-I/O backends move to udp/{linux,windows,apple}.rs.
The trait impl is kept whole -- its per-OS send_batch/send_gso/recv_batch methods
become cfg-gated one-line delegators to pub(super) free fns that take &UdpTransport
(byte-identical bodies, self -> t). transport/mod.rs is unchanged (re-exports still
resolve; udp/mod.rs re-exports windows::send_uso_all). No behavior change.
Module gates: linux = any(linux, android) (Android uses sendmmsg/recvmmsg via its
bionic binding); windows = windows (USO); apple = all(unix, not(any(linux,android)))
(recvmsg_x on Darwin, recv-loop on BSD). GSO stays linux-only.
Verified on all four target families from clean HEAD snapshots: Linux clippy
(quic + no-default, -D warnings) + full test; Windows clippy (both) + test --lib
(156); macOS check (apple recvmsg_x path); aarch64-linux-android check (android_mmsg).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The portable shared-clipboard plane in punktfunk-core, all behind the `quic`
feature (design/clipboard-and-file-transfer.md §3):
- Control messages 0x40–0x44 (ClipControl / ClipOffer / ClipFetch...) and the
HOST_CAP_CLIPBOARD capability bit, negotiated in the Welcome caps.
- Per-transfer QUIC bi-streams ("PKFs" magic) for lazy fetch of offered content,
with ClipFetchHdr status/size framing (quic::clipstream).
- The §3.5 portable wire-MIME vocabulary (text/plain;utf-8, text/html, text/rtf,
image/png) shared by both ends.
- Client-side clipboard task (client.rs) + C ABI surface bumped to v8 (abi.rs,
regenerated include/punktfunk_core.h).
- Loopback transport tests (quic::tests).
No OS clipboard integration yet — that is the host backends (Phase 1/3) and the
macOS client (Phase 1).
Ported from feat/shared-clipboard (af3a7d8c, pre-W6 base) onto current main;
three deliberate deviations from the original commit:
- ABI v6 → v8: main took v6 (reanchor gate) and v7 (typed connect rejection)
in the meantime; the clipboard C surface re-lands as v8.
- CLIP_CANCELLED_CODE 0x60 → 0x70: main's pairing-rejection close codes claimed
the 0x60–0x67 block; the vocabularies stay disjoint on purpose.
- Negotiated.host_caps coexists with main's 6-tuple host_caps plumbing: main
needs the worker-local copy for gamepad snapshots, the clipboard path needs it
across ready_tx to build the NativeClient handle (punktfunk_connection_host_caps).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn the 1446-line packet.rs into a packet/ directory module (mod.rs facade
+ header/packetize/reassemble/tests) behind glob re-exports, so every
crate::packet::X path stays byte-stable. Pure move: the header consts +
PacketHeader -> header.rs; Packetizer -> packetize.rs; the Reassembler cluster
(kept WHOLE -- disjoint-borrow hot path) + loss-window consts -> reassemble.rs;
the inline #[cfg(test)] block -> tests.rs. Sole visibility change:
LOSS_WINDOW_NS -> pub(super) (a test imports it). No behavior change.
Verified on both platforms from a clean HEAD snapshot: Linux clippy
(--features quic and --no-default-features, --all-targets -D warnings) + full
cargo test; Windows clippy (both feature sets) + cargo test --lib (156 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The controller's target is only a promise: on calm content the encoder
emits a fraction of it, every window looks clean while proving nothing,
and the climb drifts the target into rates the decoder has never seen.
The first motion spike is then the first real test — it fails, and the
decoder is overloaded for the two-window backoff latency (the reported
settle-calm-then-spike stutter).
Three changes, all client-side (no wire/ABI impact, old hosts unaffected):
- Climb gate: a clean window authorizes a climb only when its ACTUAL
delivered throughput reached 3/4 of the current target — the target was
genuinely tested. Calm windows still bank clean credit; the first
loaded window after a clean run climbs immediately.
- Proven-throughput cap: climbs step at most x1.5 past the session's
high-water mark of delivered-and-digested (decode-latency-flat)
throughput, so slow start becomes a bounded experiment instead of a
blind doubling. High-water never decays: calm periods keep a validated
target, so returning motion gets the full rate instantly; shrinking
capacity (thermals) stays the reactive decode signal's job.
- Severe decode excursion: a >45 ms-over-baseline decode spike backs off
after ONE window instead of two — the overload is already on screen.
The pump feeds the window's wire-byte throughput; the byte baseline is
rebased when the startup capacity probe completes so FLAG_PROBE filler
can't poison the proven mark with the link rate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First de-coupling for the host crate carve (plan §W6.0 / §2.4): the GameStream
(Moonlight-plane) decoded controller types were defined in gamestream/gamepad.rs — the
"junk drawer" — yet consumed 18× by the platform-neutral input injectors AND by the
Moonlight decode path. Once inject becomes pf-inject, reaching them via crate::gamestream
would be an illegal upward edge. Move the two types to core::input (below both planes;
inject already depends on core) and repoint every consumer. Also consolidate the
duplicated MAX_PADS onto the existing core::input::MAX_PADS. The gamestream BTN_* const
aliases stay for now (separate follow-up); decode()/rumble/tests remain in the Moonlight
plane, now importing the types from core.
Verified: Linux (home-worker-5) clippy -p punktfunk-core -p punktfunk-host --all-targets
-D warnings + gamepad tests green; Windows (192.168.1.158) clippy -p punktfunk-host
--features nvenc,amf-qsv --all-targets green (the inject/windows/* consumers compile).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wholesale commit of every uncommitted change across the tree, at the user's
explicit request — host refactor-campaign W1 (native.rs facade + native/ dir,
library/ + mgmt/ splits), Android, core. These streams were mid-flight and not
individually built/tested together; this supersedes the per-session HOLD
markers. Consolidating so everything lands on main in one pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan §2.5: the security-critical rustls fingerprint-pinning ServerCertVerifier
was hand-rolled three times — quic/endpoint.rs (PinVerify), pf-client-core
library.rs, punktfunk-tray status.rs — drifting copies on a trust boundary. Add
one canonical punktfunk_core::tls::PinVerify (+ cert_fingerprint) behind a light
`tls` feature (rustls + sha2 only, no QUIC runtime); `quic` now depends on it, and
quic::endpoint re-exports cert_fingerprint so that path stays byte-stable
(gamestream + pf-client-core reach it there).
- core::tls::PinVerify: new(pin) for the HTTP clients, with_observed(pin, slot)
for the QUIC TOFU connect. Behavior-identical to all three originals (pin-check
+ real CertificateVerify signature verification; only hashes the leaf when a pin
or observed slot needs it). Two focused unit tests anchor the boundary.
- quic/endpoint.rs: drop the private PinVerify, wire client_pinned through
tls::PinVerify::with_observed.
- pf-client-core library.rs + tray status.rs: use the shared verifier; tray also
routes load_pin through core cert_fingerprint and drops its direct sha2 dep,
gaining only the light core `tls` feature (still no host dep, no QUIC runtime).
Verified on Linux (home-worker-5): clippy 0/0 for core(quic), core(tls),
pf-client-core, tray, host(nvenc,vulkan-encode,pyrowave); core 153 lib tests +
loopback 7/7 (pinned handshake) + c_abi round-trip green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The last three //!-less modules in the tree (plan §2.5 / §3.2):
- io: length-prefixed control-message framing (read_msg/write_msg)
- endpoint: QUIC endpoint construction + transport tuning + the TOFU
cert-pinning verifier (PinVerify)
- pake: SPAKE2 pairing key exchange
Docs only — no code, type, or wire-format change (cbindgen header byte-identical).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local snapshot of intermingled in-flight work, committed to unblock the encode
refactor (a clean ffmpeg_win.rs for the vbv-dedup follow-on). These hunks span
the same files and can't be cleanly split here; the commit bundles three
distinct workstreams that each belong in their own PR:
- logging rework (~43 files: level re-tiering, structured fields, `?e`,
hot-path flood latches)
- conflicting-host detection (detect.rs + detect/{linux,windows}.rs + wiring
in main.rs/mgmt.rs/Cargo.toml/docs/packaging)
- standby-sink DWM-stall attribution (windows/display_events.rs + capture/
vdisplay wiring)
NOT verified as a combination. NOT to be pushed until the refactor is done and
these are re-verified and reorganized into their proper per-workstream PRs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>