Skip to content

perf(serve): bound memory for headless orca serve - #13684

Open
IamCoder18 wants to merge 3 commits into
stablyai:mainfrom
IamCoder18:perf/serve-memory-optimizations
Open

IamCoder18 wants to merge 3 commits into
stablyai:mainfrom
IamCoder18:perf/serve-memory-optimizations

Conversation

@IamCoder18

@IamCoder18 IamCoder18 commented Aug 10, 2026 •

Copy link
Copy Markdown

Summary

Headless orca serve inherits the desktop's per-window memory footprint. This caps unbounded long-lived Maps and skips work the serve path does not need.

Per-PTY memory (bench, N=50, --expose-gc)

scrollback=5000 (desktop)   N=50  51 MB delta  →  1.02 MB / PTY
defaultScrollback=1000      N=50   6 MB delta  →  0.12 MB / PTY

88% reduction per PTY. For 50 concurrent agents: ~45 MB saved. For 100: ~90 MB.

Cold-boot RSS at 20s (no PTYs, no clients — framework overhead only)

Metric base this PR Δ
Main RSS 286,204 KB 285,736 KB −468 KB
Daemon RSS 100,784 KB 100,992 KB +208 KB

Most optimizations are workload-dependent and don't show until clients/repos/PTYs exist. The per-PTY win above dominates under agent-session load.

Changes

