Files
jobtrackingapp/docs/performance/MEMORY_LEAK_REPORT.md
T
cesnimda 39266c0935 docs(perf): memory-leak investigation reports (no leak found)
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>
2026-07-05 14:59:28 +02:00

84 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 12 — 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.