Code review¶
A review of the whole code base (2026-10-11, commit eace1a2): correctness, performance, security,
clean code and consistency. Six areas were read in full: transport, host, GPU and codecs, client,
security, frontend with CI. Nothing was built or measured for the review itself; timings marked
"estimate" are estimates.
Every finding has a fixed ID and a status. Work goes from top to bottom: bugs first (A), then security (B), then performance (C), the strategic steps (D) and clean-up (E).
Status: open · fixed (with the commit) · wontfix (with the reason)
Overall¶
The foundation is strong: a real zero-copy path from KMS to the encoder, parsers that never panic (proptests, fuzzing), careful unsafe code with layout tests, sound cryptography and replay window, encoder settings already tuned for latency. The weak spots are error paths (threads that die silently), the control loops (bitrate, keyframes), the logic around pairing and the web PIN, and the limits FFmpeg puts on the next latency step.
A. Bugs¶
A1 · A pipeline thread dies and the session stays with a frozen picture¶
- Where:
apps/host-agent/src/lib.rs:1520(capturebreak),lib.rs:2157(send ends on any error butWouldBlock, which never happens on a blocking socket),lib.rs:739-757(only web sessions checkrunning),apps/client/src/lib.rs:969(any packet resets the client's timeout). - Problem: capture ends on the first
next_frameerror (monitor standby, a framebuffer removed during a mode change or when a game starts direct scan-out). The send thread ends on ENETUNREACH or EHOSTUNREACH during a Wi-Fi blip. Encode follows. Audio, pongs and the monitor list keep the client's timeout alive: the picture stays frozen and the GPU boosted until the user reconnects. - Fix: treat capture errors as transient (retry, reopen after N failures); ignore transient send
errors; if a pipeline thread ends anyway, clear
runningand let the main loop end native sessions too (with Bye). Client: no video for a while after video was seen → reconnect. - Status: fixed.
apps/host-agent/src/health.rs: send errors that are only loss (unreachable, no buffers, firewall) are ridden out and logged once a second; capture errors are retried (5 ms doubling to 100 ms); a guard in the capture, encode and send threads ends the session when one stops (also by a panic), and the control loop then sends Bye. The client-side watchdog was left out on purpose: a monitor in standby legitimately sends no video for a long time. Tests:health.rs,a_monitor_in_standby_pauses_the_stream_and_keeps_the_session(protocol.rs).
A2 · Bitrate adaptation ratchets down and never recovers¶
- Where:
crates/net/src/rate.rs:79-95,apps/host-agent/src/lib.rs:2068-2078. - Problem: smoothed loss between
CLEAN(2 %) andSOME_LOSS(15 %) holds the rate forever and resets the clean timer. The filter rises fast and falls slowly. Every frame lost despite FEC costs 30 %. With steady 3 % Wi-Fi loss (FEC's job) the rate steps down to the floor and stays there.kbps = targetis stored even when the encoder rebuild failed, so it is never retried. - Fix: count reports with no lost frames and no overflow as clean when loss is below
SOME_LOSS; symmetric filter; only store the new rate when it was applied. Test: one lost frame, then 300 reports at 3 % loss → the rate returns to the ceiling. - Status: fixed. A report is clean when no frame was lost, the sender kept up and smoothed loss
is below 15 %; the filter is symmetric; a frame lost now and then lowers the rate gently (×0.85),
only overflow or heavy loss hard (×0.7). The test also found that the 5 % rule kept the last step
from reaching the ceiling (19.55 of 20 Mbit/s for good); the limits are exempt now. The encode
loop keeps the old rate when a rebuild fails and tries again when the target changes. Tests in
rate.rs(wifi_loss_that_fec_hides_does_not_hold_the_bitrate_downand others).
A3 · Web sessions are not ended cleanly¶
- Where:
web/viewer/src/routes/live.tsx:81-85, 94-103,web/viewer/src/routes/session.tsx:17-27,web/viewer/src/lib/host.ts:76-79, 128-133; hostapps/host-agent/src/web.rs:254-284, 310-322,lib.rs:741, 980-984. - Problem: the viewer ends on
disconnected, which is transient and often recovers; it never callssession.close()on the ended screen or on unmount (browser back), so the peer connection keeps receiving and the host stays busy. On the host, a session whose browser never connects (blocked UDP port, tab closed while connecting) lives forever: str0m's ICE agent without remote candidates never reachesDisconnected, and a session whose HTTP answer timed out is installed anyway. - Fix: viewer: close on unmount and on the ended path; only
failed/closedare final, show "Verbindung unterbrochen…" with a grace timer ondisconnected. Host: noConnectedwithin 10 s ends the session; a failedreply.sendstops it. - Status: fixed. Viewer:
connectionPhase(pure) tellsdisconnected(interrupted, shown as “Verbindung unterbrochen …”) from the end; an interruption longer than 10 s ends the session; an ended session is closed, andholdSessioncloses it when the page goes away (deferred, so React's StrictMode trial unmount does not end it);closeis idempotent. Host: a browser that has not connected within 10 s ends its session; a session whose HTTP answer could not be delivered is stopped. Tests:host.test.ts,live.test.tsx,a_browser_that_never_connects_does_not_keep_the_host_busy.
A4 · The GPU stays clocked high after systemctl stop or restart¶
- Where:
apps/host-agent/src/main.rs:305(no signal handler),apps/host-agent/src/power.rs:96-98,packaging/fernsicht-host.service(RuntimeDirectory=is removed on stop). - Problem: SIGTERM kills the process,
GpuBoost::dropnever runs, and the crash record in/run/fernsichtis deleted by systemd. The next session readshighas the old value. - Fix: handle SIGTERM/SIGINT by setting
stop; keep the record in the state directory. - Status: fixed.
fernsicht_core::signal::stop_on_signalsturns SIGINT and SIGTERM into the stop flag (a thread insigwait; a second signal exits at once); the host ends its session with Bye and the GPU's values return. The record lives in the state directory now. Test:sigterm_ends_the_host_cleanly_and_tells_the_client(binaries.rs).
A5 · Switching to a monitor of another size distorts the picture¶
- Where:
apps/host-agent/src/lib.rs:1540-1565,crates/codec/src/vaapi.rs:396-404, 652. - Problem: the converter scales the new monitor to the old session size without keeping the aspect ratio: a 21:9 monitor is squeezed into 16:9.
- Fix: fit the new monitor into the stream size with letterboxing in the converter (VAAPI and the Vulkan shader); later, renegotiate the size.
- Status: fixed, by renegotiating rather than letterboxing (
pad_vaapineeds FFmpeg 7, the AppImage ships 6.1). The stream size isfit(source frame size, session box), computed per frame in the encode loop; when it changes, the encoder is rebuilt at the new size and its keyframe carries it. Pointer and input stay right because the monitor fills the picture again; the native client maps clicks onto the decoded size, the browser already usedvideoWidth. Explicit client sizes are now fitted too (no more stretching at session start, no upscaling). Test:the_stream_takes_the_shape_of_the_monitor_shown. Makes A18 reachable (FFmpeg rebuilds its frame pool on a size change), so A18 comes next.
A6 · Session frame rate is not limited to the monitor's refresh rate¶
- Where:
apps/host-agent/src/lib.rs:1188-1213, 1845,crates/capture/src/kms.rs:248. - Problem: the encoder gets
framerate = fpsand a one-frame buffer ofbitrate/fps; KMS only delivers the refresh rate. 144 fps from a 60 Hz monitor gives each frame 42 % of the bitrate. - Fix: expose the refresh rate on the source, clamp fps to it, report the result in HelloAck.
- Status: fixed.
FrameSource::refresh_hz(KMS knows it);frame_rateclamps the session's fps before the default bitrate and the encoder are set up, and HelloAck reports it. Still open: rates that are no divisor of the refresh rate (60 fps from 165 Hz) alternate between 2 and 3 vblanks; whether to round to a divisor is a judgement call left for measurements (D8). Test:the_frame_rate_is_at_most_the_monitors.
A7 · A render error after acquiring a swapchain image loses the image¶
- Where:
crates/render/src/vulkan/window.rs:282-303, 343-349,crates/render/src/vulkan/renderer.rs:536-548. - Problem:
draw()acquires before the fallible work (import, staging, pipeline). On error the image is never presented and the semaphore stays signalled; the DMA-BUF → CPU fallback hits this every time it triggers. The next acquire breaks the spec and can block forever (u64::MAX). - Fix: prepare before acquiring; on an error after acquiring, still submit a clear pass and present; finite acquire timeout.
- Status: fixed.
Renderer::preparedoes the work that can fail (staging, import, pipelines) before the window takes an image;submitonly records and submits. Acquire waits at most 100 ms and skips the frame otherwise (a hidden window whose compositor holds its images no longer blocks the present thread). Left: a failed submit after acquire (device lost) still loses the image, but then the renderer is broken anyway.
A8 · Use-after-free when the client exits on Wayland¶
- Where:
apps/client/src/main.rs:478-500,apps/client/src/shortcuts.rs:139-143, 189-197. - Problem: after
run_appthe event loop is gone;Appdropswindow(the last reference to winit's Wayland connection) beforeshortcuts, whose drop then uses the display. - Fix: drop the shortcut lock first (
app.shortcuts = Noneafterrun_app, or field order). - Status: fixed.
shortcutsis the first field ofApp, so it is dropped before the window on every path (also the early return whenrun_appfails); the SAFETY comment says why.
A9 · Keys and mouse buttons stay pressed on the host¶
- Where:
apps/client/src/main.rs:514-520, 583-589, 715-723,apps/client/src/lib.rs:857-860, 1115-1122,crates/input/src/uinput.rs:430-435, 543-560,web/viewer/src/routes/live.tsx:267-304. - Problem: releases queued on quit are never sent (the loop stops first): Ctrl+Alt+Shift+Q leaves Ctrl, Alt and Shift pressed; Bye is a single datagram; Ctrl+C sends nothing. In gaming mode without capture, button releases are swallowed. The host releases buttons on the device that moved last, not the one that pressed them. The web viewer has no pointer capture, so a release over the toolbar is lost.
- Fix: flush input and send Bye a few times on stop; signal handler; forward releases of held
buttons always; release on the pressing device;
setPointerCapturein the viewer. - Status: fixed. The client sends what is queued (the releases of closing) before its goodbye,
sends Bye three times, and
stopis set and read with Release/Acquire so the queued releases are seen; Ctrl+C and SIGTERM end the session throughstop_on_signals. In gaming mode only the capturing press stays local, releases go on (native client and viewer). The host's uinput releases each button on the pointer it went down on (button_pointer), also inrelease_all. The viewer captures the pointer on a press. Tests:keys_held_when_the_client_closes_are_released_on_the_host(fails without the fix),ctrl_c_ends_the_client_with_a_goodbye,a_button_goes_up_where_it_went_down,live.test.tsx.
A10 · "Zu viele falsche PINs" is never shown¶
- Where:
apps/client-ui/src/lib/api.ts:190-200,apps/client/src/lib.rs:278. - Problem:
"wrong PIN"matches first as a substring of "too many wrong PINs". - Fix: most specific first now; error codes later (E2).
- Status: fixed (order; codes with E2). Test in
api.test.ts.
A11 · The web viewer's mute button does nothing¶
- Where:
web/viewer/src/routes/live.tsx:106-135,web/ui/src/components/session-view.tsx:76, 177-188. - Fix: pass
onMuteChangeand mute the video element. - Status: fixed. The page keeps the state and mutes the
<video>. Test inlive.test.tsx.
A12 · One invalid input event blocks all input of the session¶
- Where:
apps/client/src/lib.rs:475-491(parse_keys_command),crates/proto/src/lib.rs:817-822, 886-893,crates/input/src/queue.rs:177-199. - Problem: the keys command accepts the mouse button codes 0x110–0x117 as keys; the host rejects the whole packet, the client resends it forever and everything behind it waits.
- Fix: validate in
InputQueue::pushwith the protocol's rules. - Status: fixed.
InputEvent::validateinprotoholds the rules once; the parser applies it,InputQueue::pushdrops what it refuses, and the keys command checks with it. A property test (validate_agrees_with_the_parser) keeps parser and rule the same.
A13 · Overlapping FEC groups make the client allocate gigabytes¶
- Where:
crates/net/src/reassembly.rs:235-244,Group::initat:66. - Problem: groups are checked to lie inside the frame, not to be disjoint or match their index. A hostile host (or any LAN machine in plain mode) can make thousands of groups each claim offset 0 with full recovery buffers.
- Fix: enforce the packetizer's layout (
group_offset == index · D · shard, equal D except the last) or cap recovery bytes per slot at the frame length; a test that counts allocations. - Status: fixed, independent of the packetizer's layout (which C4 will change): a group's data
shards must be shard-aligned and must not overlap another group's (a reused per-slot bitmap), and
recovery stays bounded by the data it protects, so a slot holds at most about four times its
frame. Test:
groups_that_claim_the_same_data_are_refused.
A14 · A failed session start locks the client out; replaced sessions get no Bye¶
- Where:
apps/host-agent/src/lib.rs:1057-1082, 824-827, 955-962. - Problem: the handshake timestamp is recorded and the old session stopped before
start_session; if it fails, the client's retransmissions are dropped as replays and the status still shows the old session. A replaced client gets no Bye and sees a frozen picture for 5 s. - Fix: record and replace only after a successful start; clear the status on every path; send Bye to the replaced client.
- Status: fixed. The old session still goes first (screen and encoder may not be had twice),
but through
replace_session: the old client gets Bye and the status is cleared. The handshake time is noted only after a successful start. A start that fails is answered with the newRejectReason::Unavailable, so the client stops asking and says why (older clients ignore the unknown reason). Tests:new_peer_replaces_the_session,a_session_that_cannot_start_is_refused_with_a_reason.
A15 · Error paths in start_session leak running threads¶
- Where:
apps/host-agent/src/lib.rs:1325-1391, 1403, 1450. - Problem: a
?after the firstspawn_hotleaves started threads running withrunning = true.overflows_seenstarts at 0 against a counter that spans sessions. - Fix: a guard that stops and joins on error; take the counter's current value as the baseline.
- Status: fixed.
health::Startingcollects the threads as they start; dropped withoutdone()(an early return), it clearsrunning, closes the frame slot and joins them. The session'soverflows_seenstarts at the counter's current value. Tests inhealth.rs.
A16 · KMS edge cases¶
- Where:
crates/capture/src/kms.rs:368-386, 157-160, 251-270,apps/host-agent/src/lib.rs:1891-1931. - Problem: one failed vblank wait (DPMS, modeset) switches to the free-running timer for the rest of the capture (up to one refresh more latency, judder). The encoder GPU is chosen independently of the card driving the monitor. The monitor list is never refreshed on hotplug.
- Fix: retry vblank periodically; pick the encoder on the capture card's device; refresh the list.
- Status: fixed. A failed vblank wait paces by timer for a second, then vblank is tried again.
--encoder autoorders GPUs that drive a monitor first (a card with an enabled connector in sysfs). The capture thread rescans the outputs every 2 s (FrameSource::rescan, the KMS outputs read byread_outputs/screen_info), so hotplug reaches the client's list and the input mapping. Tests:the_gpu_that_shows_the_monitor_comes_first,the_screen_lists_the_monitors_that_are_on.
A17 · The Vulkan device is not matched to the decoding or encoding GPU¶
- Where:
crates/gpu/src/device.rs:107-139,crates/render/src/vulkan/window.rs:80,apps/client/src/lib.rs:1218-1226,crates/codec/src/nvidia.rs:196-205. - Problem: Vulkan prefers the discrete GPU, VAAPI uses
renderD128(often the iGPU): hybrid machines import across devices, which fails. NVENC on a host with two GPUs asks for an environment variable instead of picking the matching device. - Fix: pick by
VK_EXT_physical_device_drm(render node) and by device UUID for CUDA. - Status: fixed.
fernsicht_gpu::Prefer(best kind, a render node, or a UUID) and the purepick: the client's window prefers the VAAPI decoder's render node, NVENC asks CUDA for its UUID first and takes the Vulkan device with it. Without a match the best kind is used, with a warning. Tests:the_preferred_gpu_wins_over_the_better_kind,device_numbers_split_like_glibc; the NVIDIA codec tests pass with it.
A18 · The VAAPI decoder's export cache is keyed by surface ID only¶
- Where:
crates/codec/src/vaapi.rs:977-979, 1147-1158,crates/render/src/vulkan/renderer.rs:1094-1099. - Problem: if FFmpeg rebuilds its frame pool, surface IDs are reused and old DMA-BUFs (old
size) are shown.
forget_importsis never called when a decoder is replaced. - Fix: key on (frames context, surface), clear on change; forget imports on decoder change.
- Status: fixed. The decoder holds a reference to the surface pool its exports belong to (so a
new pool can never reuse the old one's address); a new pool clears the exports and gets a new id
in the upper 32 bits of
Picture::DmaBuf's key. The renderer drops all imports when a key of another pool arrives, which also covers a replaced decoder. GPU test:a_new_stream_size_gives_new_pictures(crates/codec/tests/vaapi.rs, AMD runner).
A19 · A failed DMA-BUF export shows black frames forever¶
- Where:
crates/codec/src/vaapi.rs:1151,apps/client/src/lib.rs:1328-1334. - Fix: fall back to NV12 in
picture()or report an error that switches to the CPU path. - Status: fixed. A failed export makes the decoder hand out NV12 from then on (with a warning); the renderer takes either. Only the GPU can make an export fail, so there is no test for it.
A20 · The converter's fence is not reset after a timeout¶
- Where:
crates/gpu/src/convert.rs:564-571, 407-409,crates/render/src/vulkan/renderer.rs:907. - Problem: the next submit gets an already signalled fence; the wait returns at once and CUDA
may read a half-written buffer. The renderer waits forever (
u64::MAX). - Fix: reset after
wait_idleon the error path; one sharedsubmit_and_waitwith a limit. - Status: fixed.
fernsicht_gpu::submit_with(andsubmit_and_waitwithout semaphores) is the one place that submits and waits: at mostGPU_TIMEOUT(1 s; 30 s on a CPU device such as llvmpipe,timeout_for), and after a timeout it idles the device and resets the fence, so the next submit never gets a signalled fence. The renderer uses it too instead of waiting forever.
A21 · A partly failed swapchain creation leaks the swapchain¶
- Where:
crates/render/src/vulkan/window.rs:211-243. - Fix: build into a local value, clean up on error, then store.
- Status: fixed. Views and semaphores are built in one step; if it fails, they and the new swapchain are destroyed before the error returns.
A22 · Audio drift compensation is coarse; latency up to twice the documented value¶
- Where:
apps/client/src/lib.rs:1464-1515,crates/audio/src/jitter.rs:82-118. - Problem: the only corrections are dropping a 5 ms frame above depth 6 and 15 ms of
concealment after running dry: clicks or dropouts every minute or so, 30–35 ms instead of 15 ms
when the host's clock is faster. The sink is not prefilled, so the cushion is gone after start.
pa_simple_get_latencyreturnsu64::MAXon error and the addition overflows. - Fix: steer a smoothed depth with slight resampling (or crossfaded drop/insert); prefill with
silence; treat
u64::MAXas unknown. - Status: fixed.
fernsicht_audio::drift:DriftControlsteers the smoothed jitter depth to its target, andstretchplays a frame one sample shorter or longer (linear interpolation) when it strays more than a frame; the old drop-a-frame rule is gone. A simulation test holds the buffer within 5–25 ms for ten minutes at ±300 ppm. Prefilling turned out unnecessary: the controller restores the cushion the sound server's first buffer takes.pa_simple_get_latency's error value counts as 0, and the delay sum saturates. Native PipeWire stays D10.
A23 · Gamepad mapping misses common pads¶
- Where:
crates/input/src/gamepad.rs:74-99, 289-301. - Problem:
ABS_Z/ABS_RZalways become triggers; many generic pads put the right stick there. The X/Y swap only applies toxpad. - Fix: Z/RZ as right stick when RX/RY are missing; never merge two device axes; longer term SDL (D9).
- Status: fixed. Without RX/RY, Z/RZ are the right stick and BRAKE/GAS the triggers; each
protocol axis takes the first device axis that has it. The X/Y swap stays
xpad-only: the kernel's gamepad spec puts BTN_X north, and onlyxpadreports it west; there is no evidence of other drivers doing so. Tests:a_generic_pad_has_its_right_stick_on_z,one_device_axis_per_protocol_axis.
A24 · Gamepad axis coalescing reorders and drops events¶
- Where:
crates/input/src/queue.rs:76-97(the test at348-370locks it in). - Problem:
[DpadX=-1, A↓] + DpadX=0becomes[DpadX=0, A↓]: the d-pad tap vanishes. - Fix: coalesce only when no button event of the same pad follows; never coalesce d-pads.
- Status: fixed.
InputQueue::coalesces: only analog movement (no d-pad, no value at rest on either side, so taps of d-pad and digital triggers stay) and only when no button of the same pad follows. Test:taps_and_the_order_of_a_pad_survive_coalescing.
A25 · Gamepad focus policy is inconsistent¶
- Where:
apps/client/src/main.rs:804-814,crates/input/src/gamepad.rs:320-445,crates/input/src/queue.rs:128-144. - Problem: pads drive the host while the window is unfocused (and the local desktop sees the same presses), but focus loss releases held pad buttons. Steam's virtual pad doubles input.
- Fix: one policy (pads follow focus); skip known virtual pads.
- Status: fixed (focus). Pads follow the focus:
pad_events_while_focuseddrops their events while another window has it, and focus loss still releases them. Skipping Steam's virtual pad is left for D9 (SDL knows those devices; guessing their names here would be memory, not a source). The 2 s rescan for hotplug stays (inotify is a nicety). Test:gamepads_follow_the_focus.
A26 · A panic in the client thread leaves the window open¶
- Where:
apps/client/src/main.rs:662-666, 841,apps/client/src/lib.rs:747-750. - Fix: send
Donefrom a drop guard. - Status: fixed. The client thread holds an
OnDropguard that sendsDone, also while unwinding. Test:a_panicking_session_still_closes_the_window.
A27 · IPv6 cannot work¶
- Where:
apps/client/src/lib.rs:218, 667, 171-183. - Problem:
pair()andrun_withbind0.0.0.0:0; a v6 address fails. - Fix: bind the family of the resolved address.
- Status: fixed for sessions and pairing:
fernsicht_net::socket::connect_udpresolves the host and binds a socket of its family. Discovery stays IPv4 (broadcast; v6 would need multicast). Tests:the_local_side_takes_the_peers_family,talks_to_a_v6_host.
A28 · The receive-buffer warning is off by a factor of two¶
- Where:
crates/net/src/socket.rs:31-39. - Fix: compare against twice the requested size; as root use
SO_RCVBUFFORCE/SO_SNDBUFFORCE. - Status: fixed.
usablehalves what Linux reports before the comparison; with CAP_NET_ADMIN (the host service) the buffers are set withSO_RCVBUFFORCE/SO_SNDBUFFORCE, pastrmem_max. Test:the_usable_buffer_is_half_what_linux_reports.
A29 · Receiver statistics are skewed¶
- Where:
crates/net/src/reassembly.rs:206-210, 381-386. - Problem:
highest_frame_idcan go backwards; lost packets are counted ~8 frames late while received ones count at once; frames lost completely never count. - Fix: count on eviction consistently; monotonic highest ID.
- Status: fixed. The highest frame ID only moves forward. Frames of a gap that never arrived
count their packets as lost (like the last complete frame, at most
MAX_IN_FLIGHTframes, so a blackout does not read as heavy random loss); partly received frames book theirs when retired, which happens when the next frame completes, so the skew is small. The duplicate bookkeeping (needs_keyframevsRefChain, two loss filters) is gone with E1. Test:frames_that_never_arrived_count_their_packets_as_lost.
A30 · The app polls processes and can freeze the UI¶
- Where:
apps/client-ui/src/lib/api.ts:97-103,apps/desktop/src/share.rs:132-150,apps/desktop/src/main.rs:82-85,apps/desktop/src/backend.rs:340-343. - Problem: every 3 s
systemctlplus up to two--versionprocesses; the synchronoussessioncommand waits on a lock held while the old client stops (up to 3 s). - Fix: cache versions; make
sessionasync; stop the old client outside the lock. - Status: fixed.
version_ofruns a program again only when its file changed;connectstops the old client outside the lock (and one that raced it after storing);sessionruns off the main thread like the other commands.
A31 · Overlay values are mislabelled or dropped¶
- Where:
web/viewer/src/lib/host.ts:208-229,web/ui/src/components/latency-overlay.tsx:6, 50. - Problem: in the web viewer "Anzeige" is the jitter buffer delay, "Verlust (FEC)" comes from dropped frames (WebRTC has no FEC here), the codec defaults to "H.264". The app overlay drops the p95, max and RTT the client sends.
- Fix:
requestVideoFrameCallbackfor decode and present times; correct labels; show p95 and RTT. - Status: fixed. The web viewer takes decode and display from
requestVideoFrameCallback(frameTiming,meanTiming; the WebRTC statistics remain the fallback), shows "Verlust" without "(FEC)" and no codec before the host names it. The overlay shows p95, max and RTT where known (the app's client already sent them). Tests inhost.test.tsandlatency-overlay.test.tsx.
A32 · The session toolbar cannot be used with the keyboard¶
- Where:
web/viewer/src/routes/live.tsx:291-309,web/ui/src/components/menu-button.tsx:26-76,web/ui/src/components/segmented.tsx:141-160. - Problem: a window-level handler forwards Tab, Enter, Space and arrows to the host whatever has focus; menus lack focus handling and arrow keys.
- Fix: forward only when the screen has focus; APG menu and radio group patterns.
- Status: fixed.
keysGoToHost(pure): keys go to the host from the picture, the page and the on-screen keyboard's field, not from toolbar, menus, dialogs or other controls. A mouse click on the toolbar keeps the focus where it was (typing right after still goes to the host); Strg+Alt+T takes the keyboard into the toolbar, Esc gives it back. The menu follows the WAI-ARIA menu button pattern (focus into the menu, arrows, Home/End, Esc back to the button); the radio group is one tab stop with arrow keys. Tests inkeys.test.ts,session-view.test.tsx,primitives.test.tsx,live.test.tsx.
B. Security¶
The cryptography is sound (SPAKE2, Noise IK with separate keys per direction, AEAD with explicit counters, replay window checked before decryption). The gaps are in the logic around it.
B1 · The web PIN is also a pairing PIN; a started pairing survives close_pairing¶
- Where:
apps/host-agent/src/web.rs:825-844,apps/host-agent/src/lib.rs:596, 1132, 1154-1176. - Problem: the browser sends the PIN in clear over HTTP; the same PIN allows SPAKE2 pairing.
Step 3 never checks that pairing is still open, and
close_pairingkeeps the exchange. Someone who reads the PIN on the LAN can pair a device for good (root input). - Fix: separate the browser session from device pairing; in step 3 require the window still open and this exchange to be the one that used the PIN; drop the exchange on close.
- Status: fixed for passive listeners; the active attacker remains for D7. Each pairing window
has a generation; step 3 finishes only if the window it began in is still open, so a browser
using the PIN (which closes the window) kills a pairing begun with it, and the control loop
handles web requests before UDP packets, so a listener cannot get step 1 and 3 through first. An
attacker who intercepts and holds back the browser's HTTP request could still use the PIN: only
HTTPS or a PAKE in the page closes that (D7). Tests:
a_pairing_begun_before_a_browser_used_the_pin_is_refused,the_last_allowed_attempt_can_still_pair.
B2 · The control socket trusts a "desktop user" any local user can fake¶
- Where:
apps/host-agent/src/control.rs:155-162, 189,crates/core/src/desktop.rs:36-68, 97. - Problem: the desktop user is the lowest uid ≥ 1000 whose
/run/user/<uid>/wayland-0exists; every user owns their own/run/user/<uid>. Another local or SSH user can become the allowed uid and pair a device. - Fix: ask logind for the active session on seat0.
- Status: fixed. As root, the desktop's user is the user of the active session on seat0 from
sd_seat_get_active(libsystemd, loaded at runtime; its state file says "do not parse"), and only a regular user (uid ≥ 1000) counts. Files under/run/userare not looked at any more. Without logind or an active session there is no desktop user (only root and the service may use the socket). Tests:only_regular_users_sit_at_the_desktop,logind_names_the_active_user_if_there_is_one.
B3 · Weak handshake replay protection; the host streams before key confirmation¶
- Where:
apps/host-agent/src/lib.rs:718-719, 1054-1077, 1316-1382. - Problem:
sent_msis only compared with the last value in memory (lost on restart). A replay with a spoofed source replaces the live session and sends ~5 s of video at up to 80 Mbit/s to the victim from one 150-byte packet. - Fix: reject
sent_msfar from the host clock and persist the last value per key; replace the session and start sending only after the first authenticated packet from the client. - Status: fixed, in part differently: the link of a secure session sends nothing until the
client's first sealed packet (no amplification; the stream starts with a keyframe), and the last
handshake time per device is kept in
handshakes.jsonnext to the paired list (reset by pairing again). A window against the host clock was left out: it would lock out clients whose clock is off, and the persisted times already cover restarts. A live session can still be replaced by a handshake captured undelivered and replayed before the device's next connection. Tests:nothing_is_sent_before_the_client_proves_its_keys,handshake_times_outlive_a_restart_and_reset_on_pairing.
B4 · Unauthenticated discovery answers rewrite a paired host's address¶
- Where:
apps/client/src/lib.rs:198-210,apps/desktop/src/backend.rs:274-277,apps/client/src/discover.rs:4-6, 121,apps/host-agent/src/lib.rs:856-859. - Problem: any Announce with a paired host's (public) key is stored; the host's announce budget is global, so 20 Discover/s silence the real host.
- Fix: store an address only after a successful handshake; budget per source.
- Status: fixed. Discovery answers are shown and tried first (the app keeps them in memory),
never stored; the client stores a host's address after a session with it went through
(
remember_address). The host's answers are limited per asking address (20/s) and in total (200/s) withlimits::PerSource. Tests:an_address_is_kept_only_after_a_session_with_it,one_flooding_source_does_not_silence_the_othersand the otherlimits.rstests.
B5 · Unpairing does not end a running session¶
- Where:
apps/host-agent/src/lib.rs:613-630, 1045,apps/host-agent/src/control.rs:123-127. - Fix: unpair ends the session of that key; a control command to end the current session.
- Status: fixed. Unpairing bumps a counter; the control loop then checks the running session's
client key and ends it with Bye.
{"cmd":"disconnect"}(fernsicht-host-agent disconnect) ends the running session, a browser's too; a request while none runs does not hit the next one. Tests:unpairing_a_device_ends_its_session,the_host_can_end_the_running_session,disconnect_asks_the_host_to_end_the_session.
B6 · The web server can exhaust memory and threads of the root service¶
- Where:
apps/host-agent/src/web.rs:635-653, tiny_http 0.12 (no release since 2022). - Problem: unlimited header line length and count, no socket timeouts, a thread per connection.
- Fix: limits and timeouts (or a maintained server), bounded handlers.
- Status: fixed. tiny_http is gone;
apps/host-agent/src/http.rsis a small HTTP/1.1 server for what the viewer needs, everything bounded: head 8 KiB and 32 headers, body 64 KiB with a requiredContent-Length(no chunked), 10 s per request, 32 connections at once (more get 503), one request per connection. The head parser is a pure function. Tests inhttp.rs, andthe_host_serves_the_viewer_pagesends an endless header to the real host.
B7 · The client acts on unauthenticated packets¶
- Where:
apps/client/src/lib.rs:969, 991-1005. - Problem: a spoofed Reject ends a running session; the first Handshake reply consumes the initiator; any datagram resets the timeout.
- Fix: Reject only before the session; keep the initiator until a reply verifies; reset the timeout only on authenticated packets.
- Status: fixed.
Initiator::read_replytakes&mut self(snow restores its state when a message does not verify), so a forged answer is ignored; Reject counts only before the session; in a secure session only sealed packets that open and the verified answer reset the timeout. The e2e link can forge packets with the host's address now (forge_to_client). Tests:forged_packets_neither_stop_nor_end_a_secure_session,garbage_does_not_keep_a_silent_host_alive(both fail without the fix).
B8 · Handshakes are processed without a rate limit¶
- Where:
apps/host-agent/src/lib.rs:1038-1052. - Fix: token buckets per source and global; rate-limited logging.
- Status: fixed. Handshakes are processed at most 10 a second per address and 100 in total
(
limits::PerSource; a real client sends one every 250 ms at most); the warning about an unpaired device comes at most every 5 s per address. A cookie exchange under load (as WireGuard has) was not needed at these rates.
B9 · Anyone can use up the pairing attempts; no CSRF protection¶
- Where:
apps/host-agent/src/lib.rs:1132-1151,apps/host-agent/src/web.rs:680-727. - Problem: an attempt is counted before message 1 is validated; the limit is global;
/api/connectaccepts any Content-Type, Origin and Host (any website can spend the attempts, DNS rebinding can read/api/info); no security headers. - Fix: validate before counting; JSON content type and Origin/Host check; CSP, frame and nosniff headers.
- Status: fixed. Message 1 is checked for its form before an attempt counts; attempts are
counted per address (3) and in total (10), so one machine cannot close pairing for the others.
The API answers only a
Hostthat names this machine (host_allowed: an IP,localhost, the machine's name with or without domain);/api/connecttakes only JSON from the page's own origin (same_origin_post). Every answer has CSP,X-Frame-Options,nosniffandno-referrer; the built viewer was loaded under that policy in Chromium without errors. Tests inweb.rs,the_host_serves_the_viewer_pageanda_browser_gets_picture_sound_and_pointer_and_types.
B10 · The service unit is barely hardened; the firewall opens in the default zone¶
- Where:
packaging/fernsicht-host.service,packaging/install-host.sh:62-73,apps/desktop/src/share.rs:79-88. - Fix:
CapabilityBoundingSet,DevicePolicy=closedwithDeviceAllow,ProtectSystem=strict,ProtectHome=read-only,ProtectKernel*,RestrictNamespaces,SystemCallFilter,UMask=0077. - Status: partly fixed; the rest needs a live test as root. Added:
CapabilityBoundingSet(SYS_ADMIN, DAC_OVERRIDE, DAC_READ_SEARCH, SYS_NICE, NET_ADMIN),ProtectSystem=strict,ProtectKernelModules,ProtectKernelLogs,ProtectHostname,RestrictNamespaces,RestrictSUIDSGID,SystemCallArchitectures=native,UMask=0077(systemd-analyze verifypasses). Left out on purpose:DeviceAllow/ProtectClock(one entry closes DRM, NVIDIA and uinput),ProtectKernelTunables(the GPU clocks are in /sys),ProtectHomeand aSystemCallFilteruntil the service has run with them on both machines (handover, live test 1). The firewall rule stays in the default zone: with NetworkManager's defaults that is the zone of every network anyway.
B11 · The privileged install from the app¶
- Where:
apps/desktop/src/share.rs:57-89, 154-179. - Problem: pkexec runs an opaque
/bin/sh -c; root executes the user-writable AppImage; the extracted tree's permissions are not normalised;pkexeccomes fromPATH. - Fix:
/usr/bin/pkexec; normalise ownership and modes; extract without running the image. - Status: fixed. pkexec by its path; the AppImage is unpacked from a root-owned copy without
running it:
unsquashfsat the squashfs offset (elf_end, checked for thehsqsmagic), else a read-only loop mount of it (Bazzite has no unsquashfs), the AppImage runtime only as a last resort; the tree is then root's and writable by no one else. The unit text still comes from the app (the admin approves the installation as a whole); a dedicated polkit action with its own message would need a policy file installed first. Test:the_appimage_payload_starts_after_its_elf_runtime; the installation itself is in the live tests (handover, item 5).
B12 · Names chosen by the other side are used as identifiers¶
- Where:
apps/client/src/lib.rs:186-194,apps/desktop/src/backend.rs:339,apps/client/src/main.rs:220, 266,apps/host-agent/src/lib.rs:613-619,apps/host-agent/src/main.rs:155. - Problem: a paired host named like another host's address receives that connection;
unpair <name>removes the first match; control characters go straight to terminals. - Fix: pass and match by key; reject control characters in names.
- Status: fixed.
find_hosttries the key, then the address, then a name only if one device has it; the app passes--keywith the address it found (which the client now uses, instead of the stored one: it had made B4's discovered addresses useless).unpairtakes the key or a unique name. Names with control characters are refused when pairing (both sides), and what discovery shows in a terminal is made printable. Tests:a_name_cannot_pose_as_an_address_or_another_device,names_with_control_characters_are_refused.
B13 · Key file handling¶
- Where:
crates/secure/src/lib.rs:102-137,apps/host-agent/src/main.rs:199-205. - Fix: random temporary name with
O_EXCL|O_NOFOLLOW,fchmod0600, state directory 0700, warn on group/other access. - Status: fixed.
write_privatewrites a new file of a random name, created exclusively (create_new: never through a symlink) with 0600 from the start, then renames it; a missing directory is created 0700; a temporary file is removed on failure. Loading a key file or the paired list takes group and other access away (keep_private). Test:key_files_are_private_and_never_written_through_a_symlink.
B14 · Any local user can block the control socket¶
- Where:
apps/host-agent/src/control.rs:186-223, 52-55. - Problem: one connection at a time, uid checked after reading, timeout per read; falls back to
/tmp/fernsicht/. - Fix: check the uid first, total deadline, no
/tmpfallback. - Status: fixed. A caller who may not ask is answered at once without anything read; a request
line has 2 s in total (
read_line_within), so dripping bytes cannot hold the socket; withoutXDG_RUNTIME_DIRthe socket goes to/run/user/<uid>, never/tmp. Tests:a_caller_who_may_not_ask_is_turned_away_without_reading,a_dripping_caller_is_cut_off_in_time.
C. Performance¶
C1 · Input waits for the network loop¶
- Where:
apps/client/src/lib.rs:671, 891-901,RESEND_AFTER. - Problem: events go out only when the loop wakes (next packet or the 2 ms receive timeout): 0–2 ms extra per input, 1 kHz mice arrive in 2 ms clumps. Resends wait a fixed 15 ms although the LAN round trip is ~0.5 ms.
- Fix: send from the input path directly (socket and transport take
&self); resend after a multiple of the measured RTT. - Status: fixed.
InputHandle::pushsends what is due right away through the session's link (a clone of the socket and the session's transport, set when the session starts, cleared when it ends); the network loop only resends. The resend time is three round trips, 4–15 ms (resend_wait). Tests:input_goes_out_the_moment_it_is_pushed,resends_follow_the_round_trip.
C2 · Keyframe requests wait for the 100 ms feedback tick¶
- Where:
apps/client/src/lib.rs:926-952,apps/host-agent/src/lib.rs:1467-1469, 2080-2082,apps/host-agent/src/web.rs:340-366. - Problem: ~50 ms on average before the host even knows; requests (and browser PLIs) are not de-duplicated, so lossy Wi-Fi can cause keyframe storms.
- Fix: send an immediate request on gap detection (rate-limited); ignore requests within RTT + 2 frames of the last keyframe; later NACK and retransmit for keyframes in the LAN.
- Status: fixed. The client sends a KeyframeRequest (new packet kind 21: last
complete frame, round-trip time) the moment the need arises and again only when a keyframe
should have come (
crates/net/src/keyframe.rs). The host records the last keyframe it sent and skips requests it answers: newer than the client's last complete frame and within RTT + 2 frames - 10 ms; a client that completed a later frame lost it and gets another at once. Feedback's flag
and browser PLIs (RTT from str0m's receiver-report statistics) go through the same check.
NACK (2026-10-12): where the round trip and a 2 ms wait fit in a frame interval (a LAN), the
client asks for the data shards FEC cannot repair once a frame's packets went quiet (Nack, kind
22), for any frame, keyframes included; the host sends them again from its last four frames
(
fernsicht_net::resend), flagged as retransmissions and sealed afresh. Keeping a frame costs the send thread 2 µs for a 42 kB P-frame, 13 µs for a 250 kB keyframe, after it left. What is asked for counts as lost at once, so FEC goes on sizing for the loss on the wire. End to end, bursts of eight lost datagrams every 200 ms (half a 720p frame, beyond its recovery shards) cost no frame (shards_fec_cannot_repair_are_sent_again; without NACK each burst cost one and a keyframe). A frame asked for while its sender was only held up (a busy machine) gets its own packets after all: each takes its booked loss back, so the loss FEC and the bitrate see stays the wire's.
C3 · Fixed-rate pacing adds latency on cable and paces large frames least¶
- Where:
crates/net/src/pacer.rs:20-27, 46-48,apps/host-agent/src/lib.rs:1370-1373, 2132. - Problem: at 400 Mbit/s a 1080p P-frame takes ~0.5 ms longer than at line rate, 4K ~2 ms; frames above ~416 kB go out faster than the pacing rate. Recovery shards of earlier groups go before data of later groups.
- Fix: token bucket with a burst allowance; rate from measured capacity; data first, parity last.
- Status: fixed. The pacer is a token bucket (GCRA): 64 KiB at once,
the rest at the pacing rate, faster only when a frame would not leave within one frame interval
(the budget was half of one). The packetizer sends all data shards before the recovery shards.
On the clean 1080p60 scenario at 20 Mbit/s the network stage fell from 865 to 268 µs on
average (p95 938 → 319 µs). The rate is measured since D3 (2026-10-12): frames that fit in the
burst leave back-to-back, and the client reports the rate the slowest link spread them to
(
fernsicht_net::delivery, the median of each feedback interval); the host paces at it, at most--pace-mbit, at least four times the bitrate.
C4 · The FEC group layout is weak against bursts¶
- Where:
crates/net/src/fec.rs:25-27, 42, 185-234. - Problem: at most 64 data shards per group, filled in order: a burst of 8 losses in one group kills a 300 kB keyframe that one group with the same overhead would survive. The comment "smaller groups isolate burst loss" is backwards.
- Fix: one group per frame up to 512 shards (or interleaved shards); balanced groups.
- Status: fixed. One group per frame up to 512 shards; larger frames in balanced groups whose shards take turns on the wire (data, then recovery), no protocol change. A 250 kB keyframe is one group now: its recovery costs half (302 instead of 616 µs, one Reed-Solomon decode instead of three); packetizing it 14 µs more (45 µs, the larger transform). The minimum of 10 % recovery now covers bursts of that length anywhere in the frame.
C5 · Every bitrate change rebuilds the hardware encoder¶
- Where:
apps/host-agent/src/lib.rs:2067-2079,crates/codec/src/vaapi.rs:261, 467-470,crates/codec/src/nvidia.rs:194-217, 365-367. - Problem: NVENC: new CUDA context, Vulkan device, shader compile and import (estimate: hundreds of ms without picture); VAAPI: new display, pool, context and filter graph. Plus a keyframe, up to once a second, exactly when the network is congested.
- Fix: NVENC: change
bit_rate/rc_max_rate/rc_buffer_sizeon the open context (FFmpeg reconfigures); at least keep the GPU objects across a rebuild. - Status: fixed.
Encoder::set_bitratenow works on the running encoder for both. NVENC sets the rate on the open context; FFmpeg (8.1,reconfig_encoder) passes it toNvEncReconfigureEncoderwith the next frame, withresetEncoderandforceIDR, so a keyframe stays. Where the GPU cannot change the rate on the fly, that frame is no IDR: the encoder then opens a new codec context on the same CUDA context and screen input. VAAPI's rate control is set when FFmpeg opens the codec, so it gets a new codec context on the same display, frame pool and filter graph. The host rebuilds an encoder only for a new stream size. GPU tests: 20 → 5 Mbit/s mid-stream, one keyframe, one decoder throughout. GTX 1080: in place, the change and its frame take 3.4 ms; P-frames go from 41.7 to ~12.5 kB.
C6 · NVDEC pictures go through the CPU¶
- Where:
crates/codec/src/nvidia.rs:527-540, 591-606,crates/codec/src/ff.rs:168-184,crates/render/src/vulkan/renderer.rs:423-486. - Problem: download, copy into a
Vec, copy into staging, upload: ~0.8 ms at 1080p, ~3 ms at 4K (estimate). - Fix: CUDA → Vulkan interop (the existing convert path in reverse); interim: download straight into the mapped staging buffer.
- Status: fixed. NVDEC copies each picture on the GPU into memory allocated with CUDA's virtual
memory API and exported as a file descriptor (
cuMemCreate,cuMemExportToShareableHandle, as NVIDIA'ssimpleVulkanMMAPsample); the renderer imports it once as anOPAQUE_FDbuffer and copies the planes into its images, with queue-family handover from and back toQUEUE_FAMILY_EXTERNAL. No second Vulkan device in the decoder; the CPU path stays as the fallback (no export, another GPU). GTX 1080, 1080p: 0.30 ms to copy, convert and draw instead of 1.12 ms through the CPU.PictureKind::DmaBufis nowPictureKind::Gpu.
C7 · The NVENC input path waits on the CPU twice¶
- Where:
crates/gpu/src/convert.rs:540, 564,crates/codec/src/cuda.rs:286-288,crates/codec/src/nvidia.rs:248-269. - Fix: external semaphores between Vulkan and CUDA; no
cuStreamSynchronize; possibly no copy. - Status: fixed: no copy, and with it no
cuStreamSynchronize. NVENC's input frame now points at the buffer Vulkan writes and CUDA imported (rows aligned to 256 bytes as in FFmpeg's CUDA pool; FFmpeg registers the pointer once). Measured on the GTX 1080 first, per 1080p frame: Vulkan conversion with its fence wait 0.27 ms, the copy with its stream sync 0.11 ms, NVENC 2.14 ms. Now 2.26 ms median for all of it instead of 2.31 (p95 2.35 instead of 2.45). The Vulkan fence wait stays: an external semaphore would save only the thread's wake-up (some 10 µs), the conversion itself is GPU work NVENC has to wait for anyway.
C8 · The VAAPI encoder imports the screen buffer anew every frame¶
- Where:
crates/codec/src/vaapi.rs:675-817,docs/video.md("imports are cached"). - Fix: cache imported surfaces by (device, inode, layout) like
gpu::convert. - Status: fixed. The key moved into the capture crate (
DmaBuf::identity), used by both converters. VAAPI keeps the surfaces of the last four buffers; each cache entry holds its own reference, so an evicted surface lives on while the filter graph still uses it. GPU test: three buffers in rotation, a fresh descriptor per frame, three imports.
C9 · About 30 DRM ioctls per frame; pointer work before the frame is handed on¶
- Where:
crates/capture/src/kms.rs:303-361, 416-423, 471-530,apps/host-agent/src/lib.rs:1525-1533. - Fix: resolve property IDs once; read X and Y in one call; hand the frame on before the pointer.
- Status: fixed. The cursor plane's
CRTC_X/CRTC_Yproperty ids are found once with the plane; each frame reads both in oneget_properties(two ioctls) instead of a name lookup per property (oneGETPROPERTYper property until the name matched, for X and again for Y). The pointer leftFrame:FrameSource::cursor()is asked after the frame is in the encoder's slot, so the encoder no longer waits for it. Per frame there are now the vblank wait, the export (plane,GETFB2, one prime export and one close per buffer object) and the pointer (plane, properties). Not measured on a live KMS session (that needs root); the logic is unit-tested.
C10 · One syscall per packet, a spinning pacer, avoidable copies¶
- Where:
apps/host-agent/src/lib.rs:471-487,crates/net/src/pacer.rs:79-93,apps/client/src/lib.rs:1095-1096. - Problem: the send thread spins for the whole frame (6 % of a core at 1080p60, 23 % at 4K);
send_toon an unconnected socket; full-frame copies on both sides. - Fix: connected socket, UDP GSO (
UDP_SEGMENT) orSO_TXTIME;recvmmsgon the client; seal in place; swap buffers instead of copying. - Status: fixed, without the connected socket (the host's one socket also listens). The pacer
hands out chunks (packets due within 200 µs, at most one GSO call: 64 datagrams, 60 kB); the
host seals them straight into one buffer and sends it with
UDP_SEGMENT, falling back to one datagram per call where GSO fails and single sends work. Hot threads sleep with a 1 µs timer slack, so the pacer spins only the last 20 µs. The client enables UDP GRO and walks the run it receives; completed frames go to the decoder by swapping buffers. Send thread CPU per frame on the e2e scenarios: 1080p 170–230 → 93 µs, 4K at 80 Mbit/s 2.3 → 0.31 ms (14 % of a core → 2 %); network stage unchanged (1080p 280 µs; 4K 2.19 → 2.12 ms, which is the 400 Mbit/s pacing).
C11 · No DSCP/WMM or ECN marking¶
- Fix: video AF41 (WMM video), input, audio, pointer and feedback EF (WMM voice); later ECN as the signal for C/D3.
- Status: fixed. The host's socket marks EF by default and video datagrams
carry AF41 as a control message with the GSO send (
IP_TOS, orIPV6_TCLASSfor IPv6); the client's socket marks EF, the WebRTC socket AF41. A loopback test reads the marks back. ECN (2026-10-12, D3): the native client's video is ECT(0); the client reads CE marks (IP_RECVTOS,IPV6_RECVTCLASS) and reports their count, which the host takes for congestion.
C12 · The web viewer sends input on a reliable, ordered channel¶
- Where:
web/viewer/src/lib/host.ts:94,apps/host-agent/src/web.rs:345-346,apps/host-agent/src/lib.rs:1651-1653, 1718-1728. - Problem: one lost SCTP packet stalls every later move, key and pointer position.
- Fix: a second unordered channel without retransmits for moves and pointer positions.
- Status: fixed. The viewer opens
moves(ordered: false, maxRetransmits: 0) for mouse moves (absolute and, captured, relative); clicks keep their position on the reliable channel, in order with the button. Positions are numbered from one counter across both channels and the host drops one older than the last applied. Touch input stays on the reliable channel (its taps need the position before the button).
C13 · The web viewer's pointer re-renders React every frame¶
- Where:
web/viewer/src/routes/live.tsx:183-196, 326-346. - Fix: skip unchanged positions; move the canvas through a ref; cache the rect; skip known shapes.
- Status: fixed. Position and image live in the pointer effect, not in React state: a packet
places the canvas through its style, only when the placement changed. The box's size is read on
resize (
ResizeObserver), the picture's on the video'sresize; a repeated shape is not put together and drawn again. The placement is a pure function (placement) with unit tests.
C14 · Pointer lock is not tuned for games¶
- Where:
web/viewer/src/routes/live.tsx:255-279. - Fix:
requestPointerLock({ unadjustedMovement: true })with fallback and.catch();pointerrawupdatewhere available. - Status: fixed.
lockPointerasks for raw movement, falls back to the plain lock and swallows a refusal. The viewer listens topointerrawupdateandpointermove; once a raw update came, moves are taken from those alone. Raw updates need a secure page, so over plain HTTP in the LAN they wait for D7 (HTTPS).
C15 · Unchanged screens are encoded every refresh¶
- Where:
crates/capture/src/kms.rs:671-685. - Fix: skip when framebuffer and buffer are unchanged; a slow refresh frame for quality.
- Status: fixed. Frames say whether they changed: KMS compares the framebuffer id with the last, but trusts that only after a buffer came back after others (a flipping compositor), so a desktop drawing into the shown buffer is never frozen. The capture loop encodes a change at once, keeps encoding for 0.5 s after the last one, then one frame a second; a keyframe request is answered at once. Feedback intervals without packets no longer count as a clean network for the bitrate and FEC. Protocol test: 1–4 frames in two still seconds instead of 120.
C16 · Thread priorities are weak and miss the input path¶
- Where:
crates/core/src/thread.rs:8-24,apps/host-agent/src/lib.rs:712,apps/host-agent/src/web.rs:203. - Fix: SCHED_FIFO (low priority) or RR for capture, send and control as root; timer slack 1 ns; raise the WebRTC thread.
- Status: fixed.
make_current_hotasks forSCHED_FIFOat priority 5 (withSCHED_RESET_ON_FORK), falls back to nice −10, and reports what it got; a test checks the scheduler agrees. Hot now as well: the host's control loop (input), both WebRTC threads, the client's network loop and its gamepad reader. Timer slack is 1 µs for normal threads (C10); real-time threads have none. The client gets real time only where the user may (anRLIMIT_RTPRIO); asking rtkit over D-Bus is left open.
C17 · "Glass-to-glass" ends at vkQueuePresentKHR¶
- Where:
apps/client/src/lib.rs:1336-1340,crates/core/src/latency.rs:68,crates/render/src/vulkan/renderer.rs:907. - Problem: the compositor and the wait for the refresh are missing, so "show immediately" can't be measured and the comparison with Sunshine is skewed. The fence wait delays the present.
- Fix:
VK_KHR_present_id+VK_KHR_present_wait; present right after submit. - Status: partly fixed. The window presents right after the submit: the renderer no longer
waits for its fence there (
fernsicht_gpu::submitandwait_fence); the next frame'spreparewaits, andPresenter::settlebefore the decoder's next picture, so it never writes memory the GPU still reads. Open: measuring the moment a frame is on screen withVK_KHR_present_wait(the GTX 1080 has it, llvmpipe not). It needs a window on a real display to test, which is not opened without the user's OK.
C18 · Allocations on hot paths¶
- Where:
apps/client/src/lib.rs:1043-1048,crates/audio/src/jitter.rs:60,crates/input/src/gamepad.rs:135,crates/input/src/queue.rs:189-197,crates/input/src/uinput.rs:225. - Problem: audio copies four times per 5 ms packet (~800 allocations/s);
architecture.mdandperformance.mdsay nothing allocates. - Fix: fixed buffers moved into the jitter buffer; arrays elsewhere.
- Status: fixed. Sound packets carry their frames in fixed arrays (
Encoded, at mostMAX_AUDIO_FRAME); the jitter buffer is a ring of 32 fixed slots and lends the frame it plays; the newest capture time is one value, not a map.InputQueue::duelends a reused buffer, the pad state is bit sets and an array and hands its events to a callback, uinput translates into a fixed list and writes from the stack. The video path was without allocations already.
C19 · Packet size on VPN tunnels¶
- Problem: 1,400-byte datagrams fragment on WireGuard (1420) and Tailscale (1280).
- Fix: shard size from
IP_MTU(the reassembler already takes it from each packet). - Status: fixed. The send loop asks the route's MTU towards the client (
socket::path_mtu: a socket connected to the peer,IP_MTU/IPV6_MTU, nothing sent) and sizes its shards for it (datagram_for,fec::shard_size_for). Pointer pieces, sound and control packets stay below 1,232 bytes anyway. Unit tests cover WireGuard and Tailscale over IPv4 and IPv6; a live VPN was not at hand.
C20 · Clock sync without skew¶
- Where:
crates/net/src/clock_sync.rs. - Fix: kernel receive timestamps (
SO_TIMESTAMPNS); fit offset and skew. - Status: fixed, without the kernel timestamps. The window holds 64 samples (half a minute);
the best samples of its older and newer half give the skew (at most ±200 ppm, once they are 5 s
apart), and
offset_at(now)carries the best recent offset forward. Simulated 50 ppm with 0–1 ms queueing each way: 37 µs off instead of 1.1 ms from the best, stale sample. Kernel timestamps come only in wall-clock time (SO_TIMESTAMPNS); converting them to the monotonic clock adds its own error, and the min-RTT filter already drops the samples a late wake-up spoils.
D. Strategic steps¶
| ID | Step | Why |
|---|---|---|
| D1 | Vulkan Video encode and decode instead of FFmpeg | One path for AMD and NVIDIA, no CUDA hop, no NVDEC copy; the app manages references: reference frame invalidation instead of keyframes, intra refresh, slices |
| D2 | NVENC intra-refresh and ldkfs until then |
No keyframe bursts after loss on NVIDIA hosts |
| D3 | Delay- or ECN-based congestion control (L4S) | Reacts before packets are lost |
| D4 | HDR and 10 bit (P010, HEVC Main10, AV1 10 bit) | AB30 is cut to 8 bit and tagged sRGB today: HDR desktops look washed out |
| D5 | Signal chroma siting | Colour fringes on text in browsers |
| D6 | Local hardware pointer in desktop mode | The pointer feels free of latency |
| D7 | HTTPS for the web viewer | Keyboard Lock, pointerrawupdate, clipboard and wake lock need a secure context; Keyboard Lock does not work over HTTP today |
| D8 | Capture on commit; align capture to the client's refresh | 1–3 ms (plausible) |
| D9 | SDL3 for gamepads | Mapping database, rumble, gyro, touchpad ✅ |
| D10 | Native PipeWire audio with adaptive resampling | Smaller buffers, no drift clicks |
Status: D5 is done. Both conversions site chroma left (the NVENC converter filters 1/4, 1/2,
1/4 around the even column; scale_vaapi with out_chroma_location=left), the encoders signal
it (FFmpeg's NVENC writes no chroma location, and left is the default), and the renderer samples
chroma a quarter texel right; GPU tests put a red column at an odd x and check where it lands,
and the VAAPI test reads the location back from the stream. The other D items are open.
Order for the rest, most benefit first (decided 2026-10-11): D6 (the pointer is what feels slow), D2 (no keyframe bursts on NVIDIA hosts, a bridge to D1), D1, D8, D10, D3, D7 (first the browser flag for plain-HTTP origins, with a hint in the viewer: real certificates need a server of ours), D4, D9. D6, D2, D1, D10, D3 and D9 are done; D8 was measured and set aside; D7 has its first step, D4 its first two (10 bit, HDR tone-mapped; HDR displays are open). D9 left for later: rumble in the web viewer, the DualSense's adaptive triggers and lightbar.
D6 is done. KMS does not tell where in the pointer image the pointer's tip is, so the host learns
it: once the pointer rested after absolute input, the tip is where input put it, against the
image's position (pointer::hotspot, per image). The Cursor packet carries it (flag 2, 4 bytes
more). With it the window sets the image as its own system cursor (winit CustomCursor, scaled
like the picture) and the presenter leaves it out of the picture; the web viewer sets it as the
video's CSS cursor. Until the tip is known, or with the pointer captured, nothing changes.
D2 is done, as a choice: the client asks for intra refresh in Hello (--smooth, the app's
“Nach Verlusten: Flüssig”), the host agrees only with NVENC and says so in HelloAck. NVENC then
encodes with intra-refresh over a 0.5 s period (gop_size) and ldkfs; the client's RefChain
decodes on across a gap after the first keyframe instead of asking for one. The default stays
keyframes: they repair the picture sharp in one round trip, refresh smears it for up to half a
second. Tests: a GTX 1080 encode with frame 20 dropped is back above 30 dB PSNR by frame 119 with
no keyframe after frame 0 and no frame over 4× the median; end to end a 300 ms blackout leaves no
frame waiting for a keyframe with refresh, and frames waiting without it. Left: a bitrate change
still costs an IDR frame (FFmpeg resets NVENC with forceIDR for it), so the rate step after a
burst of loss brings one keyframe back; avoiding it needs NvEncReconfigureEncoder called without
FFmpeg, or D1.
D1, first part (decided 2026-10-11: FFmpeg's Vulkan codecs first, our own encoder with reference
frame invalidation later): the client decodes with Vulkan Video. FFmpeg's Vulkan decoder runs on the
window's own device (fernsicht-gpu creates it with the video queues, timeline semaphores and
synchronization2; FFmpeg gets our instance, device and queues and takes our queue locks), and the
renderer samples the decoded image in place, waiting for and signalling its timeline semaphore.
"Auto" tries it first, then VAAPI and NVDEC. On the GTX 1080: 0.07 ms to convert and draw a 1080p
picture against 0.31 ms for NVDEC's CUDA copy, the same decode time (same engine), no decode errors
through a 300 ms blackout with and without intra refresh. Tests: the renderer draws Vulkan-decoded
H.264 and HEVC like the CPU path, and end to end NVENC → Vulkan Video at 1080p60. It needs FFmpeg
7.1 at runtime.
D1, second part: the host encodes with Vulkan Video (--encoder vulkan). The converter writes NV12
into a buffer that stays on the device and, in the same submission, copies it into an image of
FFmpeg's encoder pool, waiting for and signalling its timeline semaphore; no CUDA. On the GTX 1080
at 1080p: 1.7 ms from the screen's DMA-BUF to an H.264 frame against 2.25 ms with NVENC through CUDA,
and 3.6 ms glass-to-glass (without the display) with Vulkan Video on both sides against 4.8 ms with
NVENC and NVDEC. On the RX 7800 XT (RADV) the same tests run: 1.7 ms from the DMA-BUF for H.264 and
HEVC, keyframes 1.2 frames' worth (H.264) and below one (HEVC), decoding 0.8–1 ms. AV1 is not
offered: FFmpeg 8.1's Vulkan AV1 encoder writes the aligned surface size as the frame size
(1920×1088 on RADV), and AV1 has no cropping. So auto stays with VAAPI on AMD as well: from the
screen, Vulkan Video's HEVC (1.71 ms, p95 1.77) is only a little faster than VAAPI's AV1 (1.75 ms,
p95 2.53), and AV1 needs 10–20 % less bitrate for the same picture. Two details from FFmpeg's source: the video profile comes from sw_pix_fmt
(vulkan_encode.c), and HEVC's picture order count is sized to the GOP, at most 16 bits
(vulkan_encode_h265.c), so the "endless" GOP is 2^15 frames. NVIDIA's driver refuses the
ultra-low-latency tuning, so the encoder takes the lowest-latency one the driver offers. auto
stays with VAAPI/NVENC for now: Vulkan Video's keyframes are about 1.6 frames' worth (NVENC keeps
to one) and intra refresh is NVENC's. Tests: H.264 and HEVC round trips from CPU pictures and from
DMA-BUFs (colours against BT.709), keyframes on request and a bitrate change, and end to end with
Vulkan Video on both sides through 1 % loss.
The AppImage gets FFmpeg 8.1: the release still builds on Ubuntu 24.04 (glibc 2.39, so it starts
on Ubuntu 24.04, Mint 22 and Debian 13 as well), whose FFmpeg 6.1 is too old, and builds its own
(packaging/ffmpeg.sh: pinned sources, only what Fernsicht uses, LGPL, 5 MB, cached per version).
The GPU tests pass against that build as well.
D8, measured and set aside (decided 2026-10-12). On zentrale (RX 7800 XT, KWin, 165 Hz) a new
framebuffer shows on the primary plane a median 1.1 ms before the vblank (p10 0.9, p90 3.2, max 5.9
ms; crates/capture/examples/commit_lead.rs, run by hand with the Measure workflow). Capturing on
commit would win about that, at the cost of reading the plane every 0.2 ms (some 5,000 calls a
second) and of waiting for the compositor's render fence first (the buffer may still be drawn when
it is committed), which takes back part of it. On NVIDIA hosts it cannot work at all: nvidia-drm
answers both vblank paths (WAIT_VBLANK and CRTC_GET_SEQUENCE) with EOPNOTSUPP, so their KMS capture
paces by timer. Aligning to the client's refresh is not pursued either. D10 comes first.
D10 is done (2026-10-12). Measured first (crates/audio/examples/sound_latency.rs, silence): through
pipewire-pulse the client's sound sat about 21 ms in the server whatever it ordered (1, 2 or 4
frames, or node.latency through PULSE_PROP): four quanta of 256 samples, pipewire-pulse's own
buffer per stream. The client now plays through a native PipeWire stream (fernsicht_audio::pipewire,
libpipewire loaded at runtime; the SPA format pod is serialized by hand, PipeWire builds those with
inline header functions) that asks for a 5 ms quantum and is fed from a ring kept one frame ahead:
8.3 ms after the client (p90 9.7), about 13 ms less, without a single silent frame even with every
core busy. PulseAudio's API stays the fallback. The host's capture stays on it: it holds about 3 ms.
"Adaptive resampling" was already done in A22 (the drift control stretches frames). Tests: the
format pod and the struct layouts against PipeWire's headers; a real stream, opt-in
(FERNSICHT_SOUND=1, it plays silence; CI has no PipeWire session).
D3 is done (2026-10-12). Feedback carries three signals more. The queue delay: each frame's
first packet against its encoding time, on two clocks whose offset cancels against the lowest of
the last 10 s (fernsicht_net::delay); the interval's average above it is what frames waited in a
queue. CE marks: the native client's video is ECN-capable, and the client counts the packets a
router marked. And the delivery rate the host now paces at (C3). The rate controller lowers the
bitrate hard from 50 ms of queue, gently from 15 ms or a CE mark, holds it while 5–15 ms build, and
counts the link clean below 5 ms; the thresholds stand clear of Wi-Fi's own jitter. End to end
through a 6 Mbit/s bottleneck whose queue never overflows, 10 Mbit/s of video went below 6 Mbit/s
with nothing lost; through a 9 Mbit/s one that marks CE from 2 ms of queue, the marks reached the
host and lowered the bitrate, again without loss. Not seen live yet: an AQM that marks (fq_codel
with ecn, CAKE) between two of our machines. Not L4S: that needs the scalable reaction (a cut in
proportion to the share marked, every round trip), which a bitrate that changes at most once a
second cannot give.
D7, first step (2026-10-12): the browser flag. Over the LAN's plain HTTP, Chrome and Edge keep
Keyboard Lock and pointerrawupdate to secure pages, but treat an origin listed in
unsafely-treat-insecure-origin-as-secure as one. The connect page tells desktop Chrome and Edge
(not Firefox, Safari or phones, where it unlocks nothing) the flag and the page's address to enter
(web/viewer/src/lib/secure.ts, a pure function of secure context, origin and user agent). Open:
real HTTPS, which needs a certificate the browser trusts for a LAN address, so a server of ours
(or the user's own CA); with it the PIN no longer crosses the LAN in clear either (B1's active
attacker).
D4, first step (2026-10-12): 10 bit. A colour type (fernsicht_core::colour: depth, primaries,
transfer) goes from the capture (the framebuffer's fourcc) through the encoders into the stream's
VUI and back out of every decoded frame to the renderer. A 10-bit desktop comes in 10 bit where the
client's decoder takes HEVC Main 10 or AV1 at 10 bit (the Hello's options byte names those codecs;
VAAPI: the Main 10 profile and 10-bit 4:2:0 surfaces, NVDEC: cuvidGetDecoderCaps at 10 bit, Vulkan
Video: the driver's capabilities for the profile): the converters write P010 (the compute shader
two words per luma row of a block, scale_vaapi format=p010), NVENC, VAAPI and Vulkan Video encode
it, and the decoders hand it on as P010 DMA-BUFs, CUDA exports with 16-bit rows or 10X6 images,
which the renderer samples as 16-bit words. The shader takes range and matrix as push constants
(BT.709 or BT.2020). Tests: the converter writes P010 in either matrix (GTX 1080); NVENC and Vulkan
Video HEVC Main 10 from an XR30 buffer decode to the right colours through NVDEC and the Vulkan
decoder, in 10-bit SDR and HDR10; the renderer shows P010 from the CPU, from NVDEC and from the
Vulkan decoder right; VAAPI's HEVC and AV1 at 10 bit from AB30 (on the AMD runner). Open: HDR
itself (reading HDR from KMS, PQ on the client), and the web viewer stays 8 bit.
D4, second step (2026-10-12): HDR. The capture reads the connector's HDR_OUTPUT_METADATA
(the EOTF byte of hdr_output_metadata, drm_mode.h) and Colorspace when it opens and once a
second (fernsicht_capture::hdr): PQ means BT.2020 and PQ, the frames carry it, and the encode loop
gets a new encoder when it changes. The stream is tagged BT.2020/SMPTE 2084 (scale_vaapi with the
same primaries and transfer in and out, so the video processor applies the matrix only). The
client's shader tone-maps PQ for an SDR window: ST 2084's EOTF, light relative to 203 nits (BT.2408),
identity up to 80 % of it and a rational curve with the same slope there up to a 1,000-nit peak,
BT.2020 → BT.709 (BT.2087) with what lies outside clipped, sRGB; fernsicht_render::colour::pq_to_sdr
spells it out and the renderer test checks the shader against it. The overlay says “10 Bit” and
“HDR”. Open: an HDR10 swapchain for HDR displays (needs VK_EXT_swapchain_colorspace and the
compositor's colour management; to be seen on a real HDR monitor), HLG, mastering metadata, and
the web viewer (it gets the stream tagged as it is, in 8 bit; untested in browsers). Not seen live:
an HDR desktop captured (bazzite's and zentrale's monitors have not been tried in HDR).
D9, first step (2026-10-12): SDL3 for the client's gamepads, and rumble. The client loads
libSDL3.so.0 at runtime (fernsicht_input::sdl; evdev stays the fallback where SDL3 is
missing): one thread waits in SDL_WaitEventTimeout, reads every pad's standardised buttons and
axes, and puts them on the evdev codes the existing PadState translates, so deduplication and
the release of an unplugged pad stay as they were. SDL names pads from its database, so our own
virtual pads are told apart by the kernel's name behind SDL_GetGamepadPath. Rumble: the host's
virtual Xbox 360 pads offer FF_RUMBLE with 16 effects; a thread per pad answers the uploads
(UI_BEGIN_FF_UPLOAD/UI_END_FF_UPLOAD, linux/uinput.h) and turns EV_FF play and stop into a
Rumble packet (kind 23) to the native client, which plays it with SDL_RumbleGamepad. Tests: a
virtual SDL gamepad (SDL_AttachVirtualJoystick) presses, pulls a trigger, is rumbled and
unplugged; in CI a uinput pad gets a rumble effect uploaded through EVIOCSFF and played, as a
game does; the fake host's rumble reaches the pads. Open: rumble in the web viewer
(vibrationActuator).
D9, SDL3 in the AppImage (2026-10-12): the release builds SDL 3.4.18 itself
(packaging/sdl3.sh: pinned tarball and sha256, only joystick, gamepad, HIDAPI, haptic and sensor;
libudev loaded at runtime, so it needs nothing beyond glibc; 3 MB) and puts it in the AppImage's
usr/lib. The client loads the copy in ../lib beside itself first, then the system's, so the
AppImage has SDL's database and rumble on Ubuntu too and the same SDL everywhere. The minimal build
passes the virtual-pad test (crates/input/tests/sdl.rs); CI builds it too and runs the SDL tests
against it (FERNSICHT_REQUIRE_SDL).
D9, gyro and touchpad (2026-10-12): a PlayStation pad (SDL's SDL_GAMEPAD_TYPE_PS4/PS5) is
announced with a new input event, PadModel (8); its sensors go as PadMotion (9: gyro in
1/16 °/s, acceleration in 1/8192 g, SDL's axes, at most every 4 ms, an unsent reading replaced by
the newer) and its touchpad as PadTouch (10: two fingers, 0..65535), its click as the button
BTN_TOUCH. The host makes a DualSense of it through /dev/uhid (fernsicht_input::uhid, the
reports in fernsicht_input::dualsense, after the kernel's hid-playstation.c, SDL's
SDL_hidapi_ps5.c and inputtino): USB report descriptor, the calibration, pairing and firmware
feature reports the driver asks for at probe (the calibration chosen so that raw values are the
protocol's units, which the kernel and SDL both accept), the 64-byte input report per change, and
rumble from the output report's motor bytes. Without uhid it stays an Xbox pad. Tests: the reports
byte by byte against the kernel's layout, the calibration through the kernel's and SDL's formulas;
a virtual PS5 pad in SDL sends motion, touch and click; in CI SDL's own PS5 driver reads the
DualSense through hidraw (as Steam does), sees button, gyro and touch as sent and rumbles it. Where
the kernel's hid-playstation loads (not CI's kernel), a test reads it back through its gamepad,
motion and touchpad devices and rumbles it by a game's EVIOCSFF. Not done: the adaptive triggers,
lightbar and player LEDs (the output report carries them; nothing on the client plays them yet).
E. Clean code and consistency¶
| ID | Finding | Where | Status |
|---|---|---|---|
| E1 | Files too large: host lib.rs 2,450 lines, client lib.rs 1,800, a 410-line window module in client main.rs; the Link abstraction leaks; native and web send loops duplicate statistics |
apps/host-agent/src/lib.rs, apps/client/src |
fixed: host lib.rs and web.rs and client lib.rs split by topic, the window in window.rs; Link sends audio and monitors, sizes shards, opens sealed packets and confirms the client itself, nothing outside link.rs matches its variants; both send loops count through throughput.rs; one RefChain (in fernsicht_net::keyframe) for the reassembler and the present thread, one loss filter for FEC and the bitrate. The long functions are steps now: the control loop (dispatch.rs), start_session, the client's network thread, present_loop, run_with and main; none is over ~100 lines |
| E2 | Untyped contracts: overlay JSON by hand (json.pop()), errors matched by substring, hand-mirrored TypeScript types, data channel messages cast |
apps/client/src/lib.rs:1378, crates/render/src/overlay.rs:91, apps/client-ui/src/lib/api.ts, web/ui/src/types.ts |
fixed: the overlay's numbers are fernsicht_render::overlay::Stats and the client's line for the app AppStats with a typed MonitorList (no json.pop()); the web viewer's messages, /api answers and error codes are serde types (FromBrowser, ToBrowser, WebError), and so are the control socket's requests and answers; failures carry a FailureCode from the client through the desktop app to the UI, which says each code in its words (no substring matching). The TypeScript types of all of them are generated by ts-rs (apps/desktop/tests/bindings.rs fails when they are stale); the viewer checks what arrives on the data channel and from /api at runtime instead of casting |
| E3 | Duplicated Rust code: spirv() 2× (and naga at runtime instead of build.rs), err() 3×, hostname() 2×, Hw built twice, audio summary twice, own submit-and-wait in the renderer |
crates/gpu, crates/render, apps/client |
fixed: the shaders compile in build scripts through the new fernsicht-shader crate (naga is a build dependency only; ash::util::read_spv loads the result), one err() in fernsicht-gpu, hostname() in fernsicht_core::sysinfo, Hw::of, one summary_of for the sound; the renderer already submits and waits through fernsicht_gpu (C17) |
| E4 | Duplicated frontend code: PIN field, device ID field, mode list, "session ended" screen, mode search validator, two error tables, formatDeviceId |
web/viewer/src/routes/connect.tsx, apps/client-ui/src/routes/devices.tsx |
fixed: @fernsicht/ui has PinField, DeviceIdField (with isPin, isDeviceId, deviceIdDigits), SESSION_MODES, SessionEnded, sessionSearch and PAIRING_ERROR_TEXT; the viewer's connect page and session, the app's devices page and session, both routers and both error tables use them |
| E5 | Stale comments: "phase 3" (tag, bitrate), "until the VAAPI backend lands", "H.264 only" in codec docs and features, NVENC "test pattern only", "i.e. VAAPI", DecodedFrame "will hand out a texture", "one lost frame per hour", rate test says 10 % for 30 %, HostDescription hard-codes "· H.264" |
many | fixed: the datagram size, the synthetic codec, DecodedFrame, the encoder kinds, KMS capture, the codec crate's and the features' docs, the UI's placeholders and demo data say what is true now; the host describes itself with the best codec its GPU encodes (vaapi::encodes, nvidia::encodes) or the GPU's name alone, not "· H.264". "About two lost frames an hour" (FEC) and the scenario's 10 % floor were already right |
| E6 | Docs vs code: video.md import cache, "nothing allocates", app.md connect dialog and search placeholder, --app help misses keys and monitor, command-line.md misses discover --wait and pair --name, web viewer Keyboard Lock over HTTP |
docs/ |
fixed: video.md's import cache was already right (C8); the allocation claim names the web viewer's one copy per frame; app.md has the app's search placeholder and says the connect dialog is the browser demo's, for a device ID; --app's help lists keys and monitor; command-line.md has discover --wait and pair --name; the web viewer's docs, status.md and the code say Keyboard Lock needs HTTPS, so not over the LAN's plain HTTP |
| E7 | Inert and dead UI and code: toolbar clipboard, files and settings buttons; favourites can't be set; unpair_from_host unused; HostInfo.pairing, resetZoom, Slot::take_timeout, Slot::is_closed, CaptureError::Stopped, InputQueue::held unused; fullscreen button on iPhone |
various | fixed: the toolbar's clipboard, files and settings buttons and the favourites (filter and field) are gone, the modes' hints no longer promise files and clipboard; the settings list the host's paired devices with “Entfernen” (unpair_from_host); the viewer says when the host shows no PIN (HostInfo.pairing) and shows no fullscreen button where pages cannot go full screen (iPhone); resetZoom, Slot::take_timeout, Slot::is_closed, CaptureError::Stopped and InputQueue::held are removed |
| E8 | Client terminal output still German; --height/--fps without help; 0.005 instead of FRAME_MS |
apps/client/src/main.rs |
fixed: what the client and the host print in a terminal is English, like their docs (the app's interface and the overlay stay German); --height and --fps have help; the sound's seconds come from FRAME_MS |
| E9 | Unknown flag bits ignored by some packets and rejected by others; FecConfig.redundancy not validated |
crates/proto, crates/net/src/fec.rs |
fixed: each packet kind names its flag bits (Kind::known_flags) and Packet::decode refuses any other bit for every kind; Packetizer::new refuses a redundancy outside 0..=max_redundancy (and NaN) and a loss outside 0..=1 |
| E10 | CI: actions on v4 (latest v7, Node 20 deprecated), not pinned by SHA, no permissions in ci.yml, appimagetool from continuous without checksum, runs twice for PRs, no path filter between Rust and web, coverage repeats the tests, UIs built three times, Playwright browsers not cached |
.github/workflows, packaging/appimage.sh |
fixed: every third-party action is pinned to a commit of a release at least two weeks old (checkout v7, setup-node v7, upload-artifact v7 and the rest; the owner's reusable workflows stay on @v1); ci.yml asks for contents: read only; appimagetool is release 1.9.1, checked against GitHub's digest; pushes run CI on main and the session branch only, other branches through their pull request, so not twice; scripts/ci-changes.sh skips the Rust jobs for web-only changes and the other way round; the workspace's tests run once, measured for coverage (end to end included); the UIs are built once and handed to the desktop job and the browser tests; Playwright's browser is cached. scripts/check.sh runs all CI checks that need no GPU before a push |
| E11 | Dependencies: Playwright 1.56 (1.64), Tauri crate 2.11 vs npm 2.12, libloading 0.8 (0.9), resolver 2 (3), MSRV not checked, no JS linter (react-hooks rules unchecked) |
package.json, Cargo.toml |
fixed: Playwright 1.63, the Tauri crate on 2.12 like @tauri-apps/api, libloading 0.9 (ash still brings 0.8), resolver 3; CI builds the workspace with its rust-version (1.88) and the desktop app with its own (1.90, which Tauri 2.12 needs); Biome formats and lints the web code with React's hook rules, Prettier is gone and there is no ESLint (TypeScript 7, which typescript-eslint does not support) |
| E12 | Naming: fernsicht-host-agent vs fernsicht-host.service; apps/client-ui vs web/*; "Windows-Taste" on a Linux host |
various | fixed: the service is fernsicht-host-agent.service like the program (an install or update removes the old fernsicht-host.service, the app still sees one until then, uninstalling removes both); the app's UI lives in web/client-ui with the other web parts; the keys menu says Super key (KDE: Meta) |
| E13 | Classes in the web viewer against the functional style (CLAUDE.md): CursorAssembler, TouchController, PadTracker, WheelAccumulator as pure step functions or closures; ConnectError as an error value |
web/viewer/src/lib/{cursor,touch,gamepad,input,host}.ts |
fixed: the wheel and the gamepads are pure steps (wheelStep, padStep), the cursor assembler and the touch controller closures (createCursorAssembler, createTouchController), and connect returns { session } | { error } instead of throwing a ConnectError. The only classes left are the test doubles for the browser's WebRTC classes |