fix(host): admit exactly ONE parked knock per Approve — stop crashing gnome-shell, and self-heal dead sessions
ci / docs-site (push) Successful in 55s
ci / web (push) Successful in 1m11s
apple / swift (push) Successful in 1m12s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
ci / bench (push) Successful in 7m3s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m57s
windows-host / package (push) Successful in 8m20s
arch / build-publish (push) Successful in 11m48s
deb / build-publish (push) Successful in 12m57s
apple / screenshots (push) Successful in 5m42s
android / android (push) Successful in 17m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m28s
ci / rust (push) Successful in 25m57s
docker / deploy-docs (push) Failing after 5m44s

A retrying unpaired client parks one QUIC connection per knock, but the
delegated-approval waiters were keyed on fingerprint alone — one console
Approve resolved ALL of them. Observed live (2026-07-10): an iPad knocked
3x, one Approve admitted three full sessions, three Mutter virtual
monitors were created within ~200us plus an ApplyMonitorsConfig, and
gnome-shell SIGSEGV'd inside meta_monitor_manager_rebuild — dropping the
box to the GDM greeter, unreachable until reboot (GDM auto-login runs
once per boot) while the lingering host spammed "RemoteDesktop ... not
activatable" libei errors.

Four fixes, outermost symptom inward:

- Knock generations (native_pairing): note_pending returns a per-knock
  generation; a re-knock bumps it and wakes the previous waiter, which
  resolves the new PairingDecision::Superseded (connection closes; the
  console list is unchanged). An approval records WHICH generation it
  admitted, so a stale waiter polling only after the pending entry is
  cleared still loses the tie — exactly one admission, no matter the
  interleaving.

- TOPOLOGY_LOCK (vdisplay/mutter): every Mutter monitor mutation
  (pre-snapshot -> RecordVirtual -> ApplyMonitorsConfig, and the
  teardown Stop) is serialized process-wide. Concurrent rebuilds are
  what segfault the shell (second on-glass crash of this class — the
  teardown race is already documented in-file), and serialization also
  keeps wait_virtual_connector's snapshot diff from attributing a
  sibling's connector. Create timeout 20s -> 45s for lock queueing.

- Session-env hygiene (vdisplay): when detection finds NOTHING live,
  clear the previous connect's XDG_CURRENT_DESKTOP/WAYLAND_DISPLAY
  retarget. A stale GNOME value kept mutter::is_available() true after
  the crash, routing explicit-backend connects into the dead session
  (create timeouts + libei error loops) instead of the crisp "no live
  graphical session" handshake error.

- Opt-in recovery hook (config + punktfunk1): PUNKTFUNK_RECOVER_SESSION_CMD
  fires (detached via sh -c, debounced to one launch/min) when a client
  connects while no graphical session is live — e.g. `sudo -n systemctl
  restart gdm` re-runs auto-login and the client's retry lands in the
  recovered desktop. The handshake error tells the client to retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 01:10:26 +02:00
parent c86da1a1ff
commit 47d22b6082
6 changed files with 332 additions and 42 deletions
+60
View File
@@ -712,6 +712,17 @@ pub fn apply_session_env(active: &ActiveSession) {
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
}
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
// connect's retarget, and the availability probes read them: after a gnome-shell crash
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
// graphical session" handshake error. Clear them so `available()` reports the truth and the
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
if active.kind == ActiveKind::None {
std::env::remove_var("XDG_CURRENT_DESKTOP");
std::env::remove_var("WAYLAND_DISPLAY");
}
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
// [`effective_topology`] directly at create time — the console policy, else the legacy
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
@@ -721,6 +732,55 @@ pub fn apply_session_env(active: &ActiveSession) {
#[cfg(not(target_os = "linux"))]
pub fn apply_session_env(_active: &ActiveSession) {}
/// Fire the operator's session-recovery hook (`PUNKTFUNK_RECOVER_SESSION_CMD`) because a client
/// connected while NO graphical session is live for this uid — the state a compositor crash
/// leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login only fires once per boot,
/// so the box would otherwise sit headless until a walk-up login or a reboot). The command runs
/// detached via `sh -c` (typically a display-manager restart — see the config docs) and is
/// debounced to one launch per minute so a retrying client can't stack restarts. Returns whether
/// a recovery is underway (just launched, or launched within the debounce window), letting the
/// handshake error tell the client to simply retry.
#[cfg(target_os = "linux")]
pub fn try_recover_session() -> bool {
let Some(cmd) = crate::config::config().recover_session_cmd.clone() else {
return false;
};
static LAST_LAUNCH: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(60);
let mut last = LAST_LAUNCH.lock().unwrap_or_else(|e| e.into_inner());
if last.is_some_and(|t| t.elapsed() < DEBOUNCE) {
return true; // a launch is already in flight — the retry lands in the recovered session
}
match std::process::Command::new("/bin/sh")
.arg("-c")
.arg(&cmd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(mut child) => {
*last = Some(std::time::Instant::now());
tracing::warn!(cmd = %cmd,
"no live graphical session — launched the operator's session-recovery command");
// Reap off-thread so the finished child never lingers as a zombie.
std::thread::spawn(move || {
let _ = child.wait();
});
true
}
Err(e) => {
tracing::error!(cmd = %cmd, error = %e,
"session-recovery command failed to launch");
false
}
}
}
#[cfg(not(target_os = "linux"))]
pub fn try_recover_session() -> bool {
false
}
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
/// against a half-stale env — it accepts events but they don't reach the compositor until a