Video egressed from whichever interface routing picked, not the one the client dialed #367

Merged
enricobuehler merged 1 commits from worktree-blackscreen-data-plane-source-ip into main 2026-08-21 12:31:47 +00:00
Owner

Follow-up to #360. A user who black-screened before, had their firewall opened, and still black-screens on 0.31.1.

What the 0.31.1 log rules out

#360's new ERROR fired — but with punched=true:

data plane bound ... client_udp=192.168.8.138:52943 udp_port=54894 direct=false punched=true
...
THE VIDEO DATA PLANE IS NOT REACHING THE CLIENT ... period_s=2.6 frames_sent=788

The punch arrived, so inbound UDP to the per-session data port is open — the firewall fix worked. The error's own advice (punched=false is "the fingerprint") contradicts the evidence it fired on. The wire-MTU watcher (native/wire_mtu.rs) also stayed silent for the full 32 s session, which on a live connection means discovery settled at video_datagram_udp_ceiling — the path carries full-size video datagrams. Both of the known causes of this shape are excluded.

What is left is an asymmetry the log makes sharp: every flow on the QUIC connection works in both directions (control, audio via conn.send_datagram, 30 input events), and on the raw data socket client→host works (the punch) while host→client delivers nothing.

The bug

bind_data_socket binds 0.0.0.0:0. The kernel then picks the video source address from the routing table, independently of the address the client's control connection arrived on. The client's data socket is connected to the host IP it dialed (pump/handshake.rs), so its kernel drops every datagram from any other source — before userspace, where nothing counts it.

With two live paths to the client — Ethernet and Wi-Fi both up on the same LAN, or a VPN/overlay adapter claiming the route — that is a permanent black screen with every gauge green: the punch still arrives (punched=true), loss_ppm stays 0 because there are no packets to see gaps in, and quinn pins the control plane to the right local address so QUIC stays perfectly healthy.

from_socket_punch already documents the mirror of this assumption for the client's source IP ("the client binds 0.0.0.0:0 and dials the same host IP as its QUIC connection, so the kernel picks the same source IP for both planes"). The host side of the same argument was never checked.

Changes

  • Fix — bind the data socket to Connection::local_ip(), unmapping an IPv4-mapped v6 address so it can still connect to a v4 peer. Wildcard fallback (loud) when unavailable.
  • Diagnostic — the data plane bound line carries the socket's post-connect local= address, and WARNs when it differs from where the control plane arrived. The old line logged only the port, which is why this session's log can't confirm the diagnosis outright.
  • Honesty — the black-screen ERROR no longer asserts "This is a PATH problem, not decode". packets_received is incremented after decrypt and replay checks (session.rs), so a session whose every datagram failed to open reports the identical zero. It now branches on what the bring-up line says instead of asserting one cause.

Verification

punktfunk-host does not build on macOS (opus/vdisplay), and OrbStack is wedged on this machine, so this was checked via the documented fallback: verbatim copies of the changed function and the source-address guard in a throwaway crate. 4/4 tests pass, including the two pre-existing ones unchanged. cargo fmt --check clean. Draft until ci / rust does the real compile.

Still open

This fix is the leading hypothesis, not a confirmed reproduction — the old log lacks the source address needed to prove it. Two things would settle it, in order:

  1. ipconfig /all on the host — are Ethernet and Wi-Fi both connected to the same LAN? If yes, this is it.
  2. Otherwise, PUNKTFUNK_GSO=0 on the host. USO (transport/udp/windows.rs) latches off only when a send errors; a NIC that accepts the batch and drops it is silent, and it is the one thing unique to the video send path that QUIC doesn't share.

