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:
parent
c2a7e40b67
commit
82aead6cea
9 changed files with 448 additions and 7 deletions
|
|
@ -41,6 +41,7 @@
|
||||||
| workplan | CB-WP-0031 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.md |
|
| 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-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-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-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-T02 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
||||||
| task | CB-WP-0001-T03 | 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-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-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-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 |
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,9 @@ pub const SCRIPT: &str = r#"
|
||||||
//
|
//
|
||||||
// No game vocabulary here (ADR-0007 D5). 'closed' is a server
|
// No game vocabulary here (ADR-0007 D5). 'closed' is a server
|
||||||
// lifecycle word, exactly like the 'ok' branch below it.
|
// lifecycle word, exactly like the 'ok' branch below it.
|
||||||
|
var sealed = false;
|
||||||
function seal() {
|
function seal() {
|
||||||
|
sealed = true;
|
||||||
// The WHOLE page goes inert, not only the controls. A greyed-out
|
// 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
|
// button beside a full-colour table still reads as a live game with
|
||||||
// one broken control; the session has ended and everything on screen
|
// one broken control; the session has ended and everything on screen
|
||||||
|
|
@ -206,8 +208,33 @@ pub const SCRIPT: &str = r#"
|
||||||
status(t);
|
status(t);
|
||||||
if (t.indexOf('ok') === 0) { window.location.reload(); }
|
if (t.indexOf('ok') === 0) { window.location.reload(); }
|
||||||
else if (t.indexOf('closed') === 0) { seal(); }
|
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 {
|
Endpoints {
|
||||||
command: endpoint,
|
command: endpoint,
|
||||||
note: "/note",
|
note: "/note",
|
||||||
|
alive: "/alive",
|
||||||
},
|
},
|
||||||
seat,
|
seat,
|
||||||
may_pass,
|
may_pass,
|
||||||
|
|
@ -969,6 +997,8 @@ pub struct Endpoints<'a> {
|
||||||
pub command: &'a str,
|
pub command: &'a str,
|
||||||
/// Free text. Nothing turns these into commands.
|
/// Free text. Nothing turns these into commands.
|
||||||
pub note: &'a str,
|
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(
|
pub fn document_with_log(
|
||||||
|
|
@ -980,7 +1010,7 @@ pub fn document_with_log(
|
||||||
log: Account<'_>,
|
log: Account<'_>,
|
||||||
meta: &[String],
|
meta: &[String],
|
||||||
) -> 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 mut s = String::with_capacity(8192);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
|
|
@ -1019,9 +1049,10 @@ pub fn document_with_log(
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
"<div id=\"cb-status\">ready</div>\
|
"<div id=\"cb-status\">ready</div>\
|
||||||
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>\
|
<script>window.CB_ENDPOINT={endpoint};window.CB_ALIVE={alive}</script>\
|
||||||
</body></html>",
|
<script>{SCRIPT}</script></body></html>",
|
||||||
endpoint = json_string(endpoint),
|
endpoint = json_string(endpoint),
|
||||||
|
alive = json_string(alive),
|
||||||
);
|
);
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
@ -1733,6 +1764,7 @@ pub fn ending(
|
||||||
message: &str,
|
message: &str,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
note_to: Option<&str>,
|
note_to: Option<&str>,
|
||||||
|
alive: &str,
|
||||||
log: Account<'_>,
|
log: Account<'_>,
|
||||||
series: &[String],
|
series: &[String],
|
||||||
) -> String {
|
) -> String {
|
||||||
|
|
@ -1814,8 +1846,10 @@ pub fn ending(
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
"<div id=\"cb-status\">the game is over</div>\
|
"<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),
|
endpoint = json_string(endpoint),
|
||||||
|
alive = json_string(alive),
|
||||||
);
|
);
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,15 +122,36 @@ var window = { location: { reload: function () { __reloaded(); } } };
|
||||||
// `__reply` is set per run. Chained `.then(fn)` passes fn's return value
|
// `__reply` is set per run. Chained `.then(fn)` passes fn's return value
|
||||||
// on, matching the promise semantics the script relies on (`r.text()`
|
// on, matching the promise semantics the script relies on (`r.text()`
|
||||||
// then the 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 __reply = "";
|
||||||
|
var __failing = false;
|
||||||
|
var __intervals = [];
|
||||||
function fetch(url, opts) {
|
function fetch(url, opts) {
|
||||||
__post(url, (opts && opts.body) || "");
|
__post(url, (opts && opts.body) || "");
|
||||||
function chain(v) {
|
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
|
// Gestures are driven against REGISTERED nodes, so the script's walk, its
|
||||||
// classList writes and its querySelectorAll all see the same objects a
|
// classList writes and its querySelectorAll all see the same objects a
|
||||||
// browser would -- rather than a synthetic {target:{id}} that bypasses
|
// 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
|
/// `then`-chain that never called its callbacks, so nothing the script
|
||||||
/// does in response to the server was reachable from any test. A page
|
/// 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.
|
/// 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(
|
pub fn gesture_with_reply(
|
||||||
html: &str,
|
html: &str,
|
||||||
down: &str,
|
down: &str,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ pub use doc::{document, text_of, Endpoints};
|
||||||
/// The endpoint pair every test in this crate posts to.
|
/// The endpoint pair every test in this crate posts to.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
const TEST_ENDPOINTS: Endpoints<'static> = Endpoints {
|
const TEST_ENDPOINTS: Endpoints<'static> = Endpoints {
|
||||||
|
alive: "/alive?t=x",
|
||||||
command: "/command?t=x",
|
command: "/command?t=x",
|
||||||
note: "/note?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**.
|
/// CB-WP-0034. The move button must name **who**.
|
||||||
///
|
///
|
||||||
/// Reported three times across three sessions, two days apart, and it
|
/// Reported three times across three sessions, two days apart, and it
|
||||||
|
|
@ -1618,6 +1697,7 @@ mod ending_page {
|
||||||
"the game ended",
|
"the game ended",
|
||||||
"/command?t=x",
|
"/command?t=x",
|
||||||
None,
|
None,
|
||||||
|
"/alive?t=x",
|
||||||
crate::doc::Account::of(&[]),
|
crate::doc::Account::of(&[]),
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
|
|
@ -1704,6 +1784,7 @@ mod ending_page {
|
||||||
"m",
|
"m",
|
||||||
"/command?t=x",
|
"/command?t=x",
|
||||||
None,
|
None,
|
||||||
|
"/alive?t=x",
|
||||||
crate::doc::Account::of(&[]),
|
crate::doc::Account::of(&[]),
|
||||||
&[],
|
&[],
|
||||||
),
|
),
|
||||||
|
|
@ -1762,6 +1843,7 @@ mod ending_page {
|
||||||
"m",
|
"m",
|
||||||
"/command?t=x",
|
"/command?t=x",
|
||||||
None,
|
None,
|
||||||
|
"/alive?t=x",
|
||||||
crate::doc::Account::of(&[]),
|
crate::doc::Account::of(&[]),
|
||||||
&[],
|
&[],
|
||||||
))
|
))
|
||||||
|
|
@ -1786,6 +1868,7 @@ mod ending_page {
|
||||||
"P1 ran out of input",
|
"P1 ran out of input",
|
||||||
"/command?t=x",
|
"/command?t=x",
|
||||||
None,
|
None,
|
||||||
|
"/alive?t=x",
|
||||||
crate::doc::Account::of(&[]),
|
crate::doc::Account::of(&[]),
|
||||||
&[],
|
&[],
|
||||||
));
|
));
|
||||||
|
|
@ -1805,6 +1888,7 @@ mod ending_page {
|
||||||
"m",
|
"m",
|
||||||
"/command?t=x",
|
"/command?t=x",
|
||||||
None,
|
None,
|
||||||
|
"/alive?t=x",
|
||||||
crate::doc::Account::of(&[]),
|
crate::doc::Account::of(&[]),
|
||||||
&[],
|
&[],
|
||||||
));
|
));
|
||||||
|
|
@ -1835,6 +1919,7 @@ mod ending_page {
|
||||||
"m",
|
"m",
|
||||||
"/command?t=x",
|
"/command?t=x",
|
||||||
None,
|
None,
|
||||||
|
"/alive?t=x",
|
||||||
crate::doc::Account::of(&[]),
|
crate::doc::Account::of(&[]),
|
||||||
&[],
|
&[],
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,20 @@ impl Guard {
|
||||||
format!("/note?t={}", self.token)
|
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.
|
/// The page's own path, token and all — for a `Location:` header.
|
||||||
///
|
///
|
||||||
/// **A redirect back to bare `/` is refused**, because control 1
|
/// **A redirect back to bare `/` is refused**, because control 1
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,7 @@ impl Server {
|
||||||
cb_render_html::Endpoints {
|
cb_render_html::Endpoints {
|
||||||
command: &self.guard.endpoint(),
|
command: &self.guard.endpoint(),
|
||||||
note: &self.guard.note_endpoint(),
|
note: &self.guard.note_endpoint(),
|
||||||
|
alive: &self.guard.alive_endpoint(),
|
||||||
},
|
},
|
||||||
Some(seat),
|
Some(seat),
|
||||||
may_pass,
|
may_pass,
|
||||||
|
|
@ -365,6 +366,10 @@ impl Server {
|
||||||
);
|
);
|
||||||
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
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") => {
|
("POST", "/command") => {
|
||||||
let fact = match PointerFact::parse(&req.body) {
|
let fact = match PointerFact::parse(&req.body) {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
|
|
@ -490,6 +495,7 @@ impl Server {
|
||||||
message,
|
message,
|
||||||
&self.guard.endpoint(),
|
&self.guard.endpoint(),
|
||||||
note_to.as_deref(),
|
note_to.as_deref(),
|
||||||
|
&self.guard.alive_endpoint(),
|
||||||
cb_render_html::doc::Account {
|
cb_render_html::doc::Account {
|
||||||
lines: &self.log_lines(),
|
lines: &self.log_lines(),
|
||||||
notes: &self.log_notes(),
|
notes: &self.log_notes(),
|
||||||
|
|
@ -525,6 +531,10 @@ impl Server {
|
||||||
Err(why) => respond(&mut stream, 400, "text/plain", &why),
|
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") => {
|
("POST", "/command") => {
|
||||||
// CB-WP-0020 T05: the browser could not start a second
|
// CB-WP-0020 T05: the browser could not start a second
|
||||||
// game without going back to a terminal, which made it
|
// game without going back to a terminal, which made it
|
||||||
|
|
@ -1049,6 +1059,7 @@ mod tests {
|
||||||
"it broke",
|
"it broke",
|
||||||
"/c",
|
"/c",
|
||||||
None,
|
None,
|
||||||
|
"/alive?t=x",
|
||||||
cb_render_html::doc::Account::of(&[]),
|
cb_render_html::doc::Account::of(&[]),
|
||||||
&[],
|
&[],
|
||||||
);
|
);
|
||||||
|
|
@ -1266,6 +1277,7 @@ mod tests {
|
||||||
"P1 ran out of input",
|
"P1 ran out of input",
|
||||||
"/command?t=x",
|
"/command?t=x",
|
||||||
None,
|
None,
|
||||||
|
"/alive?t=x",
|
||||||
cb_render_html::doc::Account::of(&[]),
|
cb_render_html::doc::Account::of(&[]),
|
||||||
&[],
|
&[],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
149
trials/2026-08-07-1829.yaml
Normal file
149
trials/2026-08-07-1829.yaml
Normal file
|
|
@ -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
|
||||||
|
|
@ -3,6 +3,7 @@ id: CB-WP-0034
|
||||||
kind: product
|
kind: product
|
||||||
title: "Who you are bonding with"
|
title: "Who you are bonding with"
|
||||||
status: done
|
status: done
|
||||||
|
state_hub_workstream_id: "bde0c1d5-a76c-4431-a48a-a9fa81f4d0cc"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Purpose
|
# Purpose
|
||||||
|
|
@ -52,6 +53,7 @@ neighbours as they were.
|
||||||
id: CB-WP-0034-T01
|
id: CB-WP-0034-T01
|
||||||
status: done
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "d8429ce2-991a-45df-9fcd-6b70b0e81511"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Controls:**
|
**Controls:**
|
||||||
|
|
|
||||||
78
workplans/CB-WP-0035-a-session-that-answers-nothing.md
Normal file
78
workplans/CB-WP-0035-a-session-that-answers-nothing.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue