diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 7154d10..140ea17 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -57,6 +57,9 @@ let dict = $state(null); let dictWarming = $state(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 selected = $state(null); let focus = $state<{ row: number; col: number } | null>(null); @@ -594,6 +597,8 @@ clearReorder(); } onDestroy(() => { + // Leaving the game: abort a still-downloading dictionary so it stops holding the channel. + warmCtrl?.abort(); window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); @@ -673,16 +678,27 @@ const v = view; if (!d || !v) return; if (d.hasDawg(v.game.variant, v.game.dictVersion) || d.dictLoadingDisabled()) return; + const ctrl = new AbortController(); + warmCtrl = ctrl; let settled = false; // 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. const grace = setTimeout(() => { if (!settled) dictWarming = true; }, 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((r) => setTimeout(() => r(null), 5000)), + ]); settled = true; clearTimeout(grace); 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 | null = null; diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index baf05f9..dfbb3b6 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -99,8 +99,9 @@ export interface GatewayClient { // --- dictionary (local move preview) --- /** 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. */ - fetchDict(variant: Variant, version: string): Promise; + * lets the client validate and score a move locally instead of a network round trip. The + * optional signal aborts a stalled download so it stops holding the channel. */ + fetchDict(variant: Variant, version: string, signal?: AbortSignal): Promise; // --- draft --- /** The player's server-persisted client-side composition (rack order + board tiles), so a diff --git a/ui/src/lib/dict/index.ts b/ui/src/lib/dict/index.ts index c7bf724..9e23769 100644 --- a/ui/src/lib/dict/index.ts +++ b/ui/src/lib/dict/index.ts @@ -3,6 +3,6 @@ // of the initial app bundle — it is a progressive enhancement over the network // preview, which remains the fallback. 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 { LOCAL_EVAL_ENABLED } from './config'; diff --git a/ui/src/lib/dict/loader.ts b/ui/src/lib/dict/loader.ts index 4deab86..3564796 100644 --- a/ui/src/lib/dict/loader.ts +++ b/ui/src/lib/dict/loader.ts @@ -16,16 +16,21 @@ const instances = new Map(); // In-flight loads by key, so overlapping callers (prefetch + a game open) share one. const inflight = new Map>(); -// Bad-connection breaker: after a few dictionary downloads fail in a session, stop -// attempting them so the player is not stuck watching the warm-up overlay when -// switching games. It is session-scoped (in memory) — an app relaunch is a natural -// retry, when the connection may have recovered. Cached dictionaries still load. -const FAILURE_LIMIT = 3; -let failures = 0; +// Bad-connection breaker: after a few dictionaries fail to become available in a session +// (a load that failed or a warm-up that hit its cap), stop attempting them so the player +// is not stuck re-watching the warm-up overlay when switching games. Session-scoped (in +// memory) — an app relaunch is a natural retry, when the connection may have recovered. +// Cached dictionaries still load. +const MISS_LIMIT = 3; +let misses = 0; 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 @@ -40,18 +45,18 @@ export function dictLoadingDisabled(): boolean { * caller then previews over the network instead. Successful loads are cached in * memory and, when freshly fetched, in IndexedDB. */ -export function getDawg(variant: Variant, version: string): Promise { +export function getDawg(variant: Variant, version: string, signal?: AbortSignal): Promise { const key = dictKey(variant, version); const have = instances.get(key); if (have) return Promise.resolve(have); const pending = inflight.get(key); 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); return p; } -async function load(variant: Variant, version: string, key: string): Promise { +async function load(variant: Variant, version: string, key: string, signal?: AbortSignal): Promise { // Tier 1: the persistent cache. try { const cached = await idbGetDawg(key); @@ -66,22 +71,22 @@ async function load(variant: Variant, version: string, key: string): Promise { + async fetchDict(_variant: Variant, _version: string, _signal?: AbortSignal): Promise { // No local dictionary in mock mode; the caller falls back to the mock evaluate. throw new Error('fetchDict unsupported in mock'); } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 40f7bca..7e93bb7 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -62,14 +62,16 @@ export function createTransport(baseUrl: string): GatewayClient { token = t; }, - async fetchDict(variant, version) { + async fetchDict(variant, version, signal) { if (!token) throw new Error('fetchDict: no session'); // 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 - // 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)}`, { headers: { authorization: `Bearer ${token}` }, priority: 'low', + signal, } as RequestInit); if (!res.ok) throw new Error(`fetchDict ${variant}/${version}: HTTP ${res.status}`); return res.arrayBuffer();