fix(ui): also resync an open game on a foreground regain without a stream drop
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s

Closes the residual tail of the previous commit: when the live stream stays
alive across a brief suspend (Telegram/iOS can pause the socket without tearing
it down), an in-game event shed from a full hub buffer is never recovered by
the reconnect refetch (no reconnect) or the open-game poll (it only runs while
the stream is down). Mirror the lobby's focus re-poll: bump app.resync on a
foreground regain that did not drop the stream, and have Game.svelte refetch
the open game once per resync. Also rescues a missed move/game_over after a
suspend. Add a silent-join mock seam + an e2e isolating this path; extend the
ARCHITECTURE §10 fallback note.
This commit is contained in:
Ilia Denisov
2026-06-14 00:14:49 +02:00
parent 16402e64c0
commit 4409253dce
6 changed files with 60 additions and 9 deletions
+3 -2
View File
@@ -574,8 +574,9 @@ verbatim. Auto-match needs no match poll — `Enqueue` returns the game the play
synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined** synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined**
event. Unlike a move, that event has no follow-up delta to trigger the move-count gap recovery, so event. Unlike a move, that event has no follow-up delta to trigger the move-count gap recovery, so
the waiting game screen recovers a missed join itself: it **polls `game.state` while the stream is the waiting game screen recovers a missed join itself: it **polls `game.state` while the stream is
down and refetches once on stream reconnect**, so a join missed during an outage or while the app down, refetches once on stream reconnect, and resyncs on a foreground regain that did not drop the
was backgrounded still resolves the open game in place. For the lobby **notification badge** (incoming friend requests + open stream** (covering an event shed from a full hub buffer while suspended), so a join missed during an
outage or while the app was backgrounded still resolves the open game in place. For the lobby **notification badge** (incoming friend requests + open
invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push
missed while the app was hidden. **Out-of-app platform push** is a fallback missed while the app was hidden. **Out-of-app platform push** is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no the **gateway** routes from the same firehose: for an event whose recipient has **no
+12
View File
@@ -64,3 +64,15 @@ test('quick game: a stream reconnect recovers a join missed while it was down',
await expect(page.getByText('Robo')).toBeVisible(); await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
}); });
test('quick game: a foreground resync recovers a join shed while the stream stayed alive', async ({ page }) => {
await enterOpenGame(page);
// The opponent joins but the event is shed with the stream still alive (no reconnect, and the
// poll only runs while the stream is down): nothing has recovered yet.
await page.evaluate(() => (window as unknown as { __mock: { joinOpponentSilently(): void } }).__mock.joinOpponentSilently());
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
// Returning to the foreground resyncs the open game (pageshow drives goForeground).
await page.evaluate(() => window.dispatchEvent(new Event('pageshow')));
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
});
+11
View File
@@ -263,6 +263,17 @@
return () => clearInterval(timer); return () => clearInterval(timer);
}); });
// (C) Returning to the foreground without the stream having dropped (so (A) does not fire) may
// still have missed an event shed from a full hub buffer while suspended; refetch once per resync.
let lastResync = app.resync;
$effect(() => {
const r = app.resync;
if (r !== lastResync) {
lastResync = r;
void load();
}
});
function isCoarse(): boolean { function isCoarse(): boolean {
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
} }
+12 -1
View File
@@ -55,6 +55,10 @@ export const app = $state<{
notifications: number; notifications: number;
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */ /** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
chatUnread: Record<string, number>; chatUnread: Record<string, number>;
/** Monotonic counter bumped when the app returns to the foreground without the live stream
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
resync: number;
}>({ }>({
ready: false, ready: false,
streamAlive: false, streamAlive: false,
@@ -70,6 +74,7 @@ export const app = $state<{
localeLocked: false, localeLocked: false,
notifications: 0, notifications: 0,
chatUnread: {}, chatUnread: {},
resync: 0,
}); });
let unsubscribeStream: (() => void) | null = null; let unsubscribeStream: (() => void) | null = null;
@@ -104,7 +109,13 @@ function goForeground(): void {
backgrounded = false; backgrounded = false;
foregroundedAt = Date.now(); foregroundedAt = Date.now();
if (!app.session) return; if (!app.session) return;
if (!app.streamAlive) openStream(); // silently re-establish a stream dropped while away if (!app.streamAlive) {
openStream(); // re-establish a stream dropped while away (its reconnect refetch then runs)
} else {
// The stream stayed alive across the suspend, so the reconnect refetch will not fire — but an
// event may have been shed from a full hub buffer while away; nudge an open game to refetch.
app.resync++;
}
void refreshNotifications(); void refreshNotifications();
} }
+2 -1
View File
@@ -22,7 +22,8 @@ if (isMock && typeof window !== 'undefined') {
}; };
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise // Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer). // attaches a robot on a timer).
(window as unknown as { __mock?: { joinOpponent(): void } }).__mock = { (window as unknown as { __mock?: { joinOpponent(): void; joinOpponentSilently(): void } }).__mock = {
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(), joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
}; };
} }
+20 -5
View File
@@ -181,14 +181,22 @@ export class MockGateway implements GatewayClient {
return { matched: false, game: structuredClone(g.view) }; return { matched: false, game: structuredClone(g.view) };
} }
// seatOpponent seats a robot in an open game's empty seat and returns the game (null once it is no
// longer open). Shared by the live join (which then pushes opponent_joined) and the silent e2e
// variant (which omits the push).
private seatOpponent(id: string): MockGame | null {
const game = this.games.get(id);
if (!game || game.view.status !== 'open') return null;
game.view.status = 'active';
game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
return game;
}
// fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the // fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the
// mock of a human or robot taking the seat. A no-op once the game is no longer open. // mock of a human or robot taking the seat. A no-op once the game is no longer open.
private fillOpponent(id: string): void { private fillOpponent(id: string): void {
const game = this.games.get(id); const game = this.seatOpponent(id);
if (!game || game.view.status !== 'open') return; if (game) this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
game.view.status = 'active';
game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
} }
// joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the // joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
@@ -198,6 +206,13 @@ export class MockGateway implements GatewayClient {
if (this.openGameId) this.fillOpponent(this.openGameId); if (this.openGameId) this.fillOpponent(this.openGameId);
} }
// joinPendingOpponentSilently seats the opponent WITHOUT pushing opponent_joined — the mock of an
// event shed from a full hub buffer while the stream is alive, so the UI recovers only on a
// foreground resync. e2e hook: window.__mock.joinOpponentSilently.
joinPendingOpponentSilently(): void {
if (this.openGameId) this.seatOpponent(this.openGameId);
}
async lobbyPoll(): Promise<MatchResult> { async lobbyPoll(): Promise<MatchResult> {
if (this.pendingMatch) { if (this.pendingMatch) {
const g = this.games.get(this.pendingMatch); const g = this.games.get(this.pendingMatch);