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
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:
+17
-1
@@ -57,6 +57,9 @@
|
||||
let dict = $state<typeof import('../lib/dict') | null>(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<number | null>(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<null>((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<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -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<ArrayBuffer>;
|
||||
* 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<ArrayBuffer>;
|
||||
|
||||
// --- draft ---
|
||||
/** The player's server-persisted client-side composition (rack order + board tiles), so a
|
||||
|
||||
@@ -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';
|
||||
|
||||
+23
-18
@@ -16,16 +16,21 @@ const instances = new Map<string, Dawg>();
|
||||
// In-flight loads by key, so overlapping callers (prefetch + a game open) share one.
|
||||
const inflight = new Map<string, Promise<Dawg | null>>();
|
||||
|
||||
// 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<Dawg | null> {
|
||||
export function getDawg(variant: Variant, version: string, signal?: AbortSignal): Promise<Dawg | null> {
|
||||
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<Dawg | null> {
|
||||
async function load(variant: Variant, version: string, key: string, signal?: AbortSignal): Promise<Dawg | null> {
|
||||
// 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<Daw
|
||||
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;
|
||||
let buf: ArrayBuffer;
|
||||
try {
|
||||
buf = await gateway.fetchDict(variant, version);
|
||||
buf = await gateway.fetchDict(variant, version, signal);
|
||||
} catch {
|
||||
noteFailure();
|
||||
return null;
|
||||
return null; // network error or aborted
|
||||
}
|
||||
|
||||
let reader: Dawg;
|
||||
try {
|
||||
reader = new Dawg(new Uint8Array(buf));
|
||||
} catch {
|
||||
noteFailure();
|
||||
return null; // a corrupt blob — do not cache it
|
||||
return null; // not a dawg — do not cache it
|
||||
}
|
||||
instances.set(key, reader);
|
||||
void idbPutDawg(key, buf);
|
||||
@@ -114,6 +119,6 @@ export function peekDawg(variant: Variant, version: string): Dawg | null {
|
||||
*/
|
||||
export function clearDictInstances(): void {
|
||||
instances.clear();
|
||||
failures = 0;
|
||||
misses = 0;
|
||||
dictDisabled = false;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
throw new Error('fetchDict unsupported in mock');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user