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-connectlocal= 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:
ipconfig /all on the host — are Ethernet and Wi-Fi both connected to the same LAN? If yes, this is it.
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.
`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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 withpunched=true:The punch arrived, so inbound UDP to the per-session data port is open — the firewall fix worked. The error's own advice (
punched=falseis "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 atvideo_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_socketbinds0.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 isconnected 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_ppmstays 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_punchalready documents the mirror of this assumption for the client's source IP ("the client binds0.0.0.0:0and 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
Connection::local_ip(), unmapping an IPv4-mapped v6 address so it can stillconnectto a v4 peer. Wildcard fallback (loud) when unavailable.data plane boundline carries the socket's post-connectlocal=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.packets_receivedis 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-hostdoes 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 --checkclean. Draft untilci / rustdoes 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:
ipconfig /allon the host — are Ethernet and Wi-Fi both connected to the same LAN? If yes, this is it.PUNKTFUNK_GSO=0on 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(_) => continueinsession.rsisn't counted anywhere. Worth a counter + a sibling control message (DeliveryReport's exact-length decode forbids widening it), separately.