fix(client/wol): retry the QUIC dial across the connect budget so wake-from-sleep launches survive
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m3s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 40s
ci / bench (push) Successful in 6m51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m6s
deb / build-publish (push) Successful in 9m54s
release / apple (push) Successful in 9m5s
deb / build-publish-host (push) Successful in 9m35s
flatpak / build-publish (push) Failing after 8m19s
android / android (push) Successful in 16m57s
apple / screenshots (push) Successful in 6m29s
arch / build-publish (push) Successful in 17m35s
windows-host / package (push) Successful in 18m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
docker / deploy-docs (push) Successful in 26s
ci / rust (push) Successful in 25m24s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m3s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 40s
ci / bench (push) Successful in 6m51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m6s
deb / build-publish (push) Successful in 9m54s
release / apple (push) Successful in 9m5s
deb / build-publish-host (push) Successful in 9m35s
flatpak / build-publish (push) Failing after 8m19s
android / android (push) Successful in 16m57s
apple / screenshots (push) Successful in 6m29s
arch / build-publish (push) Successful in 17m35s
windows-host / package (push) Successful in 18m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
docker / deploy-docs (push) Successful in 26s
ci / rust (push) Successful in 25m24s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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" "$@"
|
||||
|
||||
@@ -283,13 +283,21 @@ export async function launchStream(
|
||||
opts: LaunchOpts = {},
|
||||
): Promise<void> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ impl NativeClient {
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
connect_timeout: timeout,
|
||||
frames: frame_chan_w,
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
|
||||
@@ -32,21 +32,65 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())),
|
||||
);
|
||||
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||
let conn = ep
|
||||
.connect(remote, "punktfunk")
|
||||
.map_err(|_| PunktfunkError::InvalidArg("connect"))?
|
||||
.await
|
||||
.map_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 {
|
||||
PunktfunkError::Crypto
|
||||
} else {
|
||||
PunktfunkError::Io(std::io::Error::other(e.to_string()))
|
||||
// Dial with retry across the connect budget, not a single attempt: one quinn dial gives
|
||||
// up after the transport's idle window (~8 s of silence), which is shorter than a
|
||||
// suspend-to-RAM resume — the Steam Deck flow fires Wake-on-LAN and connects
|
||||
// immediately, so the host is still waking while the first Initials go out, and a
|
||||
// single-shot dial died just before the host came up. Short attempts keep the Initial
|
||||
// cadence dense (quinn's per-attempt retransmits back off toward multi-second gaps), so
|
||||
// the connect lands within ~a second of the host's network returning. Only SILENCE is
|
||||
// retried: a host that answers and rejects us (pin mismatch, ALPN/version, typed close)
|
||||
// must surface immediately, and the embedder's shutdown flag (budget expiry in
|
||||
// `connect`, or a user cancel) stops the loop between attempts.
|
||||
const DIAL_ATTEMPT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
// Redial headroom: leave room for the control handshake (Hello/Welcome/clock sync)
|
||||
// after a late dial success, so it still completes inside the embedder's budget.
|
||||
const CONTROL_HEADROOM: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
let start = tokio::time::Instant::now();
|
||||
let deadline = start + args.connect_timeout;
|
||||
let redial_until = start + args.connect_timeout.saturating_sub(CONTROL_HEADROOM);
|
||||
let conn = loop {
|
||||
let connecting = ep
|
||||
.connect(remote, "punktfunk")
|
||||
.map_err(|_| PunktfunkError::InvalidArg("connect"))?;
|
||||
// Cap the attempt to the remaining budget so a success never lands after the
|
||||
// embedder's `ready_rx` wait has already given up and flagged a teardown.
|
||||
let now = tokio::time::Instant::now();
|
||||
let attempt = DIAL_ATTEMPT.min(deadline.saturating_duration_since(now));
|
||||
let gave_up = || {
|
||||
tokio::time::Instant::now() >= 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
|
||||
|
||||
@@ -25,6 +25,10 @@ pub(crate) struct WorkerArgs {
|
||||
pub(crate) launch: Option<String>,
|
||||
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<FrameChannel>,
|
||||
pub(crate) audio_tx: SyncSender<AudioPacket>,
|
||||
pub(crate) rumble_tx: SyncSender<RumbleUpdate>,
|
||||
|
||||
@@ -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<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCaptu
|
||||
/// connection (the host stops waiting at once).
|
||||
const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180);
|
||||
|
||||
/// How a served connection ended. A peer that completes the QUIC handshake and closes cleanly
|
||||
/// (code 0) without ever opening the control stream is a reachability probe (the clients'
|
||||
/// hosts-page "online" pips / `--reachable`) or an abandoned connect — routine, and logged
|
||||
/// quietly: as a WARN it buried the real failures in a wake-on-LAN triage log.
|
||||
enum Served {
|
||||
Session,
|
||||
ProbeClose,
|
||||
}
|
||||
|
||||
/// One client session: handshake → input/audio planes → data plane until done/disconnect.
|
||||
/// Everything torn down on return (RAII: virtual output, encoder, threads via channel close).
|
||||
/// A connection whose first message is a PairRequest runs the pairing ceremony instead.
|
||||
@@ -651,14 +664,23 @@ async fn serve_session(
|
||||
// parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from.
|
||||
mut permit: tokio::sync::OwnedSemaphorePermit,
|
||||
sem: Arc<tokio::sync::Semaphore>,
|
||||
) -> Result<()> {
|
||||
) -> Result<Served> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user