Source: R8 fresh-eyes QA round, adversarial cross-tab scope (2026-05-17)
Severity: LOW (slow leak, only bites after months of continuous use)
Status: Deferred from R8 — needs cleanup pass design
Symptom
AlertEngineState.fired: Set<string> in app/src/lib/alertEngine.ts records
every (kind, position_id) pair that has ever fired. When a position closes or
archives, its entries (TargetHit:abc-123, StopHit:abc-123, etc.) stay in
the Set forever.
Slow-growing memory leak in the Zustand store. With 1000+ positions tracked
over months, the Set accumulates ~13 entries per position = ~13k+ strings.
Roughly 1-2 MB of heap that survives every session.
Reproduction
Not directly reproducible in a single session. The leak is observable as a
monotonically-growing state.fired.size across the position lifecycle.
# Inspect over time in browser devtools:
useFlowStore.getState().alertEngineState.fired.size
Current code
// alertEngine.ts: many fire sites that .add() to state.fired
state.fired.add(`TargetHit:${p.id}`);
state.fired.add(`StopHit:${p.id}`);
// ...etc for ~13 kinds × N positions
$ grep -n "fired\.\(delete\|clear\)" app/src/lib/alertEngine.ts \
app/src/store/flowStore.ts
# (no matches — no cleanup site exists)
Fix options
(A) Prune on position archive. When a position transitions to archived
status, delete all its keys from state.fired. Cheap (O(kinds)) per archive
event. Doesn't break the dedup semantic — archived positions can't fire
alerts anyway.
// In flowStore.ts where archiveStale runs:
for (const archivedPos of newlyArchived) {
for (const kind of ALL_ALERT_KINDS) {
state.fired.delete(`${kind}:${archivedPos.id}`);
}
// Also wall-specific keys with :wall_side suffix
state.fired.delete(`WallBroken:${archivedPos.id}:call_wall`);
state.fired.delete(`WallBroken:${archivedPos.id}:put_wall`);
state.fired.delete(`ApproachingWall:${archivedPos.id}:call_wall`);
state.fired.delete(`ApproachingWall:${archivedPos.id}:put_wall`);
}
(B) Periodic GC pass. Every Nth poll, iterate state.fired and drop any
key whose position_id isn't in the active+grace set. Higher overhead per pass
but doesn't need to know the full kind taxonomy.
(C) Bounded LRU. Replace Set with an LRU-bounded structure (max e.g.
10k entries). Simplest but risks re-firing alerts for very old positions if
they somehow reactivate (shouldn't happen, but defensive).
Recommendation
(A). The kind taxonomy is small and stable; pruning at archive is the
clearest semantic match (an archived position will never fire again). Pair
with a one-time migration that cleans up existing accumulated state on first
load post-deploy.
Cross-refs
- R8 scorecard:
H-X5
- File site:
app/src/lib/alertEngine.ts (search state.fired.add)
- Archive site:
app/src/store/flowStore.ts:704-705 (archiveStale)
- Test gap: no test currently asserts that
fired.size is bounded by archive
Source: R8 fresh-eyes QA round, adversarial cross-tab scope (2026-05-17)
Severity: LOW (slow leak, only bites after months of continuous use)
Status: Deferred from R8 — needs cleanup pass design
Symptom
AlertEngineState.fired: Set<string>inapp/src/lib/alertEngine.tsrecordsevery (kind, position_id) pair that has ever fired. When a position closes or
archives, its entries (
TargetHit:abc-123,StopHit:abc-123, etc.) stay inthe Set forever.
Slow-growing memory leak in the Zustand store. With 1000+ positions tracked
over months, the Set accumulates ~13 entries per position = ~13k+ strings.
Roughly 1-2 MB of heap that survives every session.
Reproduction
Not directly reproducible in a single session. The leak is observable as a
monotonically-growing
state.fired.sizeacross the position lifecycle.Current code
Fix options
(A) Prune on position archive. When a position transitions to
archivedstatus, delete all its keys from
state.fired. Cheap (O(kinds)) per archiveevent. Doesn't break the dedup semantic — archived positions can't fire
alerts anyway.
(B) Periodic GC pass. Every Nth poll, iterate
state.firedand drop anykey whose position_id isn't in the active+grace set. Higher overhead per pass
but doesn't need to know the full kind taxonomy.
(C) Bounded LRU. Replace Set with an LRU-bounded structure (max e.g.
10k entries). Simplest but risks re-firing alerts for very old positions if
they somehow reactivate (shouldn't happen, but defensive).
Recommendation
(A). The kind taxonomy is small and stable; pruning at archive is the
clearest semantic match (an archived position will never fire again). Pair
with a one-time migration that cleans up existing accumulated state on first
load post-deploy.
Cross-refs
H-X5app/src/lib/alertEngine.ts(searchstate.fired.add)app/src/store/flowStore.ts:704-705(archiveStale)fired.sizeis bounded by archive