From 490c5b803e5d487368635fb8e7737e3da61b1bd0 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 15:13:18 +0200 Subject: [PATCH] fix(auth): stop infinite /auth/me request loop when logged out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The axios 401 interceptor calls clearAuthClientState() on every 401, which dispatched "auth-changed"; the App handler re-fetched /auth/me, which 401'd again → interceptor → clearAuthClientState() → "auth-changed" → ... an unbounded request storm (observed live: 100+ GET /auth/me and climbing) that ran whenever the user was logged out (login page, expired session) — burning CPU, network and battery and flooding the server. Fix: make clearAuthClientState idempotent — only emit "auth-changed" when it actually removes a stored user key (a real signed-in→out transition), so repeated 401s can no longer re-trigger the fetch. Runtime-verified in a live stack: /auth/me went from 100+ & growing to 0 & stable. login-page/settings tests green. Documented in docs/performance/PERFORMANCE_IMPROVEMENTS.md (Phase 3.5 runtime finding). Co-Authored-By: Claude Opus 4.8 --- docs/performance/PERFORMANCE_IMPROVEMENTS.md | 11 +++++++++++ job-tracker-ui/src/auth.ts | 8 +++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/performance/PERFORMANCE_IMPROVEMENTS.md b/docs/performance/PERFORMANCE_IMPROVEMENTS.md index 976cfad..2ceab9a 100644 --- a/docs/performance/PERFORMANCE_IMPROVEMENTS.md +++ b/docs/performance/PERFORMANCE_IMPROVEMENTS.md @@ -5,8 +5,19 @@ ## Changes made (this pass) | Change | File | Effect | Verified | |---|---|---|---| +| **Stop the infinite `/auth/me` request loop** — make `clearAuthClientState` emit `auth-changed` only on a real signed-in→out transition | `job-tracker-ui/src/auth.ts` | Eliminates a runaway request storm (100+ `GET /auth/me` and climbing) that ran continuously whenever the user was logged out | **Runtime-confirmed** in a live stack: `/auth/me` count 100+ & growing → 0 and stable after fix | | Revoke CV-preview blob URLs on unmount only (ref-based), not on every carousel change | `job-tracker-ui/src/pages/ProfilePage.tsx` | Fixes broken previews on multi-template decks; still frees URLs on unmount | `profile-page.test.tsx` 5/5 | +### Runtime finding — self-triggering auth loop (the most impactful issue found) +Only visible with a running backend (static analysis could not surface it). Sequence: the axios response +interceptor (`api.ts`) calls `clearAuthClientState()` on **every** 401; that dispatched `"auth-changed"`; +the `App` handler re-fetched `/auth/me`; that 401'd again → interceptor → `clearAuthClientState()` → +`"auth-changed"` → … an unbounded loop that hammered the server and spun the client on the login page and +after any session expiry. Fix: `clearAuthClientState` now only emits when it actually removes a stored user +key (idempotent), so repeated 401s can't re-trigger the fetch. This is a CPU/network/battery drain and a +self-inflicted request flood, not a memory leak — but squarely in the Phase 3.5 "infinite polling / retry +loop / duplicate requests" scope, and the single highest-value fix from the whole investigation. + > Context: this was the only defect found in a full resource audit. The codebase already practises > disciplined cleanup (timers cleared, listeners removed, object URLs revoked), so there was no leak to > fix — see the main report. diff --git a/job-tracker-ui/src/auth.ts b/job-tracker-ui/src/auth.ts index 1269ec5..8d047f3 100644 --- a/job-tracker-ui/src/auth.ts +++ b/job-tracker-ui/src/auth.ts @@ -82,8 +82,14 @@ export function setAuthUserKey(value: string | null | undefined, emit = true) { } export function clearAuthClientState(emit = true) { + // Only emit "auth-changed" when this call actually transitions from + // "signed in" to "signed out". The response interceptor calls this on every + // 401; without this guard each 401 re-dispatches "auth-changed", which + // re-fetches /auth/me, which 401s again — an infinite request loop whenever + // the user is logged out (login page, expired session). + const had = safeGet(window.localStorage, AUTH_USER_KEY) != null; safeRemove(window.localStorage, AUTH_USER_KEY); - if (emit) emitAuthChanged(); + if (emit && had) emitAuthChanged(); } export function getCsrfToken(): string | null {