fix(ui): abort a stalled dictionary download; trip the breaker on warm timeouts
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s

On a slow link (EDGE) a dictionary download kept running in the background after the
warm-up gave up or the game was left, starving the channel — a new vs-AI game would
not open, and the breaker (which only counted fetch errors) never tripped, so the
warm-up overlay kept reappearing instead of settling to the network after a few
tries. Now:
- the warm-up passes an AbortSignal and aborts the download at its 5s cap, and the
  game aborts it on unmount (leaving the game), so a large download stops holding the
  channel — a subsequent action (creating a game) gets the bandwidth.
- the breaker counts warm-up misses (a load that failed OR hit the cap), not just
  fetch errors, so a persistently slow link trips it after 3 and the preview then
  goes straight to the network.
This commit is contained in:
Ilia Denisov
2026-07-01 22:21:08 +02:00
parent d57dbbab4f
commit 6a6fdffd7f
6 changed files with 49 additions and 25 deletions
+17 -1
View File
@@ -57,6 +57,9 @@
let dict = $state<typeof import('../lib/dict') | null>(null); let dict = $state<typeof import('../lib/dict') | null>(null);
let dictWarming = $state(false); let dictWarming = $state(false);
let warmStarted = false; let warmStarted = false;
// Aborts the in-flight dictionary download (at the warm-up cap, or when leaving the game)
// so a large download does not keep starving the channel on a slow link.
let warmCtrl: AbortController | null = null;
let zoomed = $state(false); let zoomed = $state(false);
let selected = $state<number | null>(null); let selected = $state<number | null>(null);
let focus = $state<{ row: number; col: number } | null>(null); let focus = $state<{ row: number; col: number } | null>(null);
@@ -594,6 +597,8 @@
clearReorder(); clearReorder();
} }
onDestroy(() => { onDestroy(() => {
// Leaving the game: abort a still-downloading dictionary so it stops holding the channel.
warmCtrl?.abort();
window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer); window.removeEventListener('pointerdown', onExtraPointer);
@@ -673,16 +678,27 @@
const v = view; const v = view;
if (!d || !v) return; if (!d || !v) return;
if (d.hasDawg(v.game.variant, v.game.dictVersion) || d.dictLoadingDisabled()) return; if (d.hasDawg(v.game.variant, v.game.dictVersion) || d.dictLoadingDisabled()) return;
const ctrl = new AbortController();
warmCtrl = ctrl;
let settled = false; let settled = false;
// A short flash-guard: a disk-cached dictionary loads in a few ms, so only show the // A short flash-guard: a disk-cached dictionary loads in a few ms, so only show the
// overlay if the load has not settled by now — a cold (network) load always outlasts it. // overlay if the load has not settled by now — a cold (network) load always outlasts it.
const grace = setTimeout(() => { const grace = setTimeout(() => {
if (!settled) dictWarming = true; if (!settled) dictWarming = true;
}, 120); }, 120);
await Promise.race([d.getDawg(v.game.variant, v.game.dictVersion), new Promise((r) => setTimeout(r, 5000))]); const loaded = await Promise.race([
d.getDawg(v.game.variant, v.game.dictVersion, ctrl.signal),
new Promise<null>((r) => setTimeout(() => r(null), 5000)),
]);
settled = true; settled = true;
clearTimeout(grace); clearTimeout(grace);
dictWarming = false; dictWarming = false;
if (!loaded) {
// Did not load within the cap: abort the download so it stops starving the channel this
// session, and count the miss toward the bad-connection breaker (3 -> stop warming).
ctrl.abort();
d.noteDictMiss();
}
} }
let previewTimer: ReturnType<typeof setTimeout> | null = null; let previewTimer: ReturnType<typeof setTimeout> | null = null;
+3 -2
View File
@@ -99,8 +99,9 @@ export interface GatewayClient {
// --- dictionary (local move preview) --- // --- dictionary (local move preview) ---
/** Fetch the raw serialized dictionary blob for a (variant, version) pair. Session-gated; /** Fetch the raw serialized dictionary blob for a (variant, version) pair. Session-gated;
* lets the client validate and score a move locally instead of a network round trip. */ * lets the client validate and score a move locally instead of a network round trip. The
fetchDict(variant: Variant, version: string): Promise<ArrayBuffer>; * optional signal aborts a stalled download so it stops holding the channel. */
fetchDict(variant: Variant, version: string, signal?: AbortSignal): Promise<ArrayBuffer>;
// --- draft --- // --- draft ---
/** The player's server-persisted client-side composition (rack order + board tiles), so a /** The player's server-persisted client-side composition (rack order + board tiles), so a
+1 -1
View File
@@ -3,6 +3,6 @@
// of the initial app bundle — it is a progressive enhancement over the network // of the initial app bundle — it is a progressive enhancement over the network
// preview, which remains the fallback. // preview, which remains the fallback.
export { evaluateLocal } from './eval'; export { evaluateLocal } from './eval';
export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, clearDictInstances } from './loader'; export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances } from './loader';
export { clearDictCache, listCachedDicts } from './store'; export { clearDictCache, listCachedDicts } from './store';
export { LOCAL_EVAL_ENABLED } from './config'; export { LOCAL_EVAL_ENABLED } from './config';
+23 -18
View File
@@ -16,16 +16,21 @@ const instances = new Map<string, Dawg>();
// In-flight loads by key, so overlapping callers (prefetch + a game open) share one. // In-flight loads by key, so overlapping callers (prefetch + a game open) share one.
const inflight = new Map<string, Promise<Dawg | null>>(); const inflight = new Map<string, Promise<Dawg | null>>();
// Bad-connection breaker: after a few dictionary downloads fail in a session, stop // Bad-connection breaker: after a few dictionaries fail to become available in a session
// attempting them so the player is not stuck watching the warm-up overlay when // (a load that failed or a warm-up that hit its cap), stop attempting them so the player
// switching games. It is session-scoped (in memory) — an app relaunch is a natural // is not stuck re-watching the warm-up overlay when switching games. Session-scoped (in
// retry, when the connection may have recovered. Cached dictionaries still load. // memory) — an app relaunch is a natural retry, when the connection may have recovered.
const FAILURE_LIMIT = 3; // Cached dictionaries still load.
let failures = 0; const MISS_LIMIT = 3;
let misses = 0;
let dictDisabled = false; let dictDisabled = false;
function noteFailure(): void { /**
if (!dictDisabled && ++failures >= FAILURE_LIMIT) dictDisabled = true; * noteDictMiss records that a dictionary did not become available in time — a load that
* failed or a warm-up that hit its cap. After MISS_LIMIT in a session the breaker trips.
*/
export function noteDictMiss(): void {
if (!dictDisabled && ++misses >= MISS_LIMIT) dictDisabled = true;
} }
/** dictLoadingDisabled reports whether the bad-connection breaker has tripped for this /** dictLoadingDisabled reports whether the bad-connection breaker has tripped for this
@@ -40,18 +45,18 @@ export function dictLoadingDisabled(): boolean {
* caller then previews over the network instead. Successful loads are cached in * caller then previews over the network instead. Successful loads are cached in
* memory and, when freshly fetched, in IndexedDB. * memory and, when freshly fetched, in IndexedDB.
*/ */
export function getDawg(variant: Variant, version: string): Promise<Dawg | null> { export function getDawg(variant: Variant, version: string, signal?: AbortSignal): Promise<Dawg | null> {
const key = dictKey(variant, version); const key = dictKey(variant, version);
const have = instances.get(key); const have = instances.get(key);
if (have) return Promise.resolve(have); if (have) return Promise.resolve(have);
const pending = inflight.get(key); const pending = inflight.get(key);
if (pending) return pending; if (pending) return pending;
const p = load(variant, version, key).finally(() => inflight.delete(key)); const p = load(variant, version, key, signal).finally(() => inflight.delete(key));
inflight.set(key, p); inflight.set(key, p);
return p; return p;
} }
async function load(variant: Variant, version: string, key: string): Promise<Dawg | null> { async function load(variant: Variant, version: string, key: string, signal?: AbortSignal): Promise<Dawg | null> {
// Tier 1: the persistent cache. // Tier 1: the persistent cache.
try { try {
const cached = await idbGetDawg(key); const cached = await idbGetDawg(key);
@@ -66,22 +71,22 @@ async function load(variant: Variant, version: string, key: string): Promise<Daw
void idbDelDawg(key); void idbDelDawg(key);
} }
// Tier 2: the session-gated download — gated by the bad-connection breaker. // Tier 2: the session-gated download — gated by the bad-connection breaker. A caller's
// signal aborts it (the warm-up giving up at its cap, or the game being left) so a large
// download does not keep starving the channel on a slow link; the caller counts the miss.
if (dictDisabled) return null; if (dictDisabled) return null;
let buf: ArrayBuffer; let buf: ArrayBuffer;
try { try {
buf = await gateway.fetchDict(variant, version); buf = await gateway.fetchDict(variant, version, signal);
} catch { } catch {
noteFailure(); return null; // network error or aborted
return null;
} }
let reader: Dawg; let reader: Dawg;
try { try {
reader = new Dawg(new Uint8Array(buf)); reader = new Dawg(new Uint8Array(buf));
} catch { } catch {
noteFailure(); return null; // not a dawg — do not cache it
return null; // a corrupt blob — do not cache it
} }
instances.set(key, reader); instances.set(key, reader);
void idbPutDawg(key, buf); void idbPutDawg(key, buf);
@@ -114,6 +119,6 @@ export function peekDawg(variant: Variant, version: string): Dawg | null {
*/ */
export function clearDictInstances(): void { export function clearDictInstances(): void {
instances.clear(); instances.clear();
failures = 0; misses = 0;
dictDisabled = false; dictDisabled = false;
} }
+1 -1
View File
@@ -161,7 +161,7 @@ export class MockGateway implements GatewayClient {
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam. // The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
return { blocked: false, permanent: false, until: '', reason: '' }; return { blocked: false, permanent: false, until: '', reason: '' };
} }
async fetchDict(_variant: Variant, _version: string): Promise<ArrayBuffer> { async fetchDict(_variant: Variant, _version: string, _signal?: AbortSignal): Promise<ArrayBuffer> {
// No local dictionary in mock mode; the caller falls back to the mock evaluate. // No local dictionary in mock mode; the caller falls back to the mock evaluate.
throw new Error('fetchDict unsupported in mock'); throw new Error('fetchDict unsupported in mock');
} }
+4 -2
View File
@@ -62,14 +62,16 @@ export function createTransport(baseUrl: string): GatewayClient {
token = t; token = t;
}, },
async fetchDict(variant, version) { async fetchDict(variant, version, signal) {
if (!token) throw new Error('fetchDict: no session'); if (!token) throw new Error('fetchDict: no session');
// Low priority so the browser schedules the (large) dictionary behind the game's own // Low priority so the browser schedules the (large) dictionary behind the game's own
// requests — the move eval, state and live events — on a slow link (EDGE). Ignored // requests — the move eval, state and live events — on a slow link (EDGE). Ignored
// where the Priority Hints API is unsupported (iOS WebKit), which is harmless. // where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The
// signal lets the caller abort a stalled download so it stops holding the channel.
const res = await fetch(`${origin}/dict/${encodeURIComponent(variant)}/${encodeURIComponent(version)}`, { const res = await fetch(`${origin}/dict/${encodeURIComponent(variant)}/${encodeURIComponent(version)}`, {
headers: { authorization: `Bearer ${token}` }, headers: { authorization: `Bearer ${token}` },
priority: 'low', priority: 'low',
signal,
} as RequestInit); } as RequestInit);
if (!res.ok) throw new Error(`fetchDict ${variant}/${version}: HTTP ${res.status}`); if (!res.ok) throw new Error(`fetchDict ${variant}/${version}: HTTP ${res.status}`);
return res.arrayBuffer(); return res.arrayBuffer();