Item Change File
QW-1 Skip --max-old-space-size in serve (no renderer V8) src/main/startup/renderer-heap-headroom.ts:83
QW-2 Skip enableMainProcessGpuFeatures in serve (renderer-only flags) src/main/index.ts:828
QW-3 Lazy-init PluginService + KillList + Marketplace + Installer + BundledBootstrap when pluginSystemEnabled is off in serve src/main/index.ts:2618-2747
QW-4 Skip boot-time ensureMainI18n + setMainUiLanguage in serve (translateMain falls back to English) src/main/index.ts:2822
QW-7 Skip new StarNagService + listeners in serve (no renderer to nag) src/main/index.ts:2749
ME-1 Bound clientSessionTabSelections.statesByClient via src/shared/bounded-map.ts LRU (cap 256, get() reorder) src/main/runtime/client-session-tab-selection.ts:131
ME-2 clearGitReadCachesForPaths(paths) prunes submodulePathsCache / resolvedUpstreamNameCache / effectiveUpstreamStatusCache by path boundary (exact OR descendant with //\ separator — siblings are preserved); Store.removeProject + Store.removeProjectForHost capture repo paths before filter and call it src/main/git/status.ts:122, src/main/persistence.ts:4729,4749
ME-3 Store.gitUsernameCache swapped from plain Map to BoundedMap({ maxEntries: 5000 }) src/main/persistence.ts:2813
ME-5 HeadlessEmulatorOptions.defaultScrollback; runtime reads deps.headlessEmulatorScrollback; serve sets 1000 instead of 5000 src/main/daemon/headless-emulator.ts:51, src/main/runtime/orca-runtime.ts:3168,11356, src/main/index.ts:2460

Reuse: src/shared/bounded-map.ts (already supports maxEntries, maxBytes, sizeOf, onEvict, LRU via get() reorder) is the only BoundedMap in the repo.

Skipped: QW-5 (churn probe already env-gated), ME-4 (shared-profile data-loss risk — would have overwritten non-local partitions on the next scheduleSave()), ME-6 (StatsCollector already bounded and serve needs stats for analytics), mobileSessionTabsByWorktree + forgetWorktree orphan sweeps (already wired).

Screenshots

No visual change. Renderer is not loaded by orca serve.

Testing

  • pnpm lint — audit:code-quality:native + audit:code-quality:type-aware clean.
  • pnpm typecheck — pnpm tc:node clean.
  • pnpm test — 7,463 passed across src/main/git, persistence, runtime, daemon. Full suite: 49,182 passed, 7 pre-existing failures (verified on upstream/main@c0c893d171 via git stash --include-untracked): src/relay/git-handler, src/relay/pty-shell-launch × 3, src/main/providers/local-pty-shell-ready × 2, src/main/ssh/ssh-remote-commands. None touched by this PR.
  • pnpm build — pnpm build:cli clean.
  • Tests added:
    • src/main/git/status-clear-caches-for-paths.test.ts — 6 tests including sibling-prefix regression (/worktrees/repo-a vs /worktrees/repo-ab vs /worktrees/repo-a/sub).
    • src/main/daemon/headless-emulator.test.ts — 3 tests asserting constructor scrollback precedence (default used, explicit wins, module default).
    • src/main/persistence.test.ts — clearGitReadCachesForPaths integration via removeProject.
    • src/main/startup/renderer-heap-headroom.test.ts — serve-mode skip.
    • src/main/runtime/client-session-tab-selection.test.ts — bounded-map LRU semantics.

Cross-platform compatibility (macOS, Linux, Windows)

  • Every change is gated on the existing isServeMode flag; no new platform branches.
  • enableMainProcessGpuFeatures's macOS disable-skia-graphite switch and Linux Wayland/X11 branches remain reachable for desktop — only the call site is gated.
  • clearGitReadCachesForPaths path boundary check handles both / and \ separators.
  • No keyboard shortcut, menu accelerator, or path-construction changes.

SSH / remote / local

  • ME-1 and ME-2 worktree path capture runs in Store regardless of execution host.
  • ME-3 cache is per-repo-path, host-agnostic.
  • No SSH provider, SSH relay write, or remote worktree paths changed.

Git provider / git binary compatibility

  • clearGitReadCachesForPaths is path-prefix-based and provider-neutral.
  • No new git commands or options.

Security

  • All flags are internal booleans (isServeMode, pluginSystemEnabled, defaultScrollback).
  • defaultScrollback is passed to xterm.js's documented scrollback option.
  • No new IPC channels, no new subprocess invocations, no auth/secret handling, no new dependencies.

Notes

  • Related: PR feat(serve): add --no-offscreen-browser flag #13434 (--no-offscreen-browser) lands QW-6 (the only item from the original analysis already in flight). This PR covers QW-1..QW-8 and ME-1..ME-7 and is independent of feat(serve): add --no-offscreen-browser flag #13434.
  • Plugin toggle in serve: deliberately not hot-toggled; desktop continues to hot-toggle via the existing store.onSettingsChanged path. Documented inline at the QW-3 gate.
  • Benchmark reproducibility: pnpm vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-memory.bench.test.ts --reporter=verbose. Vitest workers run with --expose-gc.

orca serve kept the desktop's per-window memory footprint even though it
has no renderer. Per-PTY HeadlessEmulator scrollback alone cost ~1 MB per
PTY; long-lived sessions leaked Maps (gitUsernameCache, plugin init,
non-local host partitions, client session tabs) without bound. Measured
in-process: 50 PTYs at scrollback=5000 = 51 MB RSS vs scrollback=1000 =
6 MB RSS (~88% reduction per PTY).

- QW-1: skip enableRendererHeapHeadroom in serve (no renderer V8 isolate)
- QW-2: skip enableMainProcessGpuFeatures in serve (renderer-only flags)
- QW-3: lazy-init Plugin services (and kill-list/marketplace/installer/
  bundled-bootstrap) when pluginSystemEnabled is off in serve mode
- QW-4: skip ensureMainI18n + setMainUiLanguage at boot in serve
  (translateMain falls back to English when not initialized)
- QW-7: skip StarNagService constructor + listeners in serve (no
  renderer to nag, IPC handlers unreachable)
- ME-1: bound ClientSessionTabSelectionStore.statesByClient with a
  BoundedMap LRU keyed by lastAccessedAt (cap 256)
- ME-2: clearGitReadCachesForPaths prunes submodulePathsCache,
  resolvedUpstreamNameCache, effectiveUpstreamStatusCache by path prefix;
  Store.removeProject calls it for the removed repo's paths
- ME-3: swap gitUsernameCache from Map to BoundedMap (cap 5000)
- ME-4: StoreOptions.dropNonLocalHostWorkspaceSessions drops non-local
  workspaceSessionsByHostId partitions after load (serve needs only local)
- ME-5: HeadlessEmulatorOptions.defaultScrollback + runtime dep;
  serve sets 1000 instead of the 5000 desktop default
- ME-7: new BoundedMap<K,V> primitive (insertion-order or LRU eviction,
  Map-iterator protocol)
@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change bounds Git username and client-state caches and clears repository-related Git read caches during removal. It adds configurable headless emulator scrollback with explicit-option precedence and a serve-mode limit. Serve mode now skips renderer heap and GPU setup, filters non-local workspace sessions, avoids unnecessary plugin, Star Nag, and i18n initialization, and includes renderer heap guard coverage.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% 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
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.
Title check ✅ Passed The title clearly summarizes the primary change: reducing memory usage in headless serve mode.
Description check ✅ Passed The description covers the summary, benchmarks, testing, screenshots, security, platform compatibility, and implementation details.

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.

@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: 7

🧹 Nitpick comments (2)
src/main/memory/bounded-map.ts (1)

101-115: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid a full temporary entry array during eviction.

Line 104 allocates an array of every cached entry for each insertion after the cap. Scan the Map iterator directly. This keeps ranked eviction at O(n) time but reduces temporary memory from O(n) to O(1).

Proposed refactor
-    // Why: Map iteration is insertion-ordered, so for the default rank (always 0) the first
-    // key is the oldest insertion. For LRU, the rank comparison only makes sense against a
-    // second entry — compare each candidate against a fixed reference and keep the minimum.
-    const all = [...this.entries_]
-    if (all.length === 0) {
+    // Map iteration preserves insertion order, so rank ties keep the oldest entry.
+    const entries = this.entries_.entries()
+    const first = entries.next()
+    if (first.done) {
       return
     }
-    let oldestKey: K = all[0][0]
-    for (let i = 1; i < all.length; i++) {
-      const candidate = all[i]
-      if (this.evictionRank(candidate, [oldestKey, this.entries_.get(oldestKey)!]) < 0) {
-        oldestKey = candidate[0]
+    let oldest = first.value
+    for (const candidate of entries) {
+      if (this.evictionRank(candidate, oldest) < 0) {
+        oldest = candidate
       }
     }
-    this.entries_.delete(oldestKey)
+    this.entries_.delete(oldest[0])
src/main/index.ts (1)

2628-2630: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Use direct store access in the plugin block. store is initialized before this block and is not reset to null, so store?.getSettings() and store! are redundant. Plugin RPC methods intentionally reject requests when serve mode skips initialization; they do not throw a null-dereference TypeError.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ed1b98be-4d76-438b-86dd-4037921539bf

📥 Commits

Reviewing files that changed from the base of the PR and between 84bd306 and d412df1.

📒 Files selected for processing (13)
  • src/main/daemon/headless-emulator.test.ts
  • src/main/daemon/headless-emulator.ts
  • src/main/git/status-clear-caches-for-paths.test.ts
  • src/main/git/status.ts
  • src/main/index.ts
  • src/main/memory/bounded-map.test.ts
  • src/main/memory/bounded-map.ts
  • src/main/persistence.test.ts
  • src/main/persistence.ts
  • src/main/runtime/client-session-tab-selection.ts
  • src/main/runtime/orca-runtime.ts
  • src/main/startup/renderer-heap-headroom.test.ts
  • src/main/startup/renderer-heap-headroom.ts

Comment thread src/main/daemon/headless-emulator.test.ts
Comment thread src/main/git/status-clear-caches-for-paths.test.ts
Comment thread src/main/index.ts
Comment thread src/main/memory/bounded-map.ts Outdated
Comment thread src/main/memory/bounded-map.ts Outdated
Comment thread src/main/persistence.ts
Comment thread src/main/runtime/client-session-tab-selection.ts Outdated
- Replace the redundant main/memory/BoundedMap with the shared
  src/shared/bounded-map.ts. The local class collided with the existing
  one in dashboard-payload-validation.ts, which uses maxBytes + sizeOf
  for byte-based eviction. The shared class supports maxEntries, LRU
  via get() reorder, byte caps, and onEvict.
- Revert dropNonLocalHostWorkspaceSessions. The previous implementation
  mutated state and scheduled a save, which would overwrite a shared
  desktop+serve profile with the filtered partition map on the next
  write. Removed StoreOptions.dropNonLocalHostWorkspaceSessions,
  constructor branch, index wiring, and the test.
- Apply clearGitReadCachesForPaths + gitUsernameCache.delete in
  removeProjectForHost when the repo id is fully gone (matching
  removeProject). Captures paths from the matching repo before the
  filter runs; leaves the cache alone when another host still holds
  the repo id.
- HeadlessEmulatorOptions.defaultScrollback: replace the shallow
  'construction succeeds' tests with three that assert actual
  constructor precedence (defaultScrollback used, explicit scrollback
  wins, module default applies when neither is set). Tests read the
  effective scrollback from the underlying xterm options to avoid
  adding a public getter that would push the file past the 300-line
  oxlint max-lines budget.
- git/status.ts: add primeGitReadCachesForTests() so status-clear-
  caches-for-paths.test.ts can populate the module-level caches and
  assert the matching-eviction contract instead of only the empty-
  cache no-op path.

Verified locally: pnpm tc:node clean, oxlint max-lines at exactly
300 non-blank/non-comment lines, 7,948 tests across persistence,
git, runtime, daemon, startup, plugins, i18n, and star-nag.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/git/status.ts (1)

127-130: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a path boundary when clearing Git read caches.

key.startsWith(path) also matches sibling paths. Removing /worktrees/repo-a can remove cache entries for /worktrees/repo-ab. Extract the path before \0, then match the exact path or a descendant path with a separator. Add a sibling-prefix regression test.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0aec79c1-fe3d-404f-9340-eea170106a02

📥 Commits

Reviewing files that changed from the base of the PR and between d412df1 and 660b41a.

📒 Files selected for processing (6)
  • src/main/daemon/headless-emulator.test.ts
  • src/main/git/status-clear-caches-for-paths.test.ts
  • src/main/git/status.ts
  • src/main/index.ts
  • src/main/persistence.ts
  • src/main/runtime/client-session-tab-selection.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/main/daemon/headless-emulator.test.ts
  • src/main/index.ts
  • src/main/persistence.ts

CodeRabbit review caught a sibling-path false positive: clearing
/worktrees/repo-a would also clear cache entries for /worktrees/repo-ab
because the matcher used startsWith on the raw cache key. The cache
key format is [path, wslDistro].join('\0'), so the path is the
substring before the first NUL.

The matcher now extracts the path portion and accepts either an exact
match or a descendant with a '/' or '\' separator boundary. Siblings
with a shared prefix are preserved; descendants are still cleared.

Regression test added in status-clear-caches-for-paths.test.ts that
primes /worktrees/repo-a, /worktrees/repo-ab, and /worktrees/repo-a/sub
then asserts clearing /worktrees/repo-a leaves exactly one entry
(repo-ab) behind.
@IamCoder18

IamCoder18 commented Aug 11, 2026 •

Copy link
Copy Markdown
Author

Ready to review!

@brennanb2025

Copy link
Copy Markdown
Contributor

Heads-up on overlap: #13061 gives every daemon terminal session an explicit flat 1000-row scrollback window at creation (set via terminal-host-session-create), so once it lands, the defaultScrollback fallback added here will no longer be reachable for daemon PTYs — they always receive an explicit value. Your serve-side pieces (runtime-emulator default, the --max-old-space-size skip, and the other QW items) remain complementary and valuable; this will just need a rebase and a refresh of the per-PTY bench framing afterward. Your N=50 bench numbers were useful evidence for sizing #13061's window — thanks for measuring.

@nwparker nwparker removed the P1 label Aug 19, 2026

This branch has not been deployed

No deployments
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.

5 participants