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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user