CB-WP-0035: a session that answers nothing
Some checks failed
ci / check (push) Failing after 4s

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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-07 20:31:32 +02:00
parent c2a7e40b67
commit 82aead6cea
9 changed files with 448 additions and 7 deletions

View file

@ -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,
"<div id=\"cb-status\">ready</div>\
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>\
</body></html>",
<script>window.CB_ENDPOINT={endpoint};window.CB_ALIVE={alive}</script>\
<script>{SCRIPT}</script></body></html>",
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,
"<div id=\"cb-status\">the game is over</div>\
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",
<script>window.CB_ENDPOINT={endpoint};window.CB_ALIVE={alive}</script>\
<script>{SCRIPT}</script>",
endpoint = json_string(endpoint),
alive = json_string(alive),
);
s
}

View file

@ -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<Vec<Posted>, 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), 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,

View file

@ -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(&[]),
&[],
));

View file

@ -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