v0.5.5 — a remembered session for a game that no longer exists
Reported after updating to v0.5.4: clicking Multiplayer went straight into a game with no lobby and no controls, and the board was blank. Three things lined up. start() enters a remembered session WITHOUT checking it still exists — that is what makes reconnection seamless, and it is why the lobby was skipped. The v0.5.4 update had refused to resume that game, its save being recorded under v0.5.3 and the engine-version check being exact (D7). And createRemoteSession had no onerror at all, so EventSource retried the resulting 404 forever in silence while frame stayed null and nothing rendered. The only escape was clearing site data, and nothing on screen said so. v0.5.3's Manage Game -> End had just widened the same dead end: it closes every watcher's stream, so a player whose game an administrator ended would sit frozen on a stale board indefinitely, for exactly the same reason. GET /api/session?token= is new: a cheap yes/no on whether a token still names a live game. EventSource fires error identically for a transient blip — the expected shape of a game idle for minutes (§9) — and for a 404 it will retry forever, and exposes no status code either way, so the client asks rather than guessing. Only a definite 404 closes the stream and reports the game gone; a flaky network still self-heals. The page then forgets the stored session, says why (ended by an administrator, or the service was updated, which does not carry games across), and drops into the lobby. Forgetting the token is what stops the next load repeating it. It also stops rendering nothing while it waits — "… connecting to the game" sits in the presence banner until the first push arrives, because a page showing nothing is indistinguishable from a broken one, which is what this looked like. Recorded but NOT fixed, in TODO.md: three releases in a row destroyed every game in progress, and v0.5.4's changes were rendering only. The refusal is right, but the test is exact equality against the PACKAGE version, which moves for reasons unrelated to the rules. Three options costed; the recommendation is to replay the save and refuse only if an intent actually rejects — the real question rather than a proxy for it, and a full replay measures ~100 ms. Verified live: /api/session answers 200 for a seated token, 404 once an administrator ends the game, 404 for a garbage token, and /api/stream 404s in the same state — which is the response EventSource had been retrying silently. 673 tests pass.
This commit is contained in:
@@ -481,6 +481,27 @@ export function startServer(opts: ServerOptions): void {
|
||||
|
||||
// -- The running game (token-authenticated) ------------------------------------------------
|
||||
|
||||
/**
|
||||
* IS THIS TOKEN STILL GOOD FOR ANYTHING?
|
||||
*
|
||||
* A browser remembers its session in `localStorage` and re-enters the game on the next load
|
||||
* without asking, which is what makes reconnection seamless — and what leaves it stranded
|
||||
* when the game is gone. `EventSource` cannot report a status code and retries a 404
|
||||
* silently forever, so the client needs somewhere cheap to ask a yes/no question. Two ways a
|
||||
* game legitimately disappears under a player: an engine-version bump refuses to resume it
|
||||
* (D7), and an administrator ends it (`DELETE /api/games/<id>`).
|
||||
*/
|
||||
if (url.pathname === '/api/session' && req.method === 'GET') {
|
||||
const ps = sessions.get(url.searchParams.get('token') ?? '');
|
||||
const live = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !live) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { gameId: ps.gameId, player: ps.player });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/stream' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
|
||||
+33
-1
@@ -333,7 +333,11 @@ function showScreen(which: 'lobby' | 'gameui'): void {
|
||||
function beginRemote(ready: LobbyReady): void {
|
||||
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
|
||||
showScreen('gameui');
|
||||
session = createRemoteSession(ready.token, ready.seat);
|
||||
// Nothing can be drawn until the first push arrives, and a page showing nothing at all is
|
||||
// indistinguishable from a page that is broken — which is exactly what a dead session used to
|
||||
// look like, forever.
|
||||
$('presence').textContent = '… connecting to the game';
|
||||
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
|
||||
applyCapabilities();
|
||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
||||
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
|
||||
@@ -342,6 +346,31 @@ function beginRemote(ready: LobbyReady): void {
|
||||
session.subscribe(render);
|
||||
}
|
||||
|
||||
/**
|
||||
* The game this browser remembered is gone, so stop waiting for it and go somewhere useful.
|
||||
*
|
||||
* Two things legitimately destroy a game under a seated player, and both are by design: an
|
||||
* engine-version bump refuses to resume it (D7 — a move legal under the old rules may not be under
|
||||
* the new ones), and an administrator ends it. Neither used to be survivable here. The remembered
|
||||
* token sent `start()` straight past the lobby into a game that no longer existed, `EventSource`
|
||||
* retried the 404 in silence, and the player sat on a blank page with no controls and no way back
|
||||
* short of clearing site data.
|
||||
*
|
||||
* Forgetting the token is what makes the next load land in the lobby instead of repeating it.
|
||||
*/
|
||||
function abandonRemote(): void {
|
||||
localStorage.removeItem(REMOTE_KEY);
|
||||
showScreen('lobby');
|
||||
$('presence').textContent = '';
|
||||
runLobby(beginRemote);
|
||||
const note = document.getElementById('lb-create-err');
|
||||
if (note) {
|
||||
note.textContent =
|
||||
'That game is no longer on this server — it was either ended by whoever runs it, or the ' +
|
||||
'service was updated, which does not carry games in progress across. Create or join a new one.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through
|
||||
* `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL
|
||||
@@ -352,6 +381,9 @@ function beginRemote(ready: LobbyReady): void {
|
||||
function start(): void {
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// Entered without checking it still exists — deliberately. Verifying up front would mean an
|
||||
// await before anything renders on the common path, where the game IS still there; instead the
|
||||
// session reports a dead game through `abandonRemote`, which lands in the lobby.
|
||||
const remembered = loadRemote();
|
||||
if (remembered) {
|
||||
beginRemote(remembered);
|
||||
|
||||
+34
-1
@@ -226,7 +226,16 @@ type Push = {
|
||||
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
|
||||
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
|
||||
*/
|
||||
export function createRemoteSession(token: string, seat: PlayerIndex): Session {
|
||||
export function createRemoteSession(
|
||||
token: string,
|
||||
seat: PlayerIndex,
|
||||
/**
|
||||
* Called once when this session's game is established to be gone for good, so the page can stop
|
||||
* waiting for it. Without this the only symptom is a blank screen: `EventSource` retries a 404
|
||||
* forever and reports nothing, and `frame` never becomes non-null.
|
||||
*/
|
||||
onGone?: () => void,
|
||||
): Session {
|
||||
let frame: Frame | null = null;
|
||||
let menu: Menu | null = null;
|
||||
let lines: { text: string; tone: string }[] = [];
|
||||
@@ -239,6 +248,30 @@ export function createRemoteSession(token: string, seat: PlayerIndex): Session {
|
||||
|
||||
const qs = `token=${encodeURIComponent(token)}`;
|
||||
const source = new EventSource(`/api/stream?${qs}`);
|
||||
|
||||
/**
|
||||
* A DROPPED CONNECTION AND A DEAD GAME LOOK IDENTICAL HERE, so ask before giving up.
|
||||
*
|
||||
* `EventSource` fires `error` for both a transient blip — which it recovers from by itself, and
|
||||
* which is the expected shape of a game that sits idle for minutes (multiplayer.md §9) — and a
|
||||
* 404 it will nonetheless retry forever. It exposes no status code either way. `/api/session` is
|
||||
* the cheap question that separates them: only a definite 404 closes the stream and reports the
|
||||
* game gone, so a flaky network still self-heals.
|
||||
*/
|
||||
let reportedGone = false;
|
||||
source.onerror = () => {
|
||||
if (reportedGone) return;
|
||||
void fetch(`/api/session?${qs}`)
|
||||
.then((r) => {
|
||||
if (r.status !== 404 || reportedGone) return;
|
||||
reportedGone = true;
|
||||
source.close();
|
||||
onGone?.();
|
||||
})
|
||||
.catch(() => {
|
||||
// The probe itself failed, so this says nothing about the game — leave the retry running.
|
||||
});
|
||||
};
|
||||
source.onmessage = (ev: MessageEvent<string>) => {
|
||||
const push = JSON.parse(ev.data) as Push;
|
||||
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
|
||||
|
||||
Reference in New Issue
Block a user