Skip to content

perf(ui): reduce highlight cache memory#404

Merged
benvinegar merged 1 commit into
mainfrom
perf/reduce-highlight-cache-memory
Jun 9, 2026
Merged

perf(ui): reduce highlight cache memory#404
benvinegar merged 1 commit into
mainfrom
perf/reduce-highlight-cache-memory

Conversation

@benvinegar

Copy link
Copy Markdown
Member

Summary

  • reduce the shared highlighted-diff cache from 150 files to 40 files to lower memory pressure while navigating large changesets
  • add a navigation memory benchmark for repeated hunk navigation through synthetic review streams
  • add a daemon memory check harness for session registration/snapshot/API churn

Benchmarks

Navigation memory, forward through 220 unique files:

cache max heap last heap max RSS
40 427.9 MiB 336.2 MiB 975.4 MiB
150 540.2 MiB 515.8 MiB 1140.3 MiB

Large stream scroll benchmark, average of 3 runs:

cache 4 scroll ticks
40 591.0ms
150 582.3ms

The reduced cache keeps the nearby scroll/prefetch working set warm while avoiding retention of whole-review highlight data.

Validation

  • bun run typecheck
  • bun run lint
  • bun run benchmarks/large-stream.ts
  • bun run benchmarks/navigation-memory.ts --mode forward --navigations 220 --warmup-navigations 150 --sample-every 20 --file-count 220 --lines-per-file 80 --max-heap-slope-kb 8192 --max-heap-growth-mb 1024 --max-rss-growth-mb 2048 --json-out tmp/navigation-memory-forward-220-cache40.json
  • bun run bench:daemon-memory -- --json-out tmp/daemon-memory-default.json

This PR description was generated by Pi using GPT-5 Codex

@greptile-apps

greptile-apps Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR reduces the shared highlight cache cap from 150 to 40 entries to cut peak heap and RSS during navigation of large changesets, and adds two new harnesses to validate that memory stays bounded over time.

  • src/ui/diff/useHighlightedDiff.ts: MAX_CACHE_ENTRIES drops from 150 → 40; the eviction and promise-deduplication logic is unchanged and correct.
  • benchmarks/navigation-memory.ts: new benchmark that drives forward/bounce key navigation through a synthetic review and asserts heap growth and slope stay within configurable thresholds.
  • scripts/daemon-memory-check.ts: new harness that spins up the real daemon, runs session registration/snapshot/API cycles, and checks post-cleanup RSS growth; has two infrastructure issues detailed in comments.

Confidence Score: 3/5

The production code change is safe to merge; the daemon memory check script has a deadlock risk that should be fixed before relying on it in CI.

The core production change is safe; the daemon harness has a defect that can cause it to silently deadlock rather than produce memory measurements.

scripts/daemon-memory-check.ts

Important Files Changed

Filename Overview
src/ui/diff/useHighlightedDiff.ts Reduces MAX_CACHE_ENTRIES from 150 to 40 with an improved explanatory comment; cache eviction logic and promise deduplication are unchanged and correct.
scripts/daemon-memory-check.ts New daemon memory harness; piped stdout/stderr are never consumed (can stall the daemon event loop mid-run), and reserveLoopbackPort has a TOCTOU race between releasing the listener and daemon bind.
benchmarks/navigation-memory.ts New navigation memory benchmark; forward mode silently saturates at the last file index, making post-saturation slope readings artificially flat if navigations > fileCount.
package.json Adds two new bench: script entries for the new navigation-memory and daemon-memory-check harnesses; no issues.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[useHighlightedDiff hook] --> B{Cache hit?\nSHARED_HIGHLIGHTED_DIFF_CACHE}
    B -- Yes --> C[Return cached HighlightedDiffCode]
    B -- No --> D{In-flight promise?\nSHARED_HIGHLIGHT_PROMISES}
    D -- Yes --> E[Attach to existing promise]
    D -- No --> F[loadHighlightedDiff async]
    F --> G[commitHighlightResult]
    G --> H[Add to cache]
    H --> I[enforceCacheLimit\nmax 40 entries]
    I --> J{size > 40?}
    J -- Yes --> K[Evict oldest insertion-order entry]
    K --> J
    J -- No --> L[Done]
    E --> C
    C --> M[Render highlighted diff]
    L --> M
Loading

Comments Outside Diff (1)

  1. scripts/daemon-memory-check.ts, line 865-876 (link)

    P1 Piped stdout/stderr never drained — daemon may stall

    stdout: "pipe" and stderr: "pipe" are set but child.stdout and child.stderr are never consumed. Bun uses OS pipe(2) under the hood; the default kernel pipe buffer is 64 KB on Linux. A daemon that logs each WebSocket registration, snapshot, and API request over 50 cycles will produce well over 64 KB of output, at which point every subsequent write() inside the daemon blocks its event loop. When that happens, the daemon stops processing WebSocket close events and HTTP health requests, so the waitUntil("session cleanup") and waitUntil("session registration") calls time out after 5 s and the script exits with a confusing timeout error instead of a memory result. Use stdout: "inherit" (or "ignore") to avoid the backpressure deadlock.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: scripts/daemon-memory-check.ts
    Line: 865-876
    
    Comment:
    **Piped stdout/stderr never drained — daemon may stall**
    
    `stdout: "pipe"` and `stderr: "pipe"` are set but `child.stdout` and `child.stderr` are never consumed. Bun uses OS `pipe(2)` under the hood; the default kernel pipe buffer is 64 KB on Linux. A daemon that logs each WebSocket registration, snapshot, and API request over 50 cycles will produce well over 64 KB of output, at which point every subsequent `write()` inside the daemon blocks its event loop. When that happens, the daemon stops processing WebSocket close events and HTTP health requests, so the `waitUntil("session cleanup")` and `waitUntil("session registration")` calls time out after 5 s and the script exits with a confusing timeout error instead of a memory result. Use `stdout: "inherit"` (or `"ignore"`) to avoid the backpressure deadlock.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
