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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-22 21:52:33 +02:00
parent c0281f7e9b
commit 747bdeda46
6 changed files with 92 additions and 14 deletions

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.87.8"
version = "0.87.9"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.87.8"
version = "0.87.9"
edition = "2021"
default-run = "mangalord"

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.87.8",
"version": "0.87.9",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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,

View File

@@ -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<MangaCoveragePage> {
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<MangaCoveragePage>(
`/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`
`/v1/admin/analysis/mangas${qs ? `?${qs}` : ''}`,
init?.signal ? { signal: init.signal } : undefined
);
}

View File

@@ -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;
}
}
}