Skip to content

R8 deferred: alertEngineState.fired Set grows unbounded across position lifecycle (LOW, slow leak) #9

Description

@SaigonAlpha

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions