Skip to content

perf(relay): cache process-table descendant indexes - #17646

Merged
nwparker merged 2 commits into
mainfrom
nwparker/perf-process-table-index
Sep 1, 2026
Merged

nwparker merged 2 commits into
mainfrom
nwparker/perf-process-table-index

Conversation

@nwparker

@nwparker nwparker commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​75 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​74
Prod 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​39 $\color{#cf222e}{\Huge{\mathbf{−}}}$​13 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​26

ELI5

ps output is cached for 500ms, so a burst of terminal panes all inspect the same rows array. Each pane still walked every row to rebuild its own parent→child map before looking for the agent process. This memoizes one index per snapshot, keyed by the array itself, so the first pane in a window pays for the walk and the rest get it free. The WeakMap key means the index is released along with the snapshot.

What Changed

Rebased onto current main and re-expressed on top of #17525, which has since merged.

  • src/shared/process-table-snapshot.ts — add getProcessTableIndex(rows): a WeakMap<readonly ProcessTableRow[], ProcessTableIndex> memo sitting directly on top of feat(ssh): batch process evidence in PTY inventory #17525's buildProcessTableIndex. No new index type and no second builder; main's ProcessTableIndex remains the only one.
  • src/relay/pty-shell-utils.ts — collectDescendants takes the shared index instead of rebuilding a private childrenByParent map per call, and getForegroundProcessNameFromProcessTable resolves the PTY root through index.byPid instead of rows.find().
  • Tests for cache identity, cross-snapshot isolation, the duplicate-pid tie-break, and the stats hazard below.

Why

getForegroundProcessName runs per pane on the 750ms/2000ms inspection cadence and reads the TTL-cached snapshot, so N panes in one window each did an O(rows) map build over an identical array. Now that is one build per capture. #17525 batched the inventory path but left this per-pane relay path rebuilding.

Two Deliberate Decisions

Duplicate-pid tie-break — kept first-wins, and moved the rule into the shared builder. rows.find() was first-wins; buildProcessTableIndex was last-wins (byPid.set), so routing the relay through the shared index silently flipped root resolution on a malformed capture. First-wins is restored, and the rule now lives in buildProcessTableIndex so resolveAgentForegroundProcessesFromIndex's byPid.get(rootPid) root lookup gets the same semantics the subsystem had before indexing.

The tie-break is reachable and not symmetric. A process's argv may legally contain a newline, and ps -axo ... command= prints it raw, so the lenient parser can accept a continuation line as a spurious row duplicating a real pid — and that spurious row always follows the real one it was split from. Last-wins therefore hands the pane's foreground identity to a row assembled from process-controlled argv bytes. First-wins keeps the real root. Pinned by resolves a duplicated root pid to the FIRST capture row (relay: 'bash' → 'codex') and resolves a duplicated pid to the FIRST row, as \rows.find()` did(shared, asserted against bothgetProcessTableIndexandbuildProcessTableIndex`); both confirmed red against the previous commit.

The memo builds only the two indexes a resolver reads. Delegating to a four-map buildProcessTableIndex (byPid, childrenByPpid, byPgid, byTpgid) made this PR slower in the topology it is meant to help: the relay path reads only byPid and childrenByPpid, and the code it replaced built exactly one map. A single-pane relay would have gone from ~3 map/array ops per row per 500ms window to ~10. byPgid/byTpgid have no readers anywhere in the repo, so they are dropped — the same deletion #17763 makes, written line for line to match it. Pinned by materializes only the maps the descendant walk reads.

Per 500ms TTL window, over N rows and P panes polling in that window:

map builds row visits map/array ops
before this PR, P=1 1 ~1.5N (find + walk) ~3N
PR head 55b1028 (4 maps), P=1 4 N ~10N
this branch (2 maps), P=1 2 N ~5N
before this PR, P=20 20 ~30N ~60N
PR head 55b1028 (4 maps), P=20 4 N ~10N
this branch (2 maps), P=20 2 N ~5N

