diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d55ff51..777d35d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.87.8" +version = "0.87.9" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ee8219f..c2af7b3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.87.8" +version = "0.87.9" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index 8b3a37d..3f9e0cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.87.8", + "version": "0.87.9", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index ed34e50..fc69cbb 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -89,6 +89,22 @@ export const handle: Handle = async ({ event, resolve }) => { ? null : setTimeout(() => ctrl.abort(), PROXY_TIMEOUT_MS); + // Chain the SvelteKit client's disconnect signal into the upstream + // fetch so closing an admin tab tears down the backend SSE + // subscription immediately. Without this, the backend's + // `broadcast::Receiver` + spawned tokio task leak per closed tab + // until backend restart — every page refresh adds another. + // `AbortSignal.any` would be cleaner but it's Node 20.3+; chain a + // listener so we work on older runtimes (vitest's bundled jsdom + // env included). If the client signal is already aborted (e.g. + // very fast reload), fire ctrl immediately. + if (event.request.signal.aborted) { + ctrl.abort(); + } else { + event.request.signal.addEventListener('abort', () => ctrl.abort(), { + once: true + }); + } const init: RequestInit & { duplex?: 'half' } = { method: event.request.method, headers, diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index f39a668..071d68d 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -743,7 +743,8 @@ export type PageAnalysisDetail = { /** Paginated per-manga analysis coverage (admin overview). */ export async function getAnalysisMangaCoverage( - opts: { search?: string; limit?: number; offset?: number } = {} + opts: { search?: string; limit?: number; offset?: number } = {}, + init?: { signal?: AbortSignal } ): Promise { const params = new URLSearchParams(); if (opts.search) params.set('search', opts.search); @@ -751,7 +752,8 @@ export async function getAnalysisMangaCoverage( if (opts.offset != null) params.set('offset', String(opts.offset)); const qs = params.toString(); return request( - `/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}` + `/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`, + init?.signal ? { signal: init.signal } : undefined ); } diff --git a/frontend/src/routes/admin/analysis/+page.svelte b/frontend/src/routes/admin/analysis/+page.svelte index 9992adc..b00ce14 100644 --- a/frontend/src/routes/admin/analysis/+page.svelte +++ b/frontend/src/routes/admin/analysis/+page.svelte @@ -71,6 +71,11 @@ let live = $state(false); let source: EventSource | null = null; let sseErrors = 0; + // Per-loader AbortController so a debounced search-keystroke burst + // (or unmount) can cancel its predecessor's response — without this, + // a slow first request would overwrite a faster second one, mirroring + // the issue the crawler page already solved at line 55. + let coverageAbort: AbortController | null = null; // Global "now analyzing" pointer, driven by the `started` SSE event so // the operator sees what the worker is on even when nothing is @@ -113,12 +118,24 @@ onMount(() => { void loadCoverage(true); openStream(); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibilityChange); + window.addEventListener('pagehide', onPageHide); + window.addEventListener('pageshow', onPageShow); + } }); onDestroy(() => { - source?.close(); - source = null; + closeStream(); + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibilityChange); + window.removeEventListener('pagehide', onPageHide); + window.removeEventListener('pageshow', onPageShow); + } if (clearTimer) clearTimeout(clearTimer); + // Cancel any in-flight coverage fetch so a late response can't + // overwrite state after the page is gone. + coverageAbort?.abort(); }); function pushTicker(text: string) { @@ -233,6 +250,33 @@ } } + function closeStream() { + source?.close(); + source = null; + live = false; + } + + // Mirrors `admin/crawler/+page.svelte`: close the SSE stream when + // the tab is hidden so the browser doesn't accumulate a stale + // connection (mobile Safari throttles SSE aggressively in background + // tabs), and reopen on visible. Plus pagehide / pageshow to handle + // the BFCache, which would otherwise leave the dashboard reading a + // dead connection on back-navigation. + function onVisibilityChange() { + if (typeof document === 'undefined') return; + if (document.visibilityState === 'hidden') { + closeStream(); + } else if (document.visibilityState === 'visible') { + openStream(); + } + } + function onPageHide() { + closeStream(); + } + function onPageShow() { + openStream(); + } + function openStream() { if (source) return; const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true }); @@ -283,19 +327,35 @@ loadingMore = true; } error = null; + // Cancel any in-flight predecessor — a debounced search burst + // would otherwise race; the slow first response could overwrite + // the faster second result. + coverageAbort?.abort(); + const ctrl = new AbortController(); + coverageAbort = ctrl; try { - const r = await getAnalysisMangaCoverage({ - search: query.trim() || undefined, - limit: LIMIT, - offset - }); + const r = await getAnalysisMangaCoverage( + { + search: query.trim() || undefined, + limit: LIMIT, + offset + }, + { signal: ctrl.signal } + ); mangas = reset ? r.items : [...mangas, ...r.items]; total = r.page.total ?? mangas.length; } catch (e) { + // Aborts are normal cancellation, not errors to surface. + if ((e as { name?: string })?.name === 'AbortError') return; error = e instanceof ApiError ? e.message : 'Failed to load coverage.'; } finally { - loading = false; - loadingMore = false; + // Only clear the in-flight markers if this call still owns + // them — a later call may have superseded us already. + if (coverageAbort === ctrl) coverageAbort = null; + if (!ctrl.signal.aborted) { + loading = false; + loadingMore = false; + } } }