From 747bdeda4659ecd3ca337d2753bf859a2042c3e4 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 22 Jun 2026 21:52:33 +0200 Subject: [PATCH] fix(frontend-admin): SSE/AbortController hygiene + signal-chain through proxy (0.87.9) Three frontend findings (medium): 1. **`hooks.server.ts` didn't chain client-disconnect into the upstream fetch.** Closing an admin tab left the backend's `broadcast::Receiver` + spawned tokio task running until next backend restart. Listen for `event.request.signal.abort` and forward to the timeout controller. 2. **`admin/analysis/+page.svelte` missing visibility/pagehide/pageshow handlers.** Mobile Safari throttles SSE in background tabs; bfcache leaves a dead connection on back-nav. Apply the close/open-stream pattern from `admin/crawler/+page.svelte`. 3. **`loadCoverage` had no AbortController.** Debounced search keystrokes raced. Per-loader controller plumbed through `getAnalysisMangaCoverage`. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/package.json | 2 +- frontend/src/hooks.server.ts | 16 ++++ frontend/src/lib/api/admin.ts | 6 +- .../src/routes/admin/analysis/+page.svelte | 78 ++++++++++++++++--- 6 files changed, 92 insertions(+), 14 deletions(-) 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; + } } }