Those are op counts, not timings. Measured wall clock (Node 24.18, macOS arm64, one process per variant, real bundled getForegroundProcessNameFromProcessTable from each revision; 20 000 warmed windows per config; a fresh rows-array copy per window so the WeakMap key turns over exactly as it does in production, with the copy outside the timed region; root pid placed mid-table so main's rows.find pays its average scan). Median µs per 500 ms window:

400-row table

panes main 55b1028 (4 maps) this branch (2 maps)
1 5.0 24.9 11.9
2 9.8 13.8
3 18.9 15.4
4 19.8 17.0
8 39.0 24.0
20 99.3 58.5 44.0

2000-row table

panes main 55b1028 (4 maps) this branch (2 maps)
1 17.4 117.8 50.6
20 349.6 152.4 83.5

Stated plainly: at 1 pane this is a real regression, not a wash. It costs ~1.5–2.4x at 400 rows (main ranged 4.9–7.8 µs across repeated runs, this branch a stable 11.9 µs) and ~2.9x at 2000 rows. In absolute terms that is +5 µs per 500 ms window at 400 rows and +33 µs at 2000 rows — single-digit to low-tens of microseconds against a 500 000 µs window, so it is a regression worth stating plainly but not one a user can perceive. Break-even is between 2 and 3 panes. Past that the win grows with P: 1.6x at 8 panes and 2.2x at 20 panes on a 400-row table, and 4.2x at 20 panes on a 2000-row table.

The wall-clock win is smaller than the op-count table above suggests (~12x fewer map/array ops at 20 panes) because the memo removes only the index build. The descendant walk, the candidate sort and recognizeAgentProcessFromCommandLine still run once per pane and set the floor: at 20 panes over 400 rows this branch spends ~12 µs building the one index and ~1.7 µs per pane after it. Any claim of a ~10x end-to-end speedup would be reading the op-count ratio as if it were wall clock.

The four-map version was a worse 1-pane regression than "~3x" — 24.9 µs vs 5.0 µs (~5x) at 400 rows, 117.8 µs vs 17.4 µs (~6.8x) at 2000 rows — which is what motivated narrowing the build to the two maps a resolver reads.

The memo is deliberately stats-free. buildProcessTableIndex(rows, stats) mutates the caller's counter bag and stores it on the returned index, and lookupProcessTableIndex increments it afterwards. Memoizing such an index would hand one caller's bag to an unrelated later caller and let a cache hit satisfy an indexBuilds assertion without building anything. getProcessTableIndex therefore takes no stats and never memoizes a measured build; the perf gate in agent-foreground-process-batch.test.ts still calls buildProcessTableIndex(rows, stats) directly and still measures a real build. Pinned by keeps the memo out of measured builds so a cache hit cannot satisfy a perf gate.

Notes On The Rebase

  • src/shared/process-table-snapshot.ts auto-merged with no conflict markers into a file declaring export type ProcessTableIndex twice — git status reported it clean and every test still passed; only tsc catches it (TS2300). Resolved by taking main's file wholesale and re-adding only the memo.
  • Real conflicts in src/relay/pty-shell-utils.ts and src/shared/process-table-snapshot.test.ts were resolved to main's feat(ssh): batch process evidence in PTY inventory #17525 structure, then the memo was reapplied on top.
  • The [...evidenceRows] spread this PR originally needed to remove no longer exists: feat(ssh): batch process evidence in PTY inventory #17525 rewrote listProcesses to publish titles from evidenceResults[entryIndex], so nothing copies the rows array and the identity key is intact. For the same reason the "hoist one index across both the batch resolver and the title path" idea is already satisfied on main — a listing walks the table once. No change needed there.

Interaction With #17763

#17763 (brennanb2025/r1-build-review-fixes) edits the same files: it collapses the two ps readers into one ProcessTableCapture with lazy lenient()/strict() parsers and drops byPgid/byTpgid. There is no merge-ordering constraint — the byPgid/byTpgid deletion is duplicated here rather than deferred, so this branch stands on its own at the cost measured above (a small 1-pane regression, break-even by ~3 panes, a growing win beyond) instead of needing #17763 to land first to narrow the build.

  • The deletion is written to match fix(relay): close review findings from #17525 #17763's byte for byte, so the type hunk, the loop-body hunk and the first-wins line all auto-merge.
  • ProcessTableCapture.lenient() memoizes its parse per capture, so snapshot array identity — the thing this cache keys on — survives.
  • The memo block was moved below lookupProcessTableIndex so fix(relay): close review findings from #17525 #17763's insertion of scoreForegroundCandidateRow lands on unchanged context.
  • Verified by test-merging add1f1c8a48: process-table-snapshot.ts now has exactly one 1-line conflict (both sides rewrite the return { ... } line; fix(relay): close review findings from #17525 #17763's side is correct as-is). The other three conflicts — two import lists and the getForegroundProcessNameFromProcessTable body — are unchanged from before this commit and are inherent to both PRs rewriting that function.

Linked Issue

N/A — proactive performance audit.

Visual Proof

N/A — relay/shared process bookkeeping only; no UI or interaction change.

Testing

  • pnpm tc:node — clean (run first; the only check that catches the silent duplicate-type auto-merge).
  • pnpm test src/shared/process-table-snapshot.test.ts src/relay/pty-shell-utils.test.ts src/main/providers/ — 858 passed, 5 skipped.
  • pnpm exec oxlint / pnpm exec oxfmt --check on all four changed files — clean.
  • Every new test confirmed red against pre-fix source, then restored (3 failures: both first-wins tests and the narrow-index test).
  • Local macOS only; no live SSH/network-failure or native Windows/WSL run was available.

Review

Ready for review. The earlier "#17525 may supersede this; do not merge both implementations" note is obsolete — #17525 has merged as 9477b5fcbb9, this branch now builds on it rather than duplicating it, and the overlap it warned about is gone.

No RPC, wire, persistence, mobile schema, provider, or path contract changes are included.

Agent skill upstream boundary

  • Not applicable.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (CI will cover; focused local checks listed above)

@nwparker
nwparker force-pushed the nwparker/perf-process-table-index branch from 5c98709 to 55b1028 Compare August 31, 2026 23:40
@nwparker
nwparker marked this pull request as ready for review August 31, 2026 23:42
@coderabbitai

coderabbitai Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 3edc9ee8-034e-4a3e-97d7-512a476ae09d

📥 Commits

Reviewing files that changed from the base of the PR and between fe50c21 and df1988b.

📒 Files selected for processing (4)
  • src/relay/pty-shell-utils.test.ts
  • src/relay/pty-shell-utils.ts
  • src/shared/process-table-snapshot.test.ts
  • src/shared/process-table-snapshot.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The process-table reader now shares cached lenient and strict parses for each capture. Process indexes cache PID and parent-child lookups by snapshot identity and preserve the first row for duplicate PIDs. Foreground-process lookup reuses the index for root and descendant traversal, with centralized candidate scoring. Tests cover capture behavior, index memoization, duplicate rows, and foreground resolution.

Merge Risk: 🔵 Low · up to df198

The cache reduces repeated process-table work for multiple terminal panes but adds a measured single-pane cost of about 5–33 microseconds per 500 ms window before breaking even at two to three panes. This bounded performance tradeoff is mergeable with owner awareness; no merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the change, motivation, implementation, testing, performance impact, and scope. However, the required Linked Issue section is marked N/A even though the template requires an i… Add the issue link in the Linked Issue section, using the required Fixes #<issue-number> format. If no issue exists, create or link the appropriate tracking issue before merge.
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and clearly describes the main change: caching process-table descendant indexes in the relay path.
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.
Full details: Description check

Explanation

The description explains the change, motivation, implementation, testing, performance impact, and scope. However, the required Linked Issue section is marked N/A even though the template requires an issue link.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 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.

@greptile-apps

greptile-apps Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Greptile Summary

The PR memoizes process-table indexes by snapshot-array identity while preserving the relay’s previous first-match PID semantics.

  • Reuses one byPid and childrenByPpid index across panes sharing a cached process-table snapshot.
  • Routes relay root lookup and descendant traversal through the shared index.
  • Adds coverage for cache identity, snapshot isolation, duplicate PIDs, narrow index construction, and stats isolation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/shared/process-table-snapshot.ts Adds weak identity-based index memoization and changes duplicate PID indexing to preserve first-row lookup semantics.
src/relay/pty-shell-utils.ts Reuses the shared process-table index for root resolution and descendant traversal instead of rebuilding a map per pane.
src/shared/process-table-snapshot.test.ts Covers memoization identity, snapshot separation, first-wins duplicate handling, index shape, and stats isolation.
src/relay/pty-shell-utils.test.ts Verifies relay foreground-process resolution when malformed process output repeats the root PID.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[TTL-cached process-table snapshot] --> B[getProcessTableIndex]
    B --> C{Index cached for array identity?}
    C -->|No| D[Build byPid and childrenByPpid]
    D --> E[Store in WeakMap]
    C -->|Yes| F[Reuse cached index]
    E --> G[Resolve PTY root and descendants]
    F --> G
Loading

Reviews (4): Last reviewed commit: "fix(relay): keep the process-table index..." | Re-trigger Greptile

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

✅ No new issues found.

Reviewed changes

  • getProcessTableIndex memo — a WeakMap<readonly ProcessTableRow[], ProcessTableIndex> in src/shared/process-table-snapshot.ts caches one index per snapshot-array identity; getProcessTableSnapshot() returns cached.value directly, so identity is stable within the 500ms TTL and the memo actually hits.
  • Relay path reuses the index — collectDescendants now walks index.childrenByPpid and root resolution goes through index.byPid.get(pid), eliminating the per-pane parent/child rebuild in src/relay/pty-shell-utils.ts.
  • Two tests files — identity reuse/isolation, last-wins tie-break, and a stats-hazard guard (getProcessTableIndex is stats-free so a cache hit can't satisfy a measured indexBuilds) in process-table-snapshot.test.ts; the duplicate-root-pid case in pty-shell-utils.test.ts.

The build order in collectDescendants is byte-identical to the old childrenByParent (same iterate+push, same DFS), so the only behavioral change is the first-wins → last-wins root resolution on malformed/duplicate-pid captures — deliberate, documented, and pinned by tests. The WeakMap value's back-reference to rows (via index.rows) is inert under ephemeron semantics, and the memo is shape-agnostic with respect to the in-flight #17763.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

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

✅ No new issues found.

Reviewed changes — since the prior review, the author narrowed the index to the two maps the relay actually reads and restored first-wins pid resolution.

  • Restored first-wins pid tie-break — buildProcessTableIndex now skips a duplicate pid (if (!byPid.has(row.pid))), reverting the relay root lookup from last-wins back to the original rows.find() first-wins semantics and applying the same rule to resolveAgentForegroundProcessesFromIndex.
  • Dropped the unread byPgid/byTpgid maps — ProcessTableIndex and its builder no longer materialize the two maps no resolver queries, so a one-pane relay pays for two maps per capture instead of four.
  • Tests updated and extended — duplicate-pid assertions now pin first-wins for both the memo and the shared builder, and a new test pins the two-map index shape.

I verified the removed maps have no remaining readers anywhere in the repo, the batch resolver touches only byPid/childrenByPpid, and the touched suites pass (process-table-snapshot.test.ts, pty-shell-utils.test.ts, agent-foreground-process-batch.test.ts — 59 tests) with clean lint on the changed files.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

Two defects in the memoized index this PR introduced.

- Restore the first-wins duplicate-pid tie-break the relay had as
  `rows.find()`. A process whose argv contains a newline makes `ps` print a
  continuation line that the lenient parser can accept as a spurious row
  duplicating a real pid; that row always FOLLOWS the real one, so last-wins let
  it capture the pane's foreground. The rule now lives in
  `buildProcessTableIndex`, so the batched evidence resolver's `byPid.get(rootPid)`
  root lookup gets the same semantics the subsystem had before indexing.
- Build only the two indexes a resolver reads. `byPgid`/`byTpgid` have no readers
  repo-wide, and delegating to a four-map build made a one-pane relay pay more
  per 500ms capture than the single `childrenByParent` map it replaced --
  a regression in the majority topology, in a PR whose point is relay CPU.

Matches the same deletion in #17763 line for line so whichever merges second
resolves trivially.
@nwparker
nwparker force-pushed the nwparker/perf-process-table-index branch from fe50c21 to df1988b Compare September 1, 2026 01:20

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

✅ No new issues found.

Reviewed changes

Since the prior review, the branch was rebased onto main (which now includes #17525 and #17763) and squashed from three commits into two (de5b084e25 + df1988b6a2). The author's own diff is unchanged in substance — the memo, the first-wins tie-break, and the two-map index were already reviewed and approved — and the only textual delta is the main-side #17763 surface the rebase picked up.

  • Rebased onto #17763 — getForegroundProcessNameFromProcessTable now uses main's single ProcessTableCapture reader and shared scoreForegroundCandidateRow in place of the former dual-reader layout and local candidateScore; these are main's changes, not new behavior introduced here.
  • First-wins tie-break re-applied — buildProcessTableIndex retains if (!byPid.has(row.pid)) (first-wins, matching rows.find()), and the byPgid/byTpgid deletion the df1988b6a2 commit message references is already on main via #17763, so it no longer appears in this PR's diff.
  • Tests re-verified — the duplicate-root-pid test now asserts first-wins ('codex'), and the new tests pin the two-map index shape and the stats-free memo; each is written to fail under the unwound behavior.

I verified the strict parser used by the batch evidence path throws on malformed rows, so the first-wins change is unreachable there (no behavior change for resolveAgentForegroundProcessesFromIndex), and the memo's identity key is stable because ProcessTableCapture.lenient() memoizes the parse per capture.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@nwparker
nwparker merged commit 406bd0e into main Sep 1, 2026
33 checks passed
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
* perf(relay): cache process-table descendant indexes

* fix(relay): keep the process-table index first-wins and narrow

Two defects in the memoized index this PR introduced.

- Restore the first-wins duplicate-pid tie-break the relay had as
  `rows.find()`. A process whose argv contains a newline makes `ps` print a
  continuation line that the lenient parser can accept as a spurious row
  duplicating a real pid; that row always FOLLOWS the real one, so last-wins let
  it capture the pane's foreground. The rule now lives in
  `buildProcessTableIndex`, so the batched evidence resolver's `byPid.get(rootPid)`
  root lookup gets the same semantics the subsystem had before indexing.
- Build only the two indexes a resolver reads. `byPgid`/`byTpgid` have no readers
  repo-wide, and delegating to a four-map build made a one-pane relay pay more
  per 500ms capture than the single `childrenByParent` map it replaced --
  a regression in the majority topology, in a PR whose point is relay CPU.

Matches the same deletion in stablyai#17763 line for line so whichever merges second
resolves trivially.
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