The video data port was never open, and nothing could tell us #360

Merged
enricobuehler merged 2 commits from worktree-data-plane-firewall-and-delivery-truth into main 2026-08-20 18:48:32 +00:00
Owner

A field host streamed 1919 frames into a black screen while its own log blamed the client. Four faults, each of which alone makes the failure invisible.

The bug

The Windows firewall rules are localport=-scoped (47998-48010, 9777, 5353), but the media data plane binds an ephemeral port per session. No such rule can ever cover it, so Windows Firewall drops the client's hole-punch on every session on every Windows hostpunched=false on the "data plane bound" line, in all six sessions across two field logs. The punch then never opens the return path, and video is sent blind to an address the client merely reported.

service install now also adds a program-scoped inbound UDP rule for the host executable, covering whatever port a session picks. Program-scoped rather than a pinned port: pinning into 47998-48010 would collide with Sunshine/Apollo. The port rules are kept as they are.

Why nobody could see it

loss_ppm is ambiguous at zero. Loss is a ratio over the packets that arrived, so a flawless link and a link delivering nothing both report 0. The host read total silence as perfect and decayed adaptive FEC to its floor. Clients now also send a DeliveryReport carrying the session's received-packet count.

It is a new type byte, not a field appended to LossReport — and that is load-bearing. LossReport::decode length-checks exactly, so lengthening it would make every already-shipped host reject the loss reports its FEC runs on. Mixed versions are normal here (the field case ran a current host against a months-old client). A test pins LossReport to its 9-byte form so this cannot be undone by accident.

Two opposite faults had the same period. NO_VIDEO_RETRY (client received nothing) and FLUSH_COOLDOWN (client drowning in frames) were both 2000 ms, so the host's cadence classifier could not tell them apart and picked the wrong one out loud. The no-video cooldown moves into core beside FLUSH_COOLDOWN at 2600 ms, and both the Android client and the host compare against the shared constant rather than a copy — the copy is what let them drift into being indistinguishable.

The diagnosis now leads with the delivery count: zero is an ERROR naming the data plane, a confirmed count keeps the old confident wording, and a client too old to answer gets a warning that says it cannot tell instead of guessing. A punch that never arrives is also its own warning now, rather than a debug field on an info line.

Noise budget

An older host warns per unknown control message, so the delivery report is sent every window while the count is zero, once when the first packets land, then never. A healthy session costs one message; a broken one keeps saying so.

Verification

  • 480 core tests pass (up from 477): five new covering the wire form, the v1-compat guarantee, the two cooldowns staying distinguishable, and the send rule.
  • cargo fmt --check clean; clippy clean on punktfunk-core; punktfunk-probe compiles.
  • Host symbol resolution verified — the only cargo check -p punktfunk-host errors on macOS are the 10 pre-existing platform gaps (opus, procscan, vdisplay), none naming anything introduced here.

Needs a Windows build leg. add_data_plane_firewall_rule is cfg(windows) and scripts/xcheck.sh does not cover punktfunk-host; Docker was too congested locally to compile-check it. Verified by reading instead: run_quiet(&str, &[&str]) and firewall_profile_arg -> &'static str match the usage, rustfmt parses the file, and the existing rules already pass names containing spaces and parentheses through the same path.

Open items (mid-session delivery death, the QUIC re-route, Linux/firewalld parity) are written up separately in punktfunk-planning.

