From 82aead6cea32a98308bf8de0f4c7c6d18b7516e9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 7 Aug 2026 20:31:32 +0200 Subject: [PATCH] CB-WP-0035: a session that answers nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reports, one cause: the script's fetch had no .catch. When the server had exited the promise rejected, the chain never ran, and not even the status line moved — so a dead server and a click that did nothing were indistinguishable. play again read as a dead button, and the linger timeout was invisible. Now a rejection says the session is gone and seals the page, and a 5-second heartbeat against a new /alive notices it without needing a click, which is what the timeout case requires. The beat carries the token, and does not extend the linger: that deadline is absolute. The harness had the same hole. jsrun's fetch stub had no .catch, so the branch that notices a dead server would have been unreachable in every test — the very defect the stub's own comment records from CB-WP-0024. Teaching it __failing, .catch and a recorded setInterval was the fix; writing the script defensively would have repeated the trap. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 2 + crates/cb-render-html/src/doc.rs | 44 +++++- crates/cb-render-html/src/jsrun.rs | 69 +++++++- crates/cb-render-html/src/lib.rs | 85 ++++++++++ crates/cb-render-html/src/serve.rs | 14 ++ tools/cb-play/src/hotseat.rs | 12 ++ trials/2026-08-07-1829.yaml | 149 ++++++++++++++++++ .../CB-WP-0034-who-you-are-bonding-with.md | 2 + ...-WP-0035-a-session-that-answers-nothing.md | 78 +++++++++ 9 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 trials/2026-08-07-1829.yaml create mode 100644 workplans/CB-WP-0035-a-session-that-answers-nothing.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 1c66020..9301b8c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -41,6 +41,7 @@ | workplan | CB-WP-0031 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.md | | workplan | CB-WP-0032 | done | — | workplans/CB-WP-0032-the-comments-in-the-account.md | | workplan | CB-WP-0033 | done | — | workplans/CB-WP-0033-a-game-is-the-unit.md | +| workplan | CB-WP-0034 | done | — | workplans/CB-WP-0034-who-you-are-bonding-with.md | | task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md | @@ -205,3 +206,4 @@ | task | CB-WP-0031-T01 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.md | | task | CB-WP-0032-T01 | done | — | workplans/CB-WP-0032-the-comments-in-the-account.md | | task | CB-WP-0033-T01 | done | — | workplans/CB-WP-0033-a-game-is-the-unit.md | +| task | CB-WP-0034-T01 | done | — | workplans/CB-WP-0034-who-you-are-bonding-with.md | diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 85a85f4..0212411 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -91,7 +91,9 @@ pub const SCRIPT: &str = r#" // // No game vocabulary here (ADR-0007 D5). 'closed' is a server // lifecycle word, exactly like the 'ok' branch below it. + var sealed = false; function seal() { + sealed = true; // The WHOLE page goes inert, not only the controls. A greyed-out // button beside a full-colour table still reads as a live game with // one broken control; the session has ended and everything on screen @@ -206,8 +208,33 @@ pub const SCRIPT: &str = r#" status(t); if (t.indexOf('ok') === 0) { window.location.reload(); } else if (t.indexOf('closed') === 0) { seal(); } - }); + }).catch(gone); }); + + // CB-WP-0035. The session ended without this page being told. + // + // There was no rejection handler at all, so when the server had exited + // the promise rejected and the chain simply never ran -- not even the + // status line moved. `play again` looked like a dead button, and the + // linger timeout was invisible. A request that cannot be answered is + // information, and it was being thrown away. + function gone() { + status('the session is no longer available \u2014 the game server has ' + + 'stopped. Nothing on this page can act; it is a record now.'); + seal(); + } + + // And notice it WITHOUT being clicked, which is what the timeout needs: + // a player who walks away comes back to a sealed page rather than to a + // live-looking table that answers nothing. + if (window.CB_ALIVE) { + setInterval(function () { + if (sealed) { return; } + fetch(window.CB_ALIVE).then(function (r) { + if (!r.ok) { gone(); } + }).catch(gone); + }, 5000); + } })(); "#; @@ -949,6 +976,7 @@ pub fn document( Endpoints { command: endpoint, note: "/note", + alive: "/alive", }, seat, may_pass, @@ -969,6 +997,8 @@ pub struct Endpoints<'a> { pub command: &'a str, /// Free text. Nothing turns these into commands. pub note: &'a str, + /// Where the page checks the session is still there (CB-WP-0035). + pub alive: &'a str, } pub fn document_with_log( @@ -980,7 +1010,7 @@ pub fn document_with_log( log: Account<'_>, meta: &[String], ) -> String { - let (endpoint, note_to) = (to.command, to.note); + let (endpoint, note_to, alive) = (to.command, to.note, to.alive); let mut s = String::with_capacity(8192); let _ = write!( s, @@ -1019,9 +1049,10 @@ pub fn document_with_log( let _ = write!( s, "
ready
\ - \ - ", + \ + ", endpoint = json_string(endpoint), + alive = json_string(alive), ); s } @@ -1733,6 +1764,7 @@ pub fn ending( message: &str, endpoint: &str, note_to: Option<&str>, + alive: &str, log: Account<'_>, series: &[String], ) -> String { @@ -1814,8 +1846,10 @@ pub fn ending( let _ = write!( s, "
the game is over
\ - ", + \ + ", endpoint = json_string(endpoint), + alive = json_string(alive), ); s } diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs index 490c5e8..b2aab01 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -122,15 +122,36 @@ var window = { location: { reload: function () { __reloaded(); } } }; // `__reply` is set per run. Chained `.then(fn)` passes fn's return value // on, matching the promise semantics the script relies on (`r.text()` // then the text). +// +// CB-WP-0035 adds the OTHER half: a request that is never answered. +// `.catch` is what the script uses to notice the server has gone, and a +// stub without it would repeat the defect this comment records -- the +// branch that fixes "the page still looks live" being unreachable here. +// `__failing` makes the rejection path real rather than assumed. var __reply = ""; +var __failing = false; +var __intervals = []; function fetch(url, opts) { __post(url, (opts && opts.body) || ""); function chain(v) { - return { then: function (fn) { return chain(fn ? fn(v) : v); } }; + return { + then: function (fn) { return chain(fn ? fn(v) : v); }, + catch: function () { return chain(v); } + }; } - return chain({ text: function () { return __reply; } }); + function rejected() { + return { + then: function () { return rejected(); }, + catch: function (fn) { if (fn) { fn(new Error("failed")); } return rejected(); } + }; + } + return __failing ? rejected() : chain({ ok: true, text: function () { return __reply; } }); } +// Recorded, not run: this harness is not a clock. A test fires them. +function setInterval(fn, ms) { __intervals.push(fn); return __intervals.length; } +function __beat() { for (var i = 0; i < __intervals.length; i++) { __intervals[i](); } } + // Gestures are driven against REGISTERED nodes, so the script's walk, its // classList writes and its querySelectorAll all see the same objects a // browser would -- rather than a synthetic {target:{id}} that bypasses @@ -240,6 +261,50 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result, String> /// `then`-chain that never called its callbacks, so nothing the script /// does in response to the server was reachable from any test. A page /// that ignores what it was told looked identical to one that acts on it. +/// A session that answers nothing: what a player faces when the server +/// has exited or the linger has run out (CB-WP-0035). +/// +/// `beat` fires the heartbeat instead of making a gesture, so the case +/// the maintainer actually hit -- **walking away and coming back** -- is +/// covered rather than only the case where they clicked something. +pub fn unanswered(html: &str, beat: bool) -> Result<(Vec, String), String> { + let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?; + ctx.add_callback("__post", |_url: String, _body: String| 0i32) + .map_err(|e| format!("register __post: {e}"))?; + ctx.add_callback("__reloaded", || 0i32) + .map_err(|e| format!("register __reloaded: {e}"))?; + prepare(&ctx, html)?; + ctx.eval("__failing = true;") + .map_err(|e| format!("set failing: {e}"))?; + if beat { + ctx.eval("__beat();").map_err(|e| format!("beat: {e}"))?; + } else { + ctx.eval("__down(\"done\"); __up(\"done\");") + .map_err(|e| format!("gesture: {e}"))?; + } + let keys: String = ctx + .eval_as("__liveKeys()") + .map_err(|e| format!("live keys: {e}"))?; + let status: String = ctx + .eval_as("__statusText()") + .map_err(|e| format!("status: {e}"))?; + let sealed: bool = ctx + .eval_as("__bodySealed()") + .map_err(|e| format!("body sealed: {e}"))?; + let status = if sealed { + format!("{status} [sealed]") + } else { + status + }; + Ok(( + keys.split(' ') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(), + status, + )) +} + pub fn gesture_with_reply( html: &str, down: &str, diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 942acdc..cce3c29 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -52,6 +52,7 @@ pub use doc::{document, text_of, Endpoints}; /// The endpoint pair every test in this crate posts to. #[cfg(test)] const TEST_ENDPOINTS: Endpoints<'static> = Endpoints { + alive: "/alive?t=x", command: "/command?t=x", note: "/note?t=x", }; @@ -608,6 +609,84 @@ mod gamelog { } } + /// CB-WP-0035. A server that has gone must be NOTICED. + /// + /// The script's `fetch` had no rejection handler, so when the process + /// had exited the promise rejected and the chain never ran — not even + /// the status line moved. Reported twice: *"play again does not + /// report that the session is no longer available"*, and the linger + /// timeout going unnoticed. + /// + /// **Both from one cause**, so both are asserted: the click path and + /// the heartbeat that needs no click. + #[test] + fn a_session_that_answers_nothing_is_reported_and_sealed() { + let page = crate::doc::ending( + None, + "the game ended", + "/command?t=x", + None, + "/alive?t=x", + crate::doc::Account::of(&[]), + &[], + ); + for (beat, what) in [(false, "pressing a control"), (true, "the heartbeat")] { + let (live, status) = crate::jsrun::unanswered(&page, beat).expect("run the page"); + assert!( + status.contains("no longer available"), + "{what} said nothing about the session: {status:?}" + ); + assert!( + status.contains("[sealed]"), + "{what} left the page looking live: {status:?}" + ); + assert!( + live.is_empty(), + "{what} left {live:?} still droppable on a dead session" + ); + } + } + + /// **Both** pages must carry the heartbeat (CB-WP-0035). + /// + /// The first cut wired it into the ending page only, and the gate + /// caught it as an unused variable — which is luck, not a control. + /// The timeout matters MOST during play: that is when a player walks + /// away, and the table is the thing that must stop looking live. + #[test] + fn every_page_can_tell_the_session_has_gone() { + let playing = document_with_log( + &crate::testfix::view(Some(PlayerId(0))), + &[], + crate::TEST_ENDPOINTS, + Some(PlayerId(0)), + false, + Account::of(&[]), + &[], + ); + let ended = crate::doc::ending( + None, + "m", + "/command?t=x", + None, + "/alive?t=x", + crate::doc::Account::of(&[]), + &[], + ); + for (name, page) in [("the table", &playing), ("the ending page", &ended)] { + assert!( + page.contains("CB_ALIVE"), + "{name} cannot notice the session ending" + ); + // With the token: an unauthenticated heartbeat is refused, + // and a refusal every 5s would seal a LIVE session. + assert!( + page.contains("/alive?t="), + "{name}'s heartbeat carries no token" + ); + } + } + /// CB-WP-0034. The move button must name **who**. /// /// Reported three times across three sessions, two days apart, and it @@ -1618,6 +1697,7 @@ mod ending_page { "the game ended", "/command?t=x", None, + "/alive?t=x", crate::doc::Account::of(&[]), &[], ) @@ -1704,6 +1784,7 @@ mod ending_page { "m", "/command?t=x", None, + "/alive?t=x", crate::doc::Account::of(&[]), &[], ), @@ -1762,6 +1843,7 @@ mod ending_page { "m", "/command?t=x", None, + "/alive?t=x", crate::doc::Account::of(&[]), &[], )) @@ -1786,6 +1868,7 @@ mod ending_page { "P1 ran out of input", "/command?t=x", None, + "/alive?t=x", crate::doc::Account::of(&[]), &[], )); @@ -1805,6 +1888,7 @@ mod ending_page { "m", "/command?t=x", None, + "/alive?t=x", crate::doc::Account::of(&[]), &[], )); @@ -1835,6 +1919,7 @@ mod ending_page { "m", "/command?t=x", None, + "/alive?t=x", crate::doc::Account::of(&[]), &[], )); diff --git a/crates/cb-render-html/src/serve.rs b/crates/cb-render-html/src/serve.rs index eb295ba..9ebfa2a 100644 --- a/crates/cb-render-html/src/serve.rs +++ b/crates/cb-render-html/src/serve.rs @@ -169,6 +169,20 @@ impl Guard { format!("/note?t={}", self.token) } + /// Where the page checks the session is still there (CB-WP-0035). + /// + /// **A dead server and a click that did nothing looked identical.** + /// The script's `fetch` had no rejection handler, so when the process + /// had exited the promise rejected, the chain never ran, and not even + /// the status line changed -- reported as `play again` doing nothing, + /// and as the linger timeout going unnoticed. + /// + /// Its own path rather than `/`, so a heartbeat costs a few bytes + /// instead of a whole re-render several times a minute. + pub fn alive_endpoint(&self) -> String { + format!("/alive?t={}", self.token) + } + /// The page's own path, token and all — for a `Location:` header. /// /// **A redirect back to bare `/` is refused**, because control 1 diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index 27dc96d..dcb1c69 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -351,6 +351,7 @@ impl Server { cb_render_html::Endpoints { command: &self.guard.endpoint(), note: &self.guard.note_endpoint(), + alive: &self.guard.alive_endpoint(), }, Some(seat), may_pass, @@ -365,6 +366,10 @@ impl Server { ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } + // CB-WP-0035. Cheap proof the session is still here. It + // advances nothing and decides nothing -- the loop keeps + // waiting for the seat's actual move. + ("GET", "/alive") => respond(&mut stream, 200, "text/plain", "here"), ("POST", "/command") => { let fact = match PointerFact::parse(&req.body) { Ok(f) => f, @@ -490,6 +495,7 @@ impl Server { message, &self.guard.endpoint(), note_to.as_deref(), + &self.guard.alive_endpoint(), cb_render_html::doc::Account { lines: &self.log_lines(), notes: &self.log_notes(), @@ -525,6 +531,10 @@ impl Server { Err(why) => respond(&mut stream, 400, "text/plain", &why), } } + // CB-WP-0035. Answered here too, and it does NOT extend + // the linger: the deadline is absolute, so a tab left + // open still cannot hold the process forever. + ("GET", "/alive") => respond(&mut stream, 200, "text/plain", "here"), ("POST", "/command") => { // CB-WP-0020 T05: the browser could not start a second // game without going back to a terminal, which made it @@ -1049,6 +1059,7 @@ mod tests { "it broke", "/c", None, + "/alive?t=x", cb_render_html::doc::Account::of(&[]), &[], ); @@ -1266,6 +1277,7 @@ mod tests { "P1 ran out of input", "/command?t=x", None, + "/alive?t=x", cb_render_html::doc::Account::of(&[]), &[], ); diff --git a/trials/2026-08-07-1829.yaml b/trials/2026-08-07-1829.yaml new file mode 100644 index 0000000..ac1c054 --- /dev/null +++ b/trials/2026-08-07-1829.yaml @@ -0,0 +1,149 @@ +scenario: ground/cb-play-session +description: recorded by cb-play (CB-WP-0008 T02) +covers: [] +provisional: false +provisional_owner: '' +provisional_raised: '' +ruled: '' +ruled_by: '' +ruled_note: '' +encodes_u_item: '' +seed: 1 +setup: + players: 3 + preset: standard-3p + patch: {} +commands: +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SUPPORT + target: P2 +- actor: P2 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: P3 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: respond_to_support + args: + response: accept_bond +- actor: P2 + cmd: respond_to_support + args: + response: accept_bond +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +expect: + events: [] + state: {} + rejects: [] + state_hash: 15e28280a256249f4ebddbeb638053d0683d996b7ff1193128a7d788f32f63ec diff --git a/workplans/CB-WP-0034-who-you-are-bonding-with.md b/workplans/CB-WP-0034-who-you-are-bonding-with.md index 65f3490..063796b 100644 --- a/workplans/CB-WP-0034-who-you-are-bonding-with.md +++ b/workplans/CB-WP-0034-who-you-are-bonding-with.md @@ -3,6 +3,7 @@ id: CB-WP-0034 kind: product title: "Who you are bonding with" status: done +state_hub_workstream_id: "bde0c1d5-a76c-4431-a48a-a9fa81f4d0cc" --- # Purpose @@ -52,6 +53,7 @@ neighbours as they were. id: CB-WP-0034-T01 status: done priority: high +state_hub_task_id: "d8429ce2-991a-45df-9fcd-6b70b0e81511" ``` **Controls:** diff --git a/workplans/CB-WP-0035-a-session-that-answers-nothing.md b/workplans/CB-WP-0035-a-session-that-answers-nothing.md new file mode 100644 index 0000000..9b5943a --- /dev/null +++ b/workplans/CB-WP-0035-a-session-that-answers-nothing.md @@ -0,0 +1,78 @@ +--- +id: CB-WP-0035 +kind: product +title: "A session that answers nothing" +status: done +--- + +# Purpose + +``` +structural tier S (one endpoint, one rejection handler; no rule, no + dependency, no artifact contract moved) +chaos d8 = 4 → no override +declared tier S +``` + +**Declaration 6 of chaos window 3.** + +## The report, and it is one defect not two + +> *"I think the server timing out is not noticed by the client. And 'Play +> Again' does not report that the session is no longer available."* + +Both come from the same three characters. The script's request was + +```js +fetch(...).then(...).then(...) // and no .catch +``` + +so when the process had exited the promise **rejected, the chain never +ran, and not even the status line moved.** A dead server and a click that +did nothing were indistinguishable — which is why `play again` read as a +dead button and the linger timeout was invisible. + +**A request that cannot be answered is information**, and it was being +thrown away. + +## Task: notice, and say so + +```task +id: CB-WP-0035-T01 +status: done +priority: high +``` + +**Controls:** +- **both paths** — pressing a control, and noticing without pressing + anything, which is the timeout case; +- **both pages**, because the timeout matters most *during play*; +- the heartbeat **carries the token**, or a refusal every five seconds + would seal a live session; +- mutation-proven: remove the `.catch` and the status goes empty, which is + the reported symptom exactly. + +**Done 2026-08-07.** `.catch(gone)`, plus a 5-second heartbeat against a +new `/alive` — its own path so a beat costs a few bytes rather than a +whole re-render. It **does not extend the linger**: that deadline is +absolute, so a tab left open still cannot hold the process open. + +**The harness had the same hole as the code.** `jsrun`'s `fetch` stub had +no `.catch`, so the branch that notices a dead server would have been +unreachable in every test — *the very defect the stub's own comment +records from CB-WP-0024*, where a then-chain that never invoked its +callbacks let "the page still looks live" survive. Teaching the stub +`__failing`, `.catch` and a recorded `setInterval` was the fix; writing +the script defensively instead would have repeated the trap. + +**The first cut wired the heartbeat into the ending page only.** An unused +variable caught it — luck, not a control — so `every_page_can_tell_the_ +session_has_gone` now asserts it on both. + +## Not done here + +- **Five seconds is a guess.** Nothing measures how long a player tolerates + a dead-looking page, and this pass did not find out. +- **A brief network stall seals the page**, the same as a dead server. For + a loopback-only server that is nearly always the truth, but it is a + false positive waiting for the first non-loopback deployment.