A third gap this doesn't close: the client cannot distinguish "nothing arrived" from "everything arrived and none of it opened" — the undecryptable Err(_) => continue in session.rs isn't counted anywhere. Worth a counter + a sibling control message (DeliveryReport's exact-length decode forbids widening it), separately.

Follow-up to #360. A user who black-screened before, had their firewall opened, and still black-screens on 0.31.1. ## What the 0.31.1 log rules out `#360`'s new ERROR fired — but with **`punched=true`**: ``` data plane bound ... client_udp=192.168.8.138:52943 udp_port=54894 direct=false punched=true ... THE VIDEO DATA PLANE IS NOT REACHING THE CLIENT ... period_s=2.6 frames_sent=788 ``` The punch arrived, so **inbound UDP to the per-session data port is open — the firewall fix worked.** The error's own advice (`punched=false` is "the fingerprint") contradicts the evidence it fired on. The wire-MTU watcher (`native/wire_mtu.rs`) also stayed silent for the full 32 s session, which on a live connection means discovery settled at `video_datagram_udp_ceiling` — the path carries full-size video datagrams. Both of the known causes of this shape are excluded. What is left is an asymmetry the log makes sharp: **every flow on the QUIC connection works in both directions** (control, audio via `conn.send_datagram`, 30 input events), and on the raw data socket **client→host works** (the punch) while **host→client delivers nothing**. ## The bug `bind_data_socket` binds `0.0.0.0:0`. The kernel then picks the video source address from the routing table, independently of the address the client's control connection arrived on. The client's data socket is `connect`ed to the host IP it dialed (`pump/handshake.rs`), so its kernel drops every datagram from any other source — before userspace, where nothing counts it. With two live paths to the client — **Ethernet and Wi-Fi both up on the same LAN**, or a VPN/overlay adapter claiming the route — that is a permanent black screen with every gauge green: the punch still arrives (`punched=true`), `loss_ppm` stays 0 because there are no packets to see gaps in, and quinn pins the control plane to the right local address so QUIC stays perfectly healthy. `from_socket_punch` already documents the mirror of this assumption for the *client's* source IP ("the client binds `0.0.0.0:0` and dials the same host IP as its QUIC connection, so the kernel picks the same source IP for both planes"). The host side of the same argument was never checked. ## Changes - **Fix** — bind the data socket to `Connection::local_ip()`, unmapping an IPv4-mapped v6 address so it can still `connect` to a v4 peer. Wildcard fallback (loud) when unavailable. - **Diagnostic** — the `data plane bound` line carries the socket's post-`connect` `local=` address, and WARNs when it differs from where the control plane arrived. The old line logged only the port, which is why this session's log can't confirm the diagnosis outright. - **Honesty** — the black-screen ERROR no longer asserts *"This is a PATH problem, not decode"*. `packets_received` is incremented **after** decrypt and replay checks (`session.rs`), so a session whose every datagram failed to open reports the identical zero. It now branches on what the bring-up line says instead of asserting one cause. ## Verification `punktfunk-host` does not build on macOS (opus/`vdisplay`), and OrbStack is wedged on this machine, so this was checked via the documented fallback: verbatim copies of the changed function and the source-address guard in a throwaway crate. 4/4 tests pass, including the two pre-existing ones unchanged. `cargo fmt --check` clean. **Draft until `ci / rust` does the real compile.** ## Still open This fix is the leading hypothesis, not a confirmed reproduction — the old log lacks the source address needed to prove it. Two things would settle it, in order: 1. `ipconfig /all` on the host — **are Ethernet and Wi-Fi both connected to the same LAN?** If yes, this is it. 2. Otherwise, `PUNKTFUNK_GSO=0` on the host. USO (`transport/udp/windows.rs`) latches off only when a send *errors*; a NIC that accepts the batch and drops it is silent, and it is the one thing unique to the video send path that QUIC doesn't share. A third gap this doesn't close: the client cannot distinguish "nothing arrived" from "everything arrived and none of it opened" — the undecryptable `Err(_) => continue` in `session.rs` isn't counted anywhere. Worth a counter + a sibling control message (`DeliveryReport`'s exact-length decode forbids widening it), separately.
enricobuehler added 1 commit 2026-08-21 12:07:32 +00:00
fix(host): video egressed from whichever interface routing picked, not the one the client dialed
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / bun-nix (pull_request) Successful in 39s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 6m26s
ci / rust (pull_request) Successful in 15m39s
e989d7457f
`bind_data_socket` bound `0.0.0.0:0`, so the kernel chose the video source
address from the routing table, independently of the address the client's
control connection actually arrived on. The client's data socket is
`connect`ed to the host IP it dialed, so its kernel drops every datagram from
any other source — before userspace, where nothing counts it.

On a host with two live paths to the client (Ethernet and Wi-Fi both up on the
same LAN; a VPN/overlay adapter claiming the route) that is a permanent black
screen with every gauge green: the hole-punch still arrives so the host logs
`punched=true`, `loss_ppm` stays 0 because there are no packets to see gaps
in, and QUIC — which quinn pins to the right local address — carries control,
audio and input perfectly. `from_socket_punch` already documents the mirror of
this assumption for the *client's* source IP; the host side was never checked.

Bind the data socket to `Connection::local_ip()` instead (unmapping an
IPv4-mapped v6 address so the socket can still `connect` to a v4 peer), and
fall back to the wildcard, loudly, when it is unavailable.

Two diagnostics, because this session's log could not answer the question:
- the `data plane bound` line now carries the socket's post-`connect` `local=`
  address — the source the kernel will actually stamp — and WARNs when it
  differs from the address the control plane arrived on.
- the black-screen ERROR no longer asserts "This is a PATH problem, not
  decode" and no longer names `punched=false` as *the* fingerprint. It fired
  with `punched=true` in the field, which contradicts its own advice and sent
  an investigation at the firewall. It now branches on what the bring-up line
  says, and admits its counter is incremented after decrypt and replay checks,
  so a session whose every datagram failed to open reports the same zero.
enricobuehler marked the pull request as ready for review 2026-08-21 12:31:31 +00:00
enricobuehler merged commit 13aa11355e into main 2026-08-21 12:31:47 +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#367