A field host streamed 1919 frames into a black screen while its own log blamed the client. Four faults, each of which alone makes the failure invisible. ## The bug The Windows firewall rules are `localport=`-scoped (`47998-48010, 9777, 5353`), but the media data plane binds an **ephemeral** port per session. No such rule can ever cover it, so Windows Firewall drops the client's hole-punch on **every session on every Windows host** — `punched=false` on the "data plane bound" line, in all six sessions across two field logs. The punch then never opens the return path, and video is sent blind to an address the client merely *reported*. `service install` now also adds a **program-scoped** inbound UDP rule for the host executable, covering whatever port a session picks. Program-scoped rather than a pinned port: pinning into `47998-48010` would collide with Sunshine/Apollo. The port rules are kept as they are. ## Why nobody could see it **`loss_ppm` is ambiguous at zero.** Loss is a ratio over the packets that arrived, so a flawless link and a link delivering *nothing* both report `0`. The host read total silence as perfect and decayed adaptive FEC to its floor. Clients now also send a `DeliveryReport` carrying the session's received-packet count. It is a **new type byte, not a field appended to `LossReport`** — and that is load-bearing. `LossReport::decode` length-checks exactly, so lengthening it would make every already-shipped host reject the loss reports its FEC runs on. Mixed versions are normal here (the field case ran a current host against a months-old client). A test pins `LossReport` to its 9-byte form so this cannot be undone by accident. **Two opposite faults had the same period.** `NO_VIDEO_RETRY` (client received nothing) and `FLUSH_COOLDOWN` (client drowning in frames) were both 2000 ms, so the host's cadence classifier could not tell them apart and picked the wrong one out loud. The no-video cooldown moves into core beside `FLUSH_COOLDOWN` at 2600 ms, and both the Android client and the host compare against the **shared constant** rather than a copy — the copy is what let them drift into being indistinguishable. **The diagnosis now leads with the delivery count**: zero is an `ERROR` naming the data plane, a confirmed count keeps the old confident wording, and a client too old to answer gets a warning that says it cannot tell instead of guessing. A punch that never arrives is also its own warning now, rather than a debug field on an info line. ## Noise budget An older host warns per unknown control message, so the delivery report is sent every window while the count is zero, once when the first packets land, then never. A healthy session costs one message; a broken one keeps saying so. ## Verification - **480 core tests pass** (up from 477): five new covering the wire form, the v1-compat guarantee, the two cooldowns staying distinguishable, and the send rule. - `cargo fmt --check` clean; clippy clean on `punktfunk-core`; `punktfunk-probe` compiles. - Host symbol resolution verified — the only `cargo check -p punktfunk-host` errors on macOS are the 10 pre-existing platform gaps (opus, procscan, vdisplay), none naming anything introduced here. ⚠ **Needs a Windows build leg.** `add_data_plane_firewall_rule` is `cfg(windows)` and `scripts/xcheck.sh` does not cover `punktfunk-host`; Docker was too congested locally to compile-check it. Verified by reading instead: `run_quiet(&str, &[&str])` and `firewall_profile_arg -> &'static str` match the usage, rustfmt parses the file, and the existing rules already pass names containing spaces and parentheses through the same path. Open items (mid-session delivery death, the QUIC re-route, Linux/firewalld parity) are written up separately in `punktfunk-planning`.
enricobuehler added 1 commit 2026-08-20 18:13:10 +00:00
fix(host,core): the video data port was never open, and nothing could tell
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m53s
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / web (pull_request) Successful in 1m29s
ci / docs-site (pull_request) Successful in 58s
ci / docs-drift (pull_request) Failing after 28s
ci / bun-nix (pull_request) Successful in 56s
android / android (pull_request) Successful in 7m21s
ci / rust (pull_request) Canceled after 7m48s
1280f697be
A field host streamed 1919 frames into a black screen while its own log
blamed the client. Four faults, each of which alone makes the failure
invisible.

The Windows firewall rules are `localport=`-scoped (47998-48010, 9777,
5353), but the media data plane binds an EPHEMERAL port per session. No
such rule can ever cover it, so Windows Firewall drops the client's
hole-punch on EVERY session on EVERY Windows host — `punched=false` on
the "data plane bound" line, in all six sessions of two field logs. The
punch then never opens the return path and video is sent blind to an
address the client merely reported. `service install` now also adds a
program-scoped inbound UDP rule for the host executable, which covers
whatever port a session picks. Program-scoped rather than a pinned port:
pinning into 47998-48010 would collide with Sunshine/Apollo.

`LossReport` carried only `loss_ppm`, which is ambiguous at zero — loss
is a ratio over the packets that arrived, so a flawless link and a link
delivering NOTHING both report 0. The host read total silence as perfect
and decayed adaptive FEC to its floor. Clients now also send a
`DeliveryReport` with the session's received-packet count. It is a new
type byte, NOT a field appended to `LossReport`: that message is
length-checked exactly, so lengthening it would make every shipped host
reject the loss reports its FEC runs on. Sent every window while the
count is zero, once when the first packets land, then never — an older
host warns per unknown message and must not be flooded on a good
session.

`NO_VIDEO_RETRY` (client got nothing) and `FLUSH_COOLDOWN` (client
drowning) were both 2000 ms, so the host's cadence classifier could not
tell two opposite faults apart and picked the wrong one out loud. The
no-video cooldown moves to core beside `FLUSH_COOLDOWN` at 2600 ms, and
both sides compare against the shared constant rather than a copy.

The diagnosis now leads with the delivery count: zero is an error naming
the data plane, a confirmed count keeps the old confident wording, and an
old client that cannot answer gets a warning that says so instead of
guessing. A punch that never arrives is also its own warning now, rather
than a debug field on an info line.
enricobuehler added 1 commit 2026-08-20 18:27:51 +00:00
ci(docs-drift): baseline the new control-message constant
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m14s
ci / docs-drift (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m42s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m22s
apple / swift (pull_request) Failing after 1m35s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m49s
ea3c9e1202
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and asks that
each be documented in docs-site or explicitly baselined. It cannot tell
an env knob from a cbindgen-exported `#define`, so the new
`PUNKTFUNK_MSG_DELIVERY_REPORT` header constant tripped it.

It is a wire message type byte, not a knob an operator can set — same as
every other `PUNKTFUNK_MSG_*`, all of which are already in the baseline.
Added in sorted position beside them.
enricobuehler merged commit 669a1bc0ce into main 2026-08-20 18:48:32 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#360