On-glass testing (Test 2, KWin .116) surfaced that a reconnect within the QUIC idle-timeout
window (~8s) lands on a fresh SECOND display instead of reusing the kept one: the old session
was still Active (not yet Lingering), so the registry's keep-alive reuse (which only matches
Lingering) skipped it and the old session kept streaming to nobody. Three fixes:
#3 Same-client reconnect preempt (the real fix): admission::preempt_same_identity() lists a
reconnecting client's OWN still-live session(s) (same cert fingerprint); serve_session signals
their stop + waits the release grace BEFORE acquiring, so the zombie tears down → its display
lingers → the reconnect REUSES it instead of making a second. Implements the "preempts
downstream" the admission docs already promised. Independent of the mode_conflict policy; the
pure core (same_identity_stops) is unit-tested.
#2 Deliberate quit skips linger: a client that deliberately disconnects closes the QUIC connection
with QUIT_CLOSE_CODE (0x51, shared in core::quic); the host reads the ApplicationClosed reason
and tears the display down immediately (registry release() gained force_immediate →
Linger::Immediate; multi-session-safe via the pure lifecycle machine), while a bare disconnect
still lingers for reconnect. Threaded via a session quit flag → the DisplayLease.
NativeClient::disconnect_quit() + punktfunk-probe --quit drive it; GameStream (Quit App /
h_cancel) is a documented follow-up.
#1 Configurable disconnect-detection latency: the QUIC control-connection idle timeout
(stream_transport, 8s default) is host-tunable via --idle-timeout-ms / PUNKTFUNK_IDLE_TIMEOUT_MS,
clamped >=1s with a keep-alive that scales to it so a live session never false-closes. Default
unchanged (8s stays load-bearing for the Windows IDD-push reconnect flow).
Workspace check + 63 core / 215 host / 47 vdisplay tests green; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two concurrent Windows sessions both drive the same pf-vdisplay monitor's
single-capturer IDD-push channel (newest-delivery-wins), which freezes the live
client and can wedge the driver (observed live: a concurrent-session test wedged
.173 → Moonlight 'no video'; needed a reboot). True multi-session capture is §6.6/
Stage 7. So on Windows 'separate' (incl. the unconfigured default) now resolves to
REJECT — a 2nd client gets a clean 503 and the live session is protected — instead
of join (which would freeze it). join/steal stay explicit opt-ins; Linux keeps
separate (real multi-view). Centralized as admission::effective_conflict(), shared
by the native handshake + GameStream h_launch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the mode-conflict admission surface deferred from the initial Stage 4:
- REJECT now delivers the reason to the client: punktfunk/1 closes the QUIC
connection with a distinct BUSY code (0x42) + the 'host busy: streaming WxH@Hz to
<client>' string, which the client reads from ApplicationClosed (validated on
loopback: the probe logs 'closed by peer: host busy … (code 66)').
- Windows default: separate (incl. the unconfigured default) resolves to JOIN — the
Windows native host admits a second client at the live mode instead of the old
silent last-wins reconfigure of the shared monitor (release-note behavior fix; the
reconfigure is now opt-in as steal). separate stays multi-view on Linux.
- GameStream 503: h_launch tracks the session owner fp (LaunchSession.owner_fp, kept
[u8;32] for Copy) and applies the policy when a DIFFERENT paired client launches —
reject → 503 (Moonlight 'host busy'), join → serve the live mode, steal/separate →
take over. Same-client re-launch is never a conflict.
Native reject-reason loopback-validated; Windows join-default pending .173 rebuild;
GameStream 503 pending a Moonlight client (can't drive /launch autonomously).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mode_conflict policy is now enforced at ADMISSION, before the punktfunk/1
Welcome, when a DIFFERENT client connects while another client's session is live:
- separate (default, unconfigured → no change): each client its own display.
- join: admit at the live display's mode (honest-downgrade — the Welcome carries it).
- steal: signal the victim session(s)' stop flags, wait the release grace, serve.
- reject: refuse the handshake with a busy reason (live mode + client label).
New vdisplay/admission.rs: the pure decide() (unit-tested — same-client never
conflicts, anonymous clients each distinct, join targets the oldest session) + a
live-session registry (identity + mode + stop flag) sessions register in once up.
Wired into punktfunk1 serve_session: admit() before validate_dimensions, register
after the data plane binds. A same-client reconnect never conflicts.
Validated on loopback (two probes, distinct identities, differing modes) across all
four policies: separate→own mode, join→live mode, steal→victim interrupted,
reject→handshake refused.
Remaining Stage-4 surface (deferred): GameStream 503 path, Windows-specific
defaults (separate→join map, silent-reconfigure→steal), reject reason delivered to
the client as a typed message (currently host-side log + connection close).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>