scripts/daemon-memory-check.ts:865-876
**Piped stdout/stderr never drained — daemon may stall**

`stdout: "pipe"` and `stderr: "pipe"` are set but `child.stdout` and `child.stderr` are never consumed. Bun uses OS `pipe(2)` under the hood; the default kernel pipe buffer is 64 KB on Linux. A daemon that logs each WebSocket registration, snapshot, and API request over 50 cycles will produce well over 64 KB of output, at which point every subsequent `write()` inside the daemon blocks its event loop. When that happens, the daemon stops processing WebSocket close events and HTTP health requests, so the `waitUntil("session cleanup")` and `waitUntil("session registration")` calls time out after 5 s and the script exits with a confusing timeout error instead of a memory result. Use `stdout: "inherit"` (or `"ignore"`) to avoid the backpressure deadlock.

### Issue 2 of 3
scripts/daemon-memory-check.ts:505-516
**Port TOCTOU — another process can steal the reserved port**

`reserveLoopbackPort` releases the listener before the daemon process starts. The gap between `listener.close()` and the daemon's own `bind()` is a race window: another process on the same machine can acquire the same ephemeral port in between. When that happens the daemon fails to start, `waitUntil("daemon health")` times out after 10 s, and the script exits without a memory reading. On a busy CI host this race is non-trivial. A safer pattern is to pass a server socket by file descriptor (or use a Unix-domain socket path) so ownership is never released.

### Issue 3 of 3
benchmarks/navigation-memory.ts:193-195
**Forward mode becomes a no-op past the last file**

Once `position` reaches `fileCount - 1`, `Math.min(options.fileCount - 1, position + 1)` clamps back to the same index every iteration. All remaining navigations press `]` at the boundary with no state change. Any post-warmup samples collected during these saturated navigations record flat heap readings (highlight already cached, no new work), which deflates the computed slope and makes the threshold check artificially easy to pass. The default options use `navigations: 180` and `fileCount: 90`, so running with `--mode forward` and the defaults would silently saturate at navigation 90. A guard that logs a warning when `options.navigations > options.fileCount` in forward mode would surface this quickly.

Reviews (1): Last reviewed commit: "perf(ui): reduce highlight cache memory" | Re-trigger Greptile

Comment on lines +505 to +516
HUNK_MCP_HOST: "127.0.0.1",
HUNK_MCP_PORT: String(port),
HUNK_CONFIG_HOME: scratch,
HUNK_NO_UPDATE_CHECK: "1",
},
stdout: "pipe",
stderr: "pipe",
});

const samples: MemorySample[] = [];

try {

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.

P2 Port TOCTOU — another process can steal the reserved port

reserveLoopbackPort releases the listener before the daemon process starts. The gap between listener.close() and the daemon's own bind() is a race window: another process on the same machine can acquire the same ephemeral port in between. When that happens the daemon fails to start, waitUntil("daemon health") times out after 10 s, and the script exits without a memory reading. On a busy CI host this race is non-trivial. A safer pattern is to pass a server socket by file descriptor (or use a Unix-domain socket path) so ownership is never released.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/daemon-memory-check.ts
Line: 505-516

Comment:
**Port TOCTOU — another process can steal the reserved port**

`reserveLoopbackPort` releases the listener before the daemon process starts. The gap between `listener.close()` and the daemon's own `bind()` is a race window: another process on the same machine can acquire the same ephemeral port in between. When that happens the daemon fails to start, `waitUntil("daemon health")` times out after 10 s, and the script exits without a memory reading. On a busy CI host this race is non-trivial. A safer pattern is to pass a server socket by file descriptor (or use a Unix-domain socket path) so ownership is never released.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +193 to +195
nextDirection = -1;
} else if (position <= 0) {
nextDirection = 1;

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.

P2 Forward mode becomes a no-op past the last file

Once position reaches fileCount - 1, Math.min(options.fileCount - 1, position + 1) clamps back to the same index every iteration. All remaining navigations press ] at the boundary with no state change. Any post-warmup samples collected during these saturated navigations record flat heap readings (highlight already cached, no new work), which deflates the computed slope and makes the threshold check artificially easy to pass. The default options use navigations: 180 and fileCount: 90, so running with --mode forward and the defaults would silently saturate at navigation 90. A guard that logs a warning when options.navigations > options.fileCount in forward mode would surface this quickly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: benchmarks/navigation-memory.ts
Line: 193-195

Comment:
**Forward mode becomes a no-op past the last file**

Once `position` reaches `fileCount - 1`, `Math.min(options.fileCount - 1, position + 1)` clamps back to the same index every iteration. All remaining navigations press `]` at the boundary with no state change. Any post-warmup samples collected during these saturated navigations record flat heap readings (highlight already cached, no new work), which deflates the computed slope and makes the threshold check artificially easy to pass. The default options use `navigations: 180` and `fileCount: 90`, so running with `--mode forward` and the defaults would silently saturate at navigation 90. A guard that logs a warning when `options.navigations > options.fileCount` in forward mode would surface this quickly.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@benvinegar benvinegar merged commit 67a9714 into main Jun 9, 2026
8 checks passed
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