feat(vdisplay): harden keep-alive reconnect — same-client preempt, quit-skips-linger, configurable idle
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>
This commit is contained in:
@@ -115,6 +115,31 @@ pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
|
||||
decide(effective_conflict(), req_identity, &table().lock().unwrap())
|
||||
}
|
||||
|
||||
/// Pure core of [`preempt_same_identity`]: the stop flags of live sessions owned by the SAME client
|
||||
/// as `req_identity` (its own zombies). Testable over a slice (the public fn locks the global table).
|
||||
fn same_identity_stops(
|
||||
req_identity: Option<[u8; 32]>,
|
||||
live: &[LiveSession],
|
||||
) -> Vec<Arc<AtomicBool>> {
|
||||
live.iter()
|
||||
.filter(|s| same_client(s.identity, req_identity))
|
||||
.map(|s| Arc::clone(&s.stop))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Preempt this reconnecting client's OWN still-live session(s). A client has at most one live
|
||||
/// session, so a new connection from an already-registered identity is a **reconnect** — the old
|
||||
/// session is a zombie whose QUIC idle timer hasn't fired yet (an unwanted disconnect is only
|
||||
/// declared dead after `max_idle_timeout`, ~seconds later). Return its stop flag(s) so the caller
|
||||
/// signals them and waits the release grace: the zombie tears its display down, which (keep-alive on)
|
||||
/// lingers, and THIS reconnect **reuses** that kept display instead of landing on a fresh SECOND one
|
||||
/// (the "thrown onto a second display while the old one keeps streaming" bug). Anonymous (`None`)
|
||||
/// never matches — same limitation as `steal`/`reject`. Call this BEFORE [`admit`] and before this
|
||||
/// session registers itself, so it only ever signals a *prior* session's flag, never its own.
|
||||
pub fn preempt_same_identity(req_identity: Option<[u8; 32]>) -> Vec<Arc<AtomicBool>> {
|
||||
same_identity_stops(req_identity, &table().lock().unwrap())
|
||||
}
|
||||
|
||||
/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call
|
||||
/// AFTER [`admit`] (so a session never conflicts with itself) and once the mode + stop flag are known.
|
||||
pub fn register(
|
||||
@@ -225,6 +250,20 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_identity_stops_targets_own_zombie_only() {
|
||||
let live = [
|
||||
sess(Some(1), (2560, 1440, 60)), // this client's prior (zombie) session
|
||||
sess(Some(2), (1920, 1080, 60)), // a different client
|
||||
];
|
||||
// Reconnecting as client 1 → its own zombie's stop is returned (to preempt), not client 2's.
|
||||
assert_eq!(same_identity_stops(fp(1), &live).len(), 1);
|
||||
// A client with no prior session (fp 3) has nothing of its own to preempt.
|
||||
assert_eq!(same_identity_stops(fp(3), &live).len(), 0);
|
||||
// Anonymous never matches — we can't prove it's the same client.
|
||||
assert_eq!(same_identity_stops(None, &live).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_targets_the_oldest_other_session() {
|
||||
let live = [
|
||||
|
||||
@@ -81,16 +81,23 @@ fn topology_str() -> String {
|
||||
///
|
||||
/// Windows delegates to the [`manager`](super::manager) via `vd.create` (unchanged); Linux uses the
|
||||
/// pool below; other platforms pass through.
|
||||
/// `quit` is the session's deliberate-quit flag: when the session ends with it set (the client closed
|
||||
/// with the quit application code — a user "stop", not a network drop), the display is torn down
|
||||
/// **immediately**, skipping the keep-alive linger. A bare disconnect leaves it `false` → normal linger.
|
||||
pub fn acquire(
|
||||
vd: &mut Box<dyn super::VirtualDisplay>,
|
||||
mode: super::Mode,
|
||||
quit: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
) -> Result<super::VirtualOutput> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::acquire(vd, mode)
|
||||
linux::acquire(vd, mode, quit)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
// Windows leases in the manager (its own linger); the deliberate-quit skip is not wired
|
||||
// through there yet, so the flag is accepted but unused off Linux.
|
||||
let _ = quit;
|
||||
vd.create(mode)
|
||||
}
|
||||
}
|
||||
@@ -163,8 +170,8 @@ pub fn release(slot: Option<u64>) -> usize {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, Once, OnceLock};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Once, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -304,16 +311,21 @@ mod linux {
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
gen: u64,
|
||||
quit: Arc<AtomicBool>,
|
||||
) -> VirtualOutput {
|
||||
VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode,
|
||||
keepalive: Box::new(DisplayLease { gen }),
|
||||
keepalive: Box::new(DisplayLease { gen, quit }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn acquire(vd: &mut Box<dyn VirtualDisplay>, mode: Mode) -> Result<VirtualOutput> {
|
||||
pub(super) fn acquire(
|
||||
vd: &mut Box<dyn VirtualDisplay>,
|
||||
mode: Mode,
|
||||
quit: Arc<AtomicBool>,
|
||||
) -> Result<VirtualOutput> {
|
||||
ensure_timer();
|
||||
let backend = vd.name();
|
||||
let r = reg();
|
||||
@@ -343,7 +355,7 @@ mod linux {
|
||||
e.life.acquire();
|
||||
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
|
||||
e.gen = gen;
|
||||
let out = output_for(e.node_id, e.preferred_mode, gen);
|
||||
let out = output_for(e.node_id, e.preferred_mode, gen, quit);
|
||||
tracing::info!(
|
||||
backend,
|
||||
node_id = e.node_id,
|
||||
@@ -443,15 +455,21 @@ mod linux {
|
||||
if (position.x, position.y) != (0, 0) {
|
||||
vd.apply_position(position.x, position.y);
|
||||
}
|
||||
Ok(output_for(node_id, preferred_mode, gen))
|
||||
Ok(output_for(node_id, preferred_mode, gen, quit))
|
||||
}
|
||||
|
||||
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
|
||||
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
|
||||
/// the lock is released.
|
||||
fn release(gen: u64) {
|
||||
fn release(gen: u64, force_immediate: bool) {
|
||||
let Some(r) = REG.get() else { return };
|
||||
let linger = linger();
|
||||
// A deliberate quit (the client closed with the quit code — a user "stop") tears the display
|
||||
// down NOW, overriding the keep-alive linger; a bare disconnect honors the policy.
|
||||
let linger = if force_immediate {
|
||||
Linger::Immediate
|
||||
} else {
|
||||
linger()
|
||||
};
|
||||
let (torn_down, restore) = {
|
||||
let mut es = r.entries.lock().unwrap();
|
||||
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
|
||||
@@ -489,10 +507,17 @@ mod linux {
|
||||
restore();
|
||||
}
|
||||
if let Some(e) = torn_down {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
"virtual display torn down (keep-alive off / released)"
|
||||
);
|
||||
if force_immediate {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
"virtual display torn down (deliberate quit — keep-alive skipped)"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
backend = e.backend,
|
||||
"virtual display torn down (keep-alive off / released)"
|
||||
);
|
||||
}
|
||||
drop(e); // outside the lock — the keepalive Drop may block
|
||||
}
|
||||
}
|
||||
@@ -683,11 +708,15 @@ mod linux {
|
||||
/// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op.
|
||||
struct DisplayLease {
|
||||
gen: u64,
|
||||
/// The session's deliberate-quit flag: set when the client closes with the quit application
|
||||
/// code (a user "stop", not a network drop), so this lease's `Drop` tears the display down
|
||||
/// immediately instead of lingering. `false` on a bare disconnect → normal keep-alive.
|
||||
quit: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for DisplayLease {
|
||||
fn drop(&mut self) {
|
||||
release(self.gen);
|
||||
release(self.gen, self.quit.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user