Skip to content

fix(crash-reporting): bound coalescedBreadcrumbs with TTL prune + LRU cap - #5818

Merged
nwparker merged 1 commit into
mainfrom
nwparker/leak-crash-breadcrumb-cap
Jun 19, 2026
Merged

nwparker merged 1 commit into
mainfrom
nwparker/leak-crash-breadcrumb-cap

Conversation

@nwparker

@nwparker nwparker commented Jun 19, 2026 •

Copy link
Copy Markdown
Contributor

Leak

recordCoalescedCrashBreadcrumb does coalescedBreadcrumbs.set(coalesceKey, …) with no TTL prune and no size cap. Production keys are agent:${agentType}:${state}, and agentType reaches this store (via the relay/SSH ingestRemote and OSC observed-status paths through normalizeAgentStatusPayload) as an open string — only length-trimmed to 40 chars, never enum-checked — so the key space is unbounded over a long multi-agent/SSH session. The only shrink path was the test-only clearCrashBreadcrumbsForTest. (The sibling breadcrumbs array is already bounded to MAX_BREADCRUMBS = 30 — the unbounded map was the oversight.)

Fix

In recordCoalescedCrashBreadcrumb, after the suppression check: prune entries past their suppression window (now - recordedAt >= minIntervalMs — provably inert, can no longer coalesce anything), then enforce an LRU cap of MAX_COALESCE_KEYS = 128 (delete-then-set the live key so insertion order tracks recency; evict the oldest while over the cap). This mirrors the prune() / maxKeys idiom already used by ProcessGoneDedupe in the same directory.

Evidence of the leak (regression test FAILS before the fix)

crash-breadcrumb-store-leak.test.ts against the unfixed code (fake timers, no advance, so nothing coalesces — pure cap pressure):

× caps the map even with an unbounded stream of distinct agentType keys
   AssertionError: expected 1000 to be less than or equal to 128
× keeps the most-recently-recorded key after capping
   AssertionError: expected 178 to be less than or equal to 128
 Tests  2 failed | 1 passed (3)

1000 distinct agentType keys → 1000 map entries.

Evidence the leak is resolved (test PASSES after the fix)

 Test Files  1 passed (1)
      Tests  3 passed (3)

The map stays ≤ 128 under unbounded distinct keys, and still coalesces repeated breadcrumbs for the same key within the interval.

No functional regression

src/main/crash-reporting/crash-breadcrumb-store.test.ts
 Tests  4 passed (4)

The TTL prune only deletes entries past minIntervalMs (provably inert), so a still-live entry is never wrongly evicted — the existing "coalesces repeated breadcrumbs inside the interval" test guards within-window suppression. The cap only triggers above 128 live keys, far above any single agent's working set.

🤖 Generated with Claude Code

Made with Orca 🐋


Open in Stage

… cap

recordCoalescedCrashBreadcrumb set entries keyed by agent:${agentType}:${state}
with no prune and no cap. agentType is an open string (length-trimmed only,
never enum-checked) over the relay/SSH/OSC ingest paths, so the map grew
unbounded over a long multi-agent session — the sibling breadcrumbs array
is capped at 30, but this map was not.

Prune entries past their suppression window and LRU-cap to 128, mirroring
ProcessGoneDedupe's prune/maxKeys idiom.

Regression test fails before the fix (1000 keys) and passes after.

Co-authored-by: Orca <help@stably.ai>
@ghost

ghost commented Jun 19, 2026

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 3 individual chapters for you:

Title
1 Define maximum capacity for coalesced breadcrumbs
2 Implement TTL pruning and LRU capping
3 Add test utilities and regression tests
Open in Stage

Chapters generated by Stage for commit ad4d608 on Jun 19, 2026 6:46pm UTC.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

crash-breadcrumb-store.ts introduces a MAX_COALESCE_KEYS constant (128) and updates recordCoalescedCrashBreadcrumb to enforce a bounded coalescedBreadcrumbs map. On each call, expired entries are removed first, the current coalesceKey is deleted and re-inserted to mark it most-recent, and oldest keys are evicted until the map size is within the cap. A new exported function getCoalescedKeyCountForTest returns the current map size. A companion regression test file verifies the cap holds under 1000 distinct keys, duplicate keys within minIntervalMs, and overflow-then-re-record scenarios using fake timers and per-test state clearing.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: bounding coalescedBreadcrumbs with TTL pruning and LRU capacity limits, which directly matches the core fix in the changeset.
Description check ✅ Passed The description is comprehensive, explaining the leak mechanism, the fix implementation, regression test evidence, and verification of no functional regression. However, it lacks the Screenshots and AI Review Report/Security Audit sections from the required template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5d4a9f51-2c24-4caa-81ef-3e309db08df1

