feat(vdisplay): mode-conflict admission — separate/join/steal/reject (Stage 4)

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>
This commit is contained in:
2026-07-05 10:21:28 +00:00
parent 029d1134a9
commit 42b1158ea7
3 changed files with 284 additions and 1 deletions
+56 -1
View File
@@ -652,7 +652,7 @@ async fn serve_session(
let source = opts.source;
let frames = opts.frames;
let handshake = async {
let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!(
hello.abi_version == punktfunk_core::WIRE_VERSION,
"wire version mismatch: client {} host {}",
@@ -684,6 +684,45 @@ async fn serve_session(
"video codec negotiated"
);
// Mode-conflict ADMISSION (Stage 4): a DIFFERENT client connecting while another client's
// session is live is resolved by the `mode_conflict` policy BEFORE the Welcome — `separate`
// (default, no change), `join` (serve at the live mode — an honest downgrade the client
// renders from the Welcome), `steal` (preempt the victim), or `reject` (refuse the handshake).
// A same-client reconnect never conflicts. THIS session registers in the live set once its
// data plane is up (below the handshake), so a later client can see + steal it.
{
use crate::vdisplay::admission::{admit, Admission};
match admit(endpoint::peer_fingerprint(&conn)) {
Admission::Separate => {}
Admission::Join(m) => {
tracing::info!(
requested =
%format_args!("{}x{}@{}", hello.mode.width, hello.mode.height, hello.mode.refresh_hz),
live = %format_args!("{}x{}@{}", m.0, m.1, m.2),
"mode-conflict: JOIN — admitting at the live display's mode"
);
hello.mode.width = m.0;
hello.mode.height = m.1;
hello.mode.refresh_hz = m.2;
}
Admission::Steal(victims) => {
tracing::info!(
victims = victims.len(),
"mode-conflict: STEAL — preempting the live session(s)"
);
for v in &victims {
v.store(true, Ordering::SeqCst);
}
// Give the victims the release grace to tear their display down before we acquire.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
Admission::Reject(reason) => {
tracing::warn!("mode-conflict: REJECT — {reason}");
anyhow::bail!("{reason}");
}
}
}
crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
.context("client-requested mode")?;
@@ -1055,6 +1094,22 @@ async fn serve_session(
});
}
// Register this now-live session for mode-conflict admission (Stage 4): carry its identity, the
// negotiated mode, and its stop flag so a LATER connecting client's admission can see it and
// (under `steal`) signal it. The guard removes the entry when this session ends.
let _live_guard = {
let id = endpoint::peer_fingerprint(&conn);
let label = id
.map(|fp| fp.iter().take(4).map(|b| format!("{b:02x}")).collect::<String>())
.unwrap_or_else(|| "client".to_string());
crate::vdisplay::admission::register(
id,
(welcome.mode.width, welcome.mode.height, welcome.mode.refresh_hz),
stop.clone(),
label,
)
};
// Audio plane (virtual source only — synthetic runs are protocol tests): desktop Opus
// → host→client QUIC datagrams, on its own native thread. Best-effort on every failure
// (no PipeWire audio, spawn error): the session continues without audio — and a spawn