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 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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user