📥 Commits

Reviewing files that changed from the base of the PR and between ae77156 and ad4d608.

📒 Files selected for processing (2)
  • src/main/crash-reporting/crash-breadcrumb-store-leak.test.ts
  • src/main/crash-reporting/crash-breadcrumb-store.ts

Comment on lines +52 to +57
// Drop entries past their suppression window (they can no longer coalesce
// anything) and LRU-cap the rest. delete-then-set keeps insertion order =
// recency so only genuinely idle keys are evicted.
for (const [key, entry] of coalescedBreadcrumbs) {
if (now - entry.recordedAt >= minIntervalMs) {
coalescedBreadcrumbs.delete(key)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Call sites for recordCoalescedCrashBreadcrumb:"
rg -n -C3 --type ts --type tsx '\brecordCoalescedCrashBreadcrumb\s*\('

echo
echo "minIntervalMs values passed at call sites:"
rg -n --type ts --type tsx 'minIntervalMs\s*:'

Repository: stablyai/orca

Length of output: 150


Add per-entry interval tracking or document single-interval constraint.

Line 56 applies the current call's minIntervalMs to prune all entries. Since entries don't store per-interval metadata, callers with different minIntervalMs values would cause incorrect expiry. Currently all callers use 30_000 (including the only production caller at src/main/index.ts:421 and all tests), so there's no active bug. However, the function signature accepts variable intervals per call, making this a design fragility. Either store minIntervalMs per entry or document that all callers must use the same interval.

@nwparker
nwparker merged commit 7763380 into main Jun 19, 2026
2 checks passed
@nwparker
nwparker deleted the nwparker/leak-crash-breadcrumb-cap branch June 19, 2026 19:34
brennanb2025 added a commit that referenced this pull request Aug 15, 2026
Two ways one burst's suppressed repeats were misattributed in forensics,
found across two adversarial review loops on the sibling-correlation work
but pre-dating it (the coalesce machinery shipped in #8800/#5818/#10729):

- Double-claim: a crash report filed mid-window snapshots the ring, folding
  the suppressed repeats into the emitted crumb — but the next emit still
  re-claimed those repeats in its suppressedSinceLast, reporting one burst
  twice across two crumbs.
- Mirror erasure: a fold resolving onto a re-emitted crumb overwrote the
  count that crumb was born carrying, deleting the previous window's repeats.

Track what each crumb has claimed (carried at emit, resolved by folds) so
every repeat is attributed exactly once. And never resolve into an evicted
crumb: when a storm pushes the burst crumb out of the 30-entry ring — or
past the retained-slot snapshot budget — mid-window, folding there would
mark the repeats claimed by evidence no snapshot can see and the burst
would vanish from the record entirely; drop the handle so the next emit
claims them instead.

Semantic note for trace-mirroring consumers: the returned
suppressedSinceLast is now net of already-folded repeats, so exactly-once
holds over the union of trace spans and report snapshots rather than
within the trace stream alone.
brennanb2025 added a commit that referenced this pull request Aug 15, 2026
Two ways one burst's suppressed repeats were misattributed in forensics,
found across two adversarial review loops on the sibling-correlation work
but pre-dating it (the coalesce machinery shipped in #8800/#5818/#10729):

- Double-claim: a crash report filed mid-window snapshots the ring, folding
  the suppressed repeats into the emitted crumb — but the next emit still
  re-claimed those repeats in its suppressedSinceLast, reporting one burst
  twice across two crumbs.
- Mirror erasure: a fold resolving onto a re-emitted crumb overwrote the
  count that crumb was born carrying, deleting the previous window's repeats.

Track what each crumb has claimed (carried at emit, resolved by folds) so
every repeat is attributed exactly once. And never resolve into an evicted
crumb: when a storm pushes the burst crumb out of the 30-entry ring — or
past the retained-slot snapshot budget — mid-window, folding there would
mark the repeats claimed by evidence no snapshot can see and the burst
would vanish from the record entirely; drop the handle so the next emit
claims them instead.

Semantic note for trace-mirroring consumers: the returned
suppressedSinceLast is now net of already-folded repeats, so exactly-once
holds over the union of trace spans and report snapshots rather than
within the trace stream alone.
brennanb2025 added a commit that referenced this pull request Aug 15, 2026
Two ways one burst's suppressed repeats were misattributed in forensics,
found across two adversarial review loops on the sibling-correlation work
but pre-dating it (the coalesce machinery shipped in #8800/#5818/#10729):

- Double-claim: a crash report filed mid-window snapshots the ring, folding
  the suppressed repeats into the emitted crumb — but the next emit still
  re-claimed those repeats in its suppressedSinceLast, reporting one burst
  twice across two crumbs.
- Mirror erasure: a fold resolving onto a re-emitted crumb overwrote the
  count that crumb was born carrying, deleting the previous window's repeats.

Track what each crumb has claimed (carried at emit, resolved by folds) so
every repeat is attributed exactly once. And never resolve into an evicted
crumb: when a storm pushes the burst crumb out of the 30-entry ring — or
past the retained-slot snapshot budget — mid-window, folding there would
mark the repeats claimed by evidence no snapshot can see and the burst
would vanish from the record entirely; drop the handle so the next emit
claims them instead.

Semantic note for trace-mirroring consumers: the returned
suppressedSinceLast is now net of already-folded repeats, so exactly-once
holds over the union of trace spans and report snapshots rather than
within the trace stream alone.
brennanb2025 added a commit that referenced this pull request Aug 15, 2026
Two ways one burst's suppressed repeats were misattributed in forensics,
found across two adversarial review loops on the sibling-correlation work
but pre-dating it (the coalesce machinery shipped in #8800/#5818/#10729):

- Double-claim: a crash report filed mid-window snapshots the ring, folding
  the suppressed repeats into the emitted crumb — but the next emit still
  re-claimed those repeats in its suppressedSinceLast, reporting one burst
  twice across two crumbs.
- Mirror erasure: a fold resolving onto a re-emitted crumb overwrote the
  count that crumb was born carrying, deleting the previous window's repeats.

Track what each crumb has claimed (carried at emit, resolved by folds) so
every repeat is attributed exactly once. And never resolve into an evicted
crumb: when a storm pushes the burst crumb out of the 30-entry ring — or
past the retained-slot snapshot budget — mid-window, folding there would
mark the repeats claimed by evidence no snapshot can see and the burst
would vanish from the record entirely; drop the handle so the next emit
claims them instead.

Semantic note for trace-mirroring consumers: the returned
suppressedSinceLast is now net of already-folded repeats, so exactly-once
holds over the union of trace spans and report snapshots rather than
within the trace stream alone.
brennanb2025 added a commit that referenced this pull request Aug 16, 2026
Two ways one burst's suppressed repeats were misattributed in forensics,
found across two adversarial review loops on the sibling-correlation work
but pre-dating it (the coalesce machinery shipped in #8800/#5818/#10729):

- Double-claim: a crash report filed mid-window snapshots the ring, folding
  the suppressed repeats into the emitted crumb — but the next emit still
  re-claimed those repeats in its suppressedSinceLast, reporting one burst
  twice across two crumbs.
- Mirror erasure: a fold resolving onto a re-emitted crumb overwrote the
  count that crumb was born carrying, deleting the previous window's repeats.

Track what each crumb has claimed (carried at emit, resolved by folds) so
every repeat is attributed exactly once. And never resolve into an evicted
crumb: when a storm pushes the burst crumb out of the 30-entry ring — or
past the retained-slot snapshot budget — mid-window, folding there would
mark the repeats claimed by evidence no snapshot can see and the burst
would vanish from the record entirely; drop the handle so the next emit
claims them instead.

Semantic note for trace-mirroring consumers: the returned
suppressedSinceLast is now net of already-folded repeats, so exactly-once
holds over the union of trace spans and report snapshots rather than
within the trace stream alone.
brennanb2025 added a commit that referenced this pull request Aug 16, 2026
* fix(crash-reporting): attribute coalesced repeats exactly once

Two ways one burst's suppressed repeats were misattributed in forensics,
found across two adversarial review loops on the sibling-correlation work
but pre-dating it (the coalesce machinery shipped in #8800/#5818/#10729):

- Double-claim: a crash report filed mid-window snapshots the ring, folding
  the suppressed repeats into the emitted crumb — but the next emit still
  re-claimed those repeats in its suppressedSinceLast, reporting one burst
  twice across two crumbs.
- Mirror erasure: a fold resolving onto a re-emitted crumb overwrote the
  count that crumb was born carrying, deleting the previous window's repeats.

Track what each crumb has claimed (carried at emit, resolved by folds) so
every repeat is attributed exactly once. And never resolve into an evicted
crumb: when a storm pushes the burst crumb out of the 30-entry ring — or
past the retained-slot snapshot budget — mid-window, folding there would
mark the repeats claimed by evidence no snapshot can see and the burst
would vanish from the record entirely; drop the handle so the next emit
claims them instead.

Semantic note for trace-mirroring consumers: the returned
suppressedSinceLast is now net of already-folded repeats, so exactly-once
holds over the union of trace spans and report snapshots rather than
within the trace stream alone.

* fix(crash-reporting): preserve orphaned repeat debt on cleanup

* fix(crash-reporting): preserve data-less repeat debt

* fix(crash-reporting): make coalescing window monotonic
AmethystLiang pushed a commit that referenced this pull request Aug 17, 2026
* fix(crash-reporting): attribute coalesced repeats exactly once

Two ways one burst's suppressed repeats were misattributed in forensics,
found across two adversarial review loops on the sibling-correlation work
but pre-dating it (the coalesce machinery shipped in #8800/#5818/#10729):

- Double-claim: a crash report filed mid-window snapshots the ring, folding
  the suppressed repeats into the emitted crumb — but the next emit still
  re-claimed those repeats in its suppressedSinceLast, reporting one burst
  twice across two crumbs.
- Mirror erasure: a fold resolving onto a re-emitted crumb overwrote the
  count that crumb was born carrying, deleting the previous window's repeats.

Track what each crumb has claimed (carried at emit, resolved by folds) so
every repeat is attributed exactly once. And never resolve into an evicted
crumb: when a storm pushes the burst crumb out of the 30-entry ring — or
past the retained-slot snapshot budget — mid-window, folding there would
mark the repeats claimed by evidence no snapshot can see and the burst
would vanish from the record entirely; drop the handle so the next emit
claims them instead.

Semantic note for trace-mirroring consumers: the returned
suppressedSinceLast is now net of already-folded repeats, so exactly-once
holds over the union of trace spans and report snapshots rather than
within the trace stream alone.

* fix(crash-reporting): preserve orphaned repeat debt on cleanup

* fix(crash-reporting): preserve data-less repeat debt

* fix(crash-reporting): make coalescing window monotonic
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
… cap (stablyai#5818)

recordCoalescedCrashBreadcrumb set entries keyed by agent:${agentType}:${state}
with no prune and no cap. agentType is an open string (length-trimmed only,
never enum-checked) over the relay/SSH/OSC ingest paths, so the map grew
unbounded over a long multi-agent session — the sibling breadcrumbs array
is capped at 30, but this map was not.

Prune entries past their suppression window and LRU-cap to 128, mirroring
ProcessGoneDedupe's prune/maxKeys idiom.

Regression test fails before the fix (1000 keys) and passes after.

Co-authored-by: Orca <help@stably.ai>
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…14666)

* fix(crash-reporting): attribute coalesced repeats exactly once

Two ways one burst's suppressed repeats were misattributed in forensics,
found across two adversarial review loops on the sibling-correlation work
but pre-dating it (the coalesce machinery shipped in stablyai#8800/stablyai#5818/stablyai#10729):

- Double-claim: a crash report filed mid-window snapshots the ring, folding
  the suppressed repeats into the emitted crumb — but the next emit still
  re-claimed those repeats in its suppressedSinceLast, reporting one burst
  twice across two crumbs.
- Mirror erasure: a fold resolving onto a re-emitted crumb overwrote the
  count that crumb was born carrying, deleting the previous window's repeats.

Track what each crumb has claimed (carried at emit, resolved by folds) so
every repeat is attributed exactly once. And never resolve into an evicted
crumb: when a storm pushes the burst crumb out of the 30-entry ring — or
past the retained-slot snapshot budget — mid-window, folding there would
mark the repeats claimed by evidence no snapshot can see and the burst
would vanish from the record entirely; drop the handle so the next emit
claims them instead.

Semantic note for trace-mirroring consumers: the returned
suppressedSinceLast is now net of already-folded repeats, so exactly-once
holds over the union of trace spans and report snapshots rather than
within the trace stream alone.

* fix(crash-reporting): preserve orphaned repeat debt on cleanup

* fix(crash-reporting): preserve data-less repeat debt

* fix(crash-reporting): make coalescing window monotonic
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant