From a369468ea0a7ae56c96008ca7c945746978da144 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 19 Sep 2026 14:03:19 -0400 Subject: [PATCH] bug: hold the demo playing beacon until live is acked, and retry it --- src/lib/status-reporter.sh | 3 + src/spectator/env.mjs | 4 ++ src/spectator/reporters/demo-playing.mjs | 70 +++++++++++++++++++----- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/lib/status-reporter.sh b/src/lib/status-reporter.sh index aab0181..55aba27 100644 --- a/src/lib/status-reporter.sh +++ b/src/lib/status-reporter.sh @@ -283,6 +283,9 @@ _status_daemon_loop() { case "$http_code" in 2*) last_hash="$current_hash" + # The spec-server holds its `playing` beacon until this shows `live`. + printf '%s' "$body" >"$STATUS_ACK_FILE.tmp" \ + && mv -f "$STATUS_ACK_FILE.tmp" "$STATUS_ACK_FILE" printf '[status-reporter] %s -> %s\n' "$body" "$http_code" >&2 ;; *) diff --git a/src/spectator/env.mjs b/src/spectator/env.mjs index cd3f1c3..c5c2930 100644 --- a/src/spectator/env.mjs +++ b/src/spectator/env.mjs @@ -20,6 +20,10 @@ export const EXEC_CFG_PATH = CS2_CFG_DIR ? `${CS2_CFG_DIR}/5stack_exec.cfg` : nu export const DEMO_FILE = process.env.DEMO_FILE ?? "/tmp/game-streamer/demo.dem"; +// Last status body the api accepted, written by status-reporter.sh's daemon. +export const STATUS_ACK_FILE = + process.env.STATUS_ACK_FILE ?? path.join(LOG_DIR, "status.ack"); + export const DEMO_SESSION_ID = process.env.DEMO_SESSION_ID ?? null; export const STATUS_API_BASE = process.env.STATUS_API_BASE ?? process.env.API_BASE ?? null; diff --git a/src/spectator/reporters/demo-playing.mjs b/src/spectator/reporters/demo-playing.mjs index f3a3926..38537ea 100644 --- a/src/spectator/reporters/demo-playing.mjs +++ b/src/spectator/reporters/demo-playing.mjs @@ -1,7 +1,8 @@ import process from "node:process"; +import { readFileSync } from "node:fs"; import { execCfgCommand } from "../cs2/exec-cfg.mjs"; -import { DEMO_SESSION_ID, STATUS_API_BASE } from "../env.mjs"; +import { DEMO_SESSION_ID, STATUS_ACK_FILE, STATUS_API_BASE } from "../env.mjs"; import { demoState } from "../state/demo.mjs"; export const playingState = { @@ -11,7 +12,16 @@ export const playingState = { demouiHidden: false, }; +// Bumped on every reset so a beacon still waiting/retrying for the previous +// playback stops instead of double-posting alongside the new one. +let generation = 0; + +const LIVE_ACK_POLL_MS = 500; +const LIVE_ACK_TIMEOUT_MS = 60_000; +const POST_RETRY_MS = 2_000; + export function resetPlayingState() { + generation += 1; playingState.reported = false; playingState.demouiHidden = false; } @@ -40,20 +50,54 @@ export async function reportDemoPlayingOnce() { demoState.lastSeekRealMs = Date.now(); if (!DEMO_SESSION_ID || !STATUS_API_BASE) return; - const url = `${STATUS_API_BASE}/demo-sessions/${DEMO_SESSION_ID}/status`; + await postPlaying(generation); +} + +function liveAcked() { try { - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: "playing" }), - signal: AbortSignal.timeout(5_000), - }); - if (!res.ok) { - process.stderr.write(`[spec-server] status=playing POST ${res.status}\n`); - } - } catch (err) { + return JSON.parse(readFileSync(STATUS_ACK_FILE, "utf8")).status === "live"; + } catch { + return false; + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// GSI flows as soon as cs2 has the demo loaded, which is routinely BEFORE +// run-demo.sh finishes start_capture and its `live` reaches the api through +// the 2s status daemon. `playing` sent that early either gets overwritten by +// the late `live` (viewer stuck on "Demo Loading") or mounts the WHEP player +// against a path with no publisher (3 failures → permanent HLS fallback). So +// hold it until `live` is acked, and keep retrying: nothing re-sends it. +async function postPlaying(mine) { + const deadline = Date.now() + LIVE_ACK_TIMEOUT_MS; + while (mine === generation && !liveAcked() && Date.now() < deadline) { + await sleep(LIVE_ACK_POLL_MS); + } + if (mine === generation && !liveAcked()) { process.stderr.write( - `[spec-server] status=playing POST failed: ${(err && err.message) || err}\n`, + `[spec-server] status=live never acked after ${LIVE_ACK_TIMEOUT_MS}ms — sending playing anyway\n`, ); } + + const url = `${STATUS_API_BASE}/demo-sessions/${DEMO_SESSION_ID}/status`; + while (mine === generation) { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "playing" }), + signal: AbortSignal.timeout(5_000), + }); + if (res.ok) return; + process.stderr.write(`[spec-server] status=playing POST ${res.status}\n`); + // 4xx won't get better by asking again (bad body / session gone). + if (res.status >= 400 && res.status < 500) return; + } catch (err) { + process.stderr.write( + `[spec-server] status=playing POST failed: ${(err && err.message) || err}\n`, + ); + } + await sleep(POST_RETRY_MS); + } }