From 56d21f9445ffe45038ab2ec4b97e9b8d59e3c435 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 21 Jul 2026 22:26:10 +0200 Subject: [PATCH] fix(client/wol): retry the QUIC dial across the connect budget so wake-from-sleep launches survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launching from the Decky plugin against a sleeping host fired the Wake-on-LAN packet and dialed immediately — but the dial was a single quinn attempt that gave up after the transport's 8 s idle window, far shorter than a suspend-to-RAM resume. The host woke to find the client already gone (its retransmitted Initials show up host-side as "QUIC accept failed error=timed out"), the stream shortcut exited, and the launch looked cancelled. - punktfunk-core: dial in a loop until the embedder's connect budget is spent — short 3 s attempts keep the Initial cadence dense, so the connect lands within ~a second of the host's network returning. Only silence is retried: pin mismatches, typed rejections, and any real answer surface immediately, and the shutdown flag stops the loop. - decky: stretch the budget to 75 s when a magic packet actually went out (PF_CONNECT_TIMEOUT → --connect-timeout via the wrapper), so the retry window covers the resume; an awake host still connects in <1 s. - host: a clean close before the control handshake is a reachability probe (hosts-page online pips), not a failed session — log it at debug instead of WARN "session ended with error", which buried the real failures in the reporter's log. Co-Authored-By: Claude Fable 5 --- clients/decky/bin/punktfunkrun.sh | 13 +++- clients/decky/src/steam.ts | 16 +++-- crates/punktfunk-core/src/client/mod.rs | 1 + .../src/client/pump/handshake.rs | 72 +++++++++++++++---- crates/punktfunk-core/src/client/worker.rs | 4 ++ crates/punktfunk-host/src/native.rs | 36 ++++++++-- 6 files changed, 116 insertions(+), 26 deletions(-) diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh index b14926f1..ea414b81 100755 --- a/clients/decky/bin/punktfunkrun.sh +++ b/clients/decky/bin/punktfunkrun.sh @@ -16,6 +16,8 @@ # PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games) # PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect) # PF_MGMT management-API port for --browse (optional; client defaults to 47990) +# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after +# firing Wake-on-LAN so the connect survives the host's resume) # PF_APPID flatpak app id (default io.unom.Punktfunk) # PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) # @@ -61,10 +63,17 @@ if [ -z "${PF_HOST:-}" ]; then echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2 exit 2 fi +# Trailing args shared by both streaming execs. A stretched connect budget rides along when the +# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak +# without --connect-timeout ignores the flag harmlessly (hand-scanned argv). +set -- --fullscreen +if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then + set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@" +fi if [ -n "${PF_LAUNCH:-}" ]; then # A pinned game: the id rides the session Hello and the host launches that title. echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2 - exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" --fullscreen + exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@" fi echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2 -exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen +exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@" diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 135854a0..7cd5f98d 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -283,13 +283,21 @@ export async function launchStream( opts: LaunchOpts = {}, ): Promise { // Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now - // so it races with the shortcut setup (near-zero added latency), and awaited just before RunGame. + // so it races with the shortcut setup (near-zero added latency); its outcome is needed below + // (the connect budget), and RunGame follows the await either way, so nothing is slower for it. // Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is // known), and the connect that follows has its own retry window, so a failure never blocks launch. const waking = wake(host, port).catch(() => ({ ok: false })); - const { appId, runner } = await ensureStreamShortcut(); + const [{ appId, runner }, woke] = await Promise.all([ensureStreamShortcut(), waking]); const target = port && port !== 9777 ? `${host}:${port}` : host; const env = [`PF_HOST=${target}`]; + // A magic packet actually went out (a MAC was known), so the host may be mid-resume from + // suspend — that takes far longer than the client's default 15 s connect budget. Stretch the + // budget so the client's wake-tolerant dial keeps retrying across the resume; against an + // already-awake host the connect still lands in under a second, so this costs nothing. + if (woke.ok) { + env.push("PF_CONNECT_TIMEOUT=75"); + } if (opts.browse) { env.push("PF_BROWSE=1"); if (opts.mgmt) { @@ -303,9 +311,9 @@ export async function launchStream( env.push(`PF_LAUNCH=${opts.launchId}`); } // KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper - // script rides behind it as an argument and reads PF_* from the environment. + // script rides behind it as an argument and reads PF_* from the environment. The wake was + // awaited above, so the magic packet is out before the connect attempt. SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`); - await waking; // ensure the magic packet is out before the connect attempt SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); } diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index d4929313..98798c22 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -393,6 +393,7 @@ impl NativeClient { launch, pin, identity, + connect_timeout: timeout, frames: frame_chan_w, audio_tx, rumble_tx, diff --git a/crates/punktfunk-core/src/client/pump/handshake.rs b/crates/punktfunk-core/src/client/pump/handshake.rs index 9f0d45fc..b3d7a6e7 100644 --- a/crates/punktfunk-core/src/client/pump/handshake.rs +++ b/crates/punktfunk-core/src/client/pump/handshake.rs @@ -32,21 +32,65 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result= redial_until + || shutdown.load(std::sync::atomic::Ordering::SeqCst) + }; + match tokio::time::timeout(attempt, connecting).await { + Ok(Ok(conn)) => break conn, + Ok(Err(e)) => { + // A pin mismatch surfaces as a TLS failure; report it as a crypto error so + // the embedder can distinguish "wrong host identity" from plain IO trouble. + let fp_mismatch = pin.is_some() + && observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true); + if fp_mismatch { + return Err(PunktfunkError::Crypto); + } + // The transport's own idle expiry — the host never answered — is the one + // retryable outcome; everything else is a real answer or a local failure. + let host_silent = matches!(e, quinn::ConnectionError::TimedOut); + if !host_silent { + return Err(PunktfunkError::Io(std::io::Error::other(e.to_string()))); + } + if gave_up() { + return Err(PunktfunkError::Timeout); + } } - })?; + // Attempt window elapsed with the host still silent; dropping `connecting` + // abandoned that dial — go again unless the budget is spent. + Err(_) => { + if gave_up() { + return Err(PunktfunkError::Timeout); + } + } + } + tracing::debug!(%remote, "host silent — re-dialing (wake/resume tolerant connect)"); + }; let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]); // The rest of the handshake runs in an inner future so a failure can consult // `conn.close_reason()`: a host that turned us away with a typed application close diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index 25915bb1..a2c6a524 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -25,6 +25,10 @@ pub(crate) struct WorkerArgs { pub(crate) launch: Option, pub(crate) pin: Option<[u8; 32]>, pub(crate) identity: Option<(String, String)>, + /// The embedder's connect budget (the same value `connect` bounds `ready_rx` with): the + /// dial loop re-dials a silent host within it, so a host still resuming from Wake-on-LAN + /// is caught the moment its network comes back instead of failing on the first attempt. + pub(crate) connect_timeout: std::time::Duration, pub(crate) frames: Arc, pub(crate) audio_tx: SyncSender, pub(crate) rumble_tx: SyncSender, diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index c3478755..c4f3a0e0 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -439,7 +439,11 @@ pub(crate) async fn serve( ) .await { - Ok(()) => tracing::info!(%peer, "session complete"), + Ok(Served::Session) => tracing::info!(%peer, "session complete"), + Ok(Served::ProbeClose) => tracing::debug!( + %peer, + "closed before the control handshake (reachability probe)" + ), Err(e) => { tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error") } @@ -629,6 +633,15 @@ type AudioCapSlot = Arc, -) -> Result<()> { +) -> Result { let peer = conn.remote_address(); // First message decides what this connection is: a pairing ceremony or a session. - let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi()) + let (mut send, mut recv) = match tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi()) .await .map_err(|_| anyhow!("control stream timeout"))? - .context("accept control stream")?; + { + // A clean close before any control stream: a reachability probe / abandoned connect, + // not a failed session (see [`Served::ProbeClose`]). + Err(quinn::ConnectionError::ApplicationClosed(ref ac)) + if ac.error_code == quinn::VarInt::from_u32(0) => + { + return Ok(Served::ProbeClose); + } + r => r.context("accept control stream")?, + }; let first = tokio::time::timeout(HANDSHAKE_TIMEOUT, io::read_msg(&mut recv)) .await .map_err(|_| anyhow!("first message timeout"))??; @@ -709,7 +731,9 @@ async fn serve_session( } *last = Some(std::time::Instant::now()); } - return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await; + return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin) + .await + .map(|()| Served::Session); } // Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the @@ -1392,7 +1416,7 @@ async fn serve_session( // host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the // TV's gaming session back so it's the default when no one is streaming. crate::vdisplay::restore_managed_session(); - result + result.map(|()| Served::Session) } /// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails