39266c0935
Evidence-based investigation across every leak vector (timers, listeners, object
URLs, observers, websockets, static server collections, IMemoryCache, Python
caches). Verdict: no confirmed memory leak — the codebase has disciplined
cleanup. One resource-release correctness bug (over-eager blob-URL revocation in
the CV carousel) was found and fixed (eed9b1f).
Adds docs/performance/: MEMORY_LEAK_REPORT.md, ROOT_CAUSE_ANALYSIS.md,
PERFORMANCE_IMPROVEMENTS.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
6.5 KiB
Markdown
84 lines
6.5 KiB
Markdown
# Memory Leak Report — Job Tracker
|
||
|
||
**Date:** 2026-07-05
|
||
**Investigator role:** Senior Performance Engineer (memory/browser internals/full-stack)
|
||
**Verdict:** **No confirmed memory leak.** One *resource-release correctness* bug (over-eager blob-URL
|
||
revocation) was found and fixed; it is the opposite of a leak. See [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md)
|
||
and [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md).
|
||
|
||
> Method & honesty note. The app is a data-driven SPA that renders only after the backend answers
|
||
> `/auth/config` + `/auth/me`; headless (no backend/DB) it sits on a "Loading…" screen, so live
|
||
> DevTools heap-snapshot/allocation-timeline profiling of populated screens was **not** performed in this
|
||
> environment. Evidence here is therefore **static code analysis of every known leak vector** plus the
|
||
> existing automated test suite. Where a runtime confirmation is still advisable, it is called out
|
||
> explicitly. Per the mission's Final Rule, nothing below is reported as a leak unless the code path
|
||
> actually retains memory — and none did.
|
||
|
||
---
|
||
|
||
## Phase 1–2 — Does a leak exist? Can it be reproduced?
|
||
|
||
No leak was reproduced or evidenced. The classic React/browser leak vectors were each checked in code and
|
||
found to have correct teardown. "Memory grows while using the app" (the usual trigger for this kind of
|
||
investigation) is explained by **expected behaviour** — MUI/emulator caches, route-level component state,
|
||
and delayed GC — not by retained graphs. There is no growing global collection, no unremoved listener, no
|
||
uncleared timer, and no real-time connection to leak.
|
||
|
||
## Phase 3 / 3.5 — Vector-by-vector evidence
|
||
|
||
| Vector | Finding | Evidence | Verdict |
|
||
|---|---|---|---|
|
||
| **Timers / intervals** | Both `setInterval`s clear on cleanup | `App.tsx:154-155` (reminders, 60s → `clearInterval`); `ProfilePage.tsx:319-323` (extraction poll, 4s → `clearInterval`) | ✅ no leak |
|
||
| **`setTimeout`** | Used only for one-shot object-URL revokes | `BackupCard.tsx:29`, `Attachments.tsx:193`, `ImportExportJobs.tsx:21` | ✅ no leak |
|
||
| **Event listeners** | Every `addEventListener` has a matching `removeEventListener` in the effect cleanup | `App.tsx:174-175` (auth-changed), `App.tsx:185-186` (keydown), `CropImageDialog.tsx:114-124` (mouse/touch drag ×4) | ✅ no leak |
|
||
| **Object URLs (media)** | Created URLs are revoked on cleanup/timeout | `CropImageDialog.tsx:59/65`, `Attachments.tsx:111/181/193/201`, `BackupCard.tsx:18/29`, `ImportExportJobs.tsx:16/21`, `JobDetailsDialog.tsx:507/514`, `ProfilePage.tsx` (see fix) | ✅ no leak (1 over-revoke bug fixed) |
|
||
| **Observers** | None used | grep: no `ResizeObserver` / `IntersectionObserver` / `MutationObserver` in `src/` | ✅ n/a |
|
||
| **WebSocket / SSE / SignalR** | None used | grep: no `new WebSocket` / `EventSource` / SignalR client anywhere | ✅ n/a |
|
||
| **Signal/event subscriptions** | Only the `window` `"auth-changed"` custom event; unsubscribed on cleanup | `App.tsx:157-176` | ✅ no leak |
|
||
| **Global/module state (client)** | No module-level mutable collection that grows unbounded | grep for module-scope `Map`/array caches — none accumulating | ✅ no leak |
|
||
| **Client caches (localStorage)** | Bounded keys (prefs, columns, saved views); no per-event append | `App.tsx`, `SettingsView.tsx`, `SavedViewsMenu.tsx`, `themePrefs.ts` | ✅ no leak |
|
||
| **React effects w/o cleanup** | All effects reviewed return cleanup where they acquire resources | see rows above | ✅ no leak |
|
||
| **Server static collections** | All `static` collections are **fixed lookup tables** or **method return types**, never growing fields | `AttachmentsController`, `AuthController`, `ProfileCvController`, `HumanLanguageCatalog`, `StructuredCvProfileJson` | ✅ no leak |
|
||
| **Server `IMemoryCache`** | Bounded: OAuth state entries expire in 15 min and are removed on consume | `GmailOAuthService.cs:72` (`TimeSpan.FromMinutes(15)`), `:133-138` (`TryGetValue`+`Remove`) | ✅ no leak |
|
||
| **AI service (Python) caches** | `cachetools.TTLCache` (bounded by TTL + maxsize) | `tools/summarizer/app.py:4` | ✅ no leak |
|
||
| **Server timers / background** | Hosted services use scoped DI + `PeriodicTimer`/delays; no accumulating handlers | `FollowUpReminderHostedService`, `RulesHostedService`, `JobEnrichmentHostedService`, etc. | ✅ no leak |
|
||
|
||
## Phase 3.5 — Repeated/duplicate work audit
|
||
|
||
- **Reminders poll** (`App.tsx:151`, every 60s): correct URL `/jobapplications/reminders`, cheap, cleaned
|
||
up. (An earlier read rendered the path with backslashes — a display artifact; the source uses forward
|
||
slashes. **No bug.**)
|
||
- **Extraction-run poll** (`ProfilePage.tsx:315-324`, every 4s): effect deps `[extractionRuns, loadProfile]`
|
||
and `extractionRuns` changes each poll, so the interval is torn down + recreated every 4s while a run is
|
||
active. **Not a leak** (cleanup runs); benign churn that self-terminates when runs finish. Minor — see
|
||
[PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md).
|
||
- No duplicate subscriptions, no retry storms, no infinite render loops observed.
|
||
|
||
## Phase 4 — Root cause
|
||
No leak → no leak root cause. The single defect found is an *over-release* (revoking blob URLs still in
|
||
use), root-caused in [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md).
|
||
|
||
## Phase 5 — Fix
|
||
`fix(profile): revoke CV-preview blob URLs on unmount, not on every change` (commit `eed9b1f`). Smallest
|
||
change: track the carousel in a ref and revoke only on unmount.
|
||
|
||
## Phase 6 — Verification
|
||
`profile-page.test.tsx` passes **5/5** with an adequate test timeout after the fix. The broader suite's
|
||
intermittent timeouts are a **pre-existing** flakiness of the heavy RTL suites (verified: they fail
|
||
identically on the clean tree; three of them don't touch `ProfilePage`).
|
||
|
||
## Phase 7 — Regression audit
|
||
Swept all object-URL, timer, and listener sites (table above). No other instance of the over-revoke
|
||
pattern, and no missing-cleanup pattern, was found.
|
||
|
||
## Remaining risks / recommendations
|
||
- Live heap-snapshot profiling on a **populated** session (real backend) is still worth doing once, to
|
||
confirm the static conclusion under real navigation — see [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md).
|
||
- Keep the disciplined cleanup pattern (this codebase is already good at it).
|
||
|
||
## Security-audit note (standing instruction)
|
||
The single code change is a client-side blob-URL revocation-timing fix: no auth/authz surface, no new user
|
||
input, no data exposure, no injection vector, no secret handling. Nothing for the security lens to flag.
|
||
Existing protections (HttpOnly-cookie + CSRF auth, SSRF blocklist, global query-filter tenancy) are
|
||
untouched.
|