Skip to content

perf(port-scanner): shared ps per scan, lsof -a, idle-session decay - #5723

Merged
Kitenite merged 4 commits into
mainfrom
optimize-port-scanner-per
Jul 17, 2026
Merged

Kitenite merged 4 commits into
mainfrom
optimize-port-scanner-per

Conversation

@Kitenite

@Kitenite Kitenite commented Jul 16, 2026 •

Copy link
Copy Markdown
Member

Problem

Customer report (validated + reproduced empirically with CDP on a live dev desktop): the port scanner is a major source of background system load, in both v1 and v2 (shared packages/port-scanner).

  1. Per-session full-system ps every 2.5s. getProcessTree() used pidtree, whose darwin backend spawns ps -A per call — one per registered session per tick. Measured: 13 sessions → 35 distinct ps spawns/12s, 26 sessions → 60/12s (lower bounds, ~50% sampler catch rate). Linear scaling confirmed; extrapolates to the customer's ~13/sec at 32 panes.
  2. lsof without -a. lsof ORs the -p and -iTCP selectors, enumerating every TCP listener on the machine (observed: 721 lines from 56 unrelated processes, zero requested pids) only for the JS-side pid filter to discard everything.
  3. No idle handling at all. Fixed 2.5s cadence forever — no decay after 5 min untouched (measured), scanning continues for unattached sessions with no renderer (boot-restored and reaper-registered ones included). stopPeriodicScanIfIdle only stopped at zero sessions.

Fix

  • One shared process-table read per scan cycle: new getProcessTreesForPids() calls pidtree(-1, {advanced: true}) once (one ps/wmic total) and walks the parent/child table in memory for every due session. O(sessions) full-table dumps → O(1). Same shape as readProcessTable() in pty-daemon.
  • lsof -a: selectors are now ANDed; the pid check stays as defense in depth. (~23% faster measured; the big win is not walking every process's fd table.)
  • Idle decay: sessions with no PTY output for 60s drop from the 2.5s cadence to 30s. Any output (not just port hints) restores the fast cadence — checkOutputForHint now takes the terminalId and doubles as the activity signal. Ports are still detected for silently-daemonized servers, just slower; skipped sessions keep their previously detected ports untouched. Re-upserting the same pid (the reaper's periodic sync of unattached daemon sessions) does not reset the idle clock. Addresses the intent of perf(desktop): increase port scan interval and skip scan when no sessions #3238/perf(desktop): skip periodic port scan for idle sessions with no dev servers #3239.
  • Rename stopPeriodicScanIfIdle → stopPeriodicScanIfNoSessions (it never handled idleness).

Steady-state for a fully idle machine: from sessions × 0.4 ps/sec to ~0.03 ps/sec + ~0.03 lsof/sec, regardless of session count.

Tests

  • New regression tests: single tree read per bulk scan, idle skip/slow cadence, output restores fast cadence, same-pid re-upsert preserves the idle clock, skipped sessions keep their ports, forceScan bypasses cadence.
  • Real-process integration tests for getProcessTreesForPids (spawned child appears in tree, dead roots omitted) and getListeningPortsForPids (Bun.serve listener found with -a in place).
  • Each new behavior verified by mutation: always-due, always-reset-on-upsert, and no-activity-marking mutations each fail exactly the intended tests.
  • bun test (port-scanner 65 ✓, desktop daemon-manager 10 ✓, host-service terminal 89 ✓), turbo typecheck on all three packages ✓, bun run lint ✓.

Open in Stage

Summary by CodeRabbit

  • Bug Fixes

    • Improved terminal output hint detection so hints are correctly attributed to the originating terminal session.
    • Fixed fragmented/partial output handling to avoid incorrect hint matches.
    • Reduced false-positive port scans from unrelated terminal output.
    • Refined idle-session port refresh behavior so previously detected ports persist until the next due scan.
  • Performance

    • Reduced scanning overhead by batching process-tree discovery for sessions that are due to be scanned.
    • Improved scan scheduling and force-scan coalescing to maintain consistent scan cadence.
  • Tests

    • Updated scanner/port-manager tests to validate the new bulk scanning and cadence behavior.

Customer-reported (and reproduced): the port scanner's CPU/process-spawn
cost scaled linearly with terminal count and never backed off.

- getProcessTreesForPids: one pidtree(-1) system-table read per scan
  cycle walked in memory, replacing one full `ps -A` spawn per session
  per 2.5s tick (measured ~5 ps/sec at 26 sessions, all idle).
- lsof -a: AND the -p and -iTCP selectors. Without it lsof ORs them and
  enumerates every TCP listener on the machine (observed 721 lines from
  56 unrelated processes), all discarded in JS by the pid filter.
- Idle decay: sessions with no PTY output for 60s are scanned every 30s
  instead of every 2.5s; any output restores the fast cadence. Fixes
  parked/unattached sessions (boot-restored, reaper-registered) paying
  full scan cost 24/7 with nobody watching. Re-upserts of the same pid
  (reaper sync) don't reset the idle clock.
- Rename stopPeriodicScanIfIdle → stopPeriodicScanIfNoSessions; it only
  ever stopped at zero sessions and hid the missing idle handling.
@ghost

ghost commented Jul 16, 2026 •

Copy link
Copy Markdown

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

Title
1 Batch process-tree discovery for performance
2 Optimize lsof with ANDed selectors
3 Implement idle session decay and activity tracking
4 Wire terminal activity to PortManager
5 Verify performance and idle logic with tests
Open in Stage

Chapters generated by Stage for commit 84e0426 on Jul 17, 2026 12:22am UTC.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 7 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR addresses a customer-reported background load issue in the port scanner by making three targeted fixes: collapsing O(sessions) ps spawns into a single system-wide pidtree(-1) call per tick, fixing lsof to AND its -p and -iTCP selectors with -a (previously it ORed them and walked every process's fd table), and introducing an idle-decay cadence that drops terminals with no PTY output in the last 60 s from the 2.5 s scan interval to 30 s.

  • getProcessTreesForPids replaces per-session getProcessTree(pid) calls with a single process-table read that is walked in memory for every due session, reducing steady-state scan cost from O(sessions × 0.4 ps/sec) to ~0.03 ps/sec regardless of session count.
  • Idle decay adds lastActivityAt / lastScannedAt to each SessionEntry; checkOutputForHint now takes terminalId and doubles as the activity signal, restoring the fast cadence on any PTY output; re-upserts with the same pid do not reset the idle clock.
  • -a lsof flag ANDs the pid and TCP selectors so only sockets belonging to the requested pids are enumerated, eliminating the previously observed 721-line full-machine output.

Confidence Score: 4/5

Safe to merge. All three fixes are mechanically straightforward, well-tested with mutation-verified tests, and the behavioral changes are clearly documented and match the stated intent.

The implementation is solid: idle-clock updates happen before any early returns in checkOutputForHint, the shared pidtree call correctly propagates errors to preserve last-known-good state rather than zeroing out ports, and the forceScan path correctly bypasses idle filtering. The one minor gap is in terminal.ts where checkOutputForHint is gated on hintText.length > 0, meaning terminals emitting binary-only chunks could silently miss an idle-clock refresh — a scenario rare in practice but a subtle divergence from the daemon path.

packages/host-service/src/terminal/terminal.ts — idle clock update is conditional on the StringDecoder producing a non-empty chunk, unlike daemon-manager.ts which always updates it.

Important Files Changed

Filename Overview
packages/port-scanner/src/scanner.ts Replaces per-session getProcessTree(pid) with a single system-wide getProcessTreesForPids(rootPids[]) using pidtree(-1, {advanced:true}); adds -a to lsof to AND selectors. Core of the O(sessions)→O(1) improvement.
packages/port-scanner/src/port-manager.ts Adds lastActivityAt/lastScannedAt to SessionEntry, idle decay logic (isSessionDue), batched collectSessionPids, and promotes checkOutputForHint to accept terminalId as an activity signal. Minor inconsistency in how the idle clock is updated vs. the daemon path.
packages/port-scanner/src/port-manager.test.ts Adds comprehensive idle-decay tests (skip/slow cadence, activity restore, same-pid re-upsert, skipped sessions retain ports, forceScan bypasses cadence) and updates existing tests for the new bulk-tree API. Module-restore afterAll guard prevents mock leakage to scanner.test.ts.
packages/port-scanner/src/scanner.test.ts Adds real-process integration tests for getProcessTreesForPids (child appears in tree, dead roots omitted) and getListeningPortsForPids (Bun.serve listener found with -a in place). Well-structured and complete.
packages/host-service/src/terminal/terminal.ts Passes terminalId to checkOutputForHint, but the call is gated on hintText.length > 0 — if the StringDecoder produces an empty chunk (e.g. partial multibyte sequences from binary output), the idle clock won't be updated for that chunk.
apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts Trivial one-line update: passes paneId as the new first arg to checkOutputForHint. Called on every data event, so the idle clock is always updated in this path.
packages/port-scanner/src/index.ts Re-exports getProcessTreesForPids in place of the removed getProcessTree. Correct rename.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Interval as "setInterval (2.5s)"
    participant PM as PortManager
    participant Scanner as "scanner.ts"
    participant OS as "OS (ps/lsof)"

    note over Interval,OS: Before this PR — per-session ps spawns
    Interval->>PM: tick
    PM->>Scanner: getProcessTree(pidA)
    Scanner->>OS: "ps -A (spawn 1)"
    PM->>Scanner: getProcessTree(pidB)
    Scanner->>OS: "ps -A (spawn 2)"
    PM->>Scanner: "getListeningPortsForPids(allPids)"
    Scanner->>OS: "lsof -p pids -iTCP ... (OR: all TCP listeners)"

    note over Interval,OS: After this PR — one shared read per tick
    Interval->>PM: tick
    PM->>PM: "isSessionDue check (skip idle sessions)"
    PM->>Scanner: "getProcessTreesForPids([pidA, pidB, ...])"
    Scanner->>OS: "ps -A (ONE spawn for all sessions)"
    Scanner-->>PM: "Map{pidA:[...], pidB:[...]}"
    PM->>Scanner: "getListeningPortsForPids(allPids)"
    Scanner->>OS: "lsof -a -p pids -iTCP ... (AND: only requested PIDs)"
    Scanner-->>PM: "PortInfo[]"
    PM-->>PM: "updatePortsFromScan (skipped idle sessions untouched)"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Interval as "setInterval (2.5s)"
    participant PM as PortManager
    participant Scanner as "scanner.ts"
    participant OS as "OS (ps/lsof)"

    note over Interval,OS: Before this PR — per-session ps spawns
    Interval->>PM: tick
    PM->>Scanner: getProcessTree(pidA)
    Scanner->>OS: "ps -A (spawn 1)"
    PM->>Scanner: getProcessTree(pidB)
    Scanner->>OS: "ps -A (spawn 2)"
    PM->>Scanner: "getListeningPortsForPids(allPids)"
    Scanner->>OS: "lsof -p pids -iTCP ... (OR: all TCP listeners)"

    note over Interval,OS: After this PR — one shared read per tick
    Interval->>PM: tick
    PM->>PM: "isSessionDue check (skip idle sessions)"
    PM->>Scanner: "getProcessTreesForPids([pidA, pidB, ...])"
    Scanner->>OS: "ps -A (ONE spawn for all sessions)"
    Scanner-->>PM: "Map{pidA:[...], pidB:[...]}"
    PM->>Scanner: "getListeningPortsForPids(allPids)"
    Scanner->>OS: "lsof -a -p pids -iTCP ... (AND: only requested PIDs)"
    Scanner-->>PM: "PortInfo[]"
    PM-->>PM: "updatePortsFromScan (skipped idle sessions untouched)"
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/host-service/src/terminal/terminal.ts:1231-1232
**Idle clock not updated on empty-decoded chunks**

`checkOutputForHint` is only called when `hintText.length > 0`. A `StringDecoder` buffers partial multibyte sequences and can return an empty string when a chunk boundary lands mid-sequence, so a terminal actively emitting binary-heavy output could silently fail to refresh `lastActivityAt`. In practice this path is rare (ASCII is immediate), but it creates a subtle divergence with `daemon-manager.ts` where the clock is updated on every data event regardless of decoded length. Consider calling `portManager.checkOutputForHint(terminalId, hintText)` unconditionally (relying on the `if (session) session.lastActivityAt = ...` guard in the method itself for the activity update) and keep the port-hint scheduling conditional on non-empty text internally.

Reviews (1): Last reviewed commit: "perf(port-scanner): shared ps per scan, ..." | Re-trigger Greptile

Comment on lines +1231 to +1232
if (hintText.length > 0)
portManager.checkOutputForHint(terminalId, hintText);

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 Idle clock not updated on empty-decoded chunks

checkOutputForHint is only called when hintText.length > 0. A StringDecoder buffers partial multibyte sequences and can return an empty string when a chunk boundary lands mid-sequence, so a terminal actively emitting binary-heavy output could silently fail to refresh lastActivityAt. In practice this path is rare (ASCII is immediate), but it creates a subtle divergence with daemon-manager.ts where the clock is updated on every data event regardless of decoded length. Consider calling portManager.checkOutputForHint(terminalId, hintText) unconditionally (relying on the if (session) session.lastActivityAt = ... guard in the method itself for the activity update) and keep the port-hint scheduling conditional on non-empty text internally.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/terminal/terminal.ts
Line: 1231-1232

Comment:
**Idle clock not updated on empty-decoded chunks**

`checkOutputForHint` is only called when `hintText.length > 0`. A `StringDecoder` buffers partial multibyte sequences and can return an empty string when a chunk boundary lands mid-sequence, so a terminal actively emitting binary-heavy output could silently fail to refresh `lastActivityAt`. In practice this path is rare (ASCII is immediate), but it creates a subtle divergence with `daemon-manager.ts` where the clock is updated on every data event regardless of decoded length. Consider calling `portManager.checkOutputForHint(terminalId, hintText)` unconditionally (relying on the `if (session) session.lastActivityAt = ...` guard in the method itself for the activity update) and keep the port-hint scheduling conditional on non-empty text internally.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 89354db — the hintText.length > 0 guard is gone, so checkOutputForHint runs on every output chunk and refreshes the idle clock even when the decoder buffers a partial codepoint; hint matching on "" is a no-op. Now consistent with v1's daemon-manager.

…nd idle clock

- A forceScan arriving while a scan is in flight now keeps its
  cadence-bypass in the coalesced follow-up (forceRequested flag).
- host-service marks terminal activity even when the UTF-8 decoder
  buffers a partial codepoint and yields an empty string — the chunk is
  still real output and must refresh the idle clock.
@coderabbitai

coderabbitai Bot commented Jul 17, 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 45e1a898-be49-4c6e-bb27-75dc8b0932f0

📥 Commits

Reviewing files that changed from the base of the PR and between f05d98c and 84e0426.

📒 Files selected for processing (3)
  • packages/port-scanner/src/port-manager.test.ts
  • packages/port-scanner/src/port-manager.ts
  • packages/port-scanner/src/scanner.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/port-scanner/src/scanner.test.ts
  • packages/port-scanner/src/port-manager.test.ts
  • packages/port-scanner/src/port-manager.ts

📝 Walkthrough

Walkthrough

Port scanning now batches process-tree discovery, tracks terminal activity, skips non-due idle sessions, and supports forced scans. Terminal output passes session identifiers to hint detection, while scanner and PortManager tests cover bulk discovery, cadence, forced follow-ups, and integration behavior.

Changes

Terminal port scanning

Layer / File(s) Summary
Bulk process-tree and port scanner APIs
packages/port-scanner/src/scanner.ts, packages/port-scanner/src/index.ts, packages/port-scanner/src/scanner.test.ts
Process-tree discovery uses a bulk root-PID API, listening-port filtering uses combined selectors, and integration tests cover process trees and sockets.
Session timing and batched scan orchestration
packages/port-scanner/src/port-manager.ts
PortManager tracks activity and scan timestamps, batches due sessions, applies idle cadence rules, and preserves forced follow-up scans.
Terminal output hint attribution
apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts, packages/host-service/src/terminal/terminal.ts
Terminal output passes its terminal identifier to hint detection, including decoder chunks that produce empty text.
PortManager behavior and regression coverage
packages/port-scanner/src/port-manager.test.ts
Tests cover bulk scanner instrumentation, scoped hints, idle decay, forced scans, cadence, state retention, and teardown behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TerminalOutput
  participant PortManager
  participant ProcessScanner
  participant PortScanner
  TerminalOutput->>PortManager: checkOutputForHint(terminalId, data)
  PortManager->>PortManager: select due or forced sessions
  PortManager->>ProcessScanner: getProcessTreesForPids(rootPids)
  ProcessScanner-->>PortManager: return descendant PID map
  PortManager->>PortScanner: getListeningPortsForPids(pids)
  PortScanner-->>PortManager: return listening ports
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is conventional, concise, and accurately summarizes the main port-scanner performance changes.
Description check ✅ Passed The description covers the problem, fix, and testing, though it doesn't follow the exact template headings or include the checklist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch optimize-port-scanner-per

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/port-scanner/src/port-manager.test.ts`:
- Around line 616-631: Update the “forceScan arriving mid-scan keeps its bypass
in the coalesced follow-up” test to replace the 10ms and 100ms sleeps with a
deferred barrier in the scanner mock. Await the first scan entering
getListeningPortsForPids, invoke forceScan while it is blocked, release the
barrier, then await both periodic and forced scans deterministically.

In `@packages/port-scanner/src/port-manager.ts`:
- Around line 160-164: Update the existing-session branch in the session upsert
flow so when workspaceId changes, migrate every DetectedPort tracked for
terminalId to the new workspace and emit the corresponding port-change
notifications. Preserve the idle clock by keeping the existing SessionEntry
update behavior, and ensure getPortsByWorkspace and killPort observe the new
workspace assignment.
- Around line 320-324: In the due-session processing loop, validate that the
retrieved session entry still has the same PID as the captured pid before
updating or applying results. Discard the iteration when entry.pid !== pid;
otherwise mark the entry scanned and use entry.workspaceId, rather than the
stale destructured workspaceId, when attributing the PID tree.

In `@packages/port-scanner/src/scanner.test.ts`:
- Around line 94-103: Update the child-process setup in the “includes the root
pid and its descendants from one table read” test to use the Bun executable with
a long-lived JavaScript process instead of invoking the platform-specific sleep
command. Preserve the existing PID assertions and cleanup behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 14c46e8a-aa84-43ed-ba9b-1e609d2dc72b

📥 Commits

Reviewing files that changed from the base of the PR and between b78483b and 89354db.

📒 Files selected for processing (7)
  • apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts
  • packages/host-service/src/terminal/terminal.ts
  • packages/port-scanner/src/index.ts
  • packages/port-scanner/src/port-manager.test.ts
  • packages/port-scanner/src/port-manager.ts
  • packages/port-scanner/src/scanner.test.ts
  • packages/port-scanner/src/scanner.ts

@github-actions

github-actions Bot commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) ✅ View Branch
Vercel API (Vercel) ✅ Open Preview
Vercel Web (Vercel) ✅ Open Preview
Vercel Marketing (Vercel) ✅ Open Preview
Vercel Admin (Vercel) ✅ Open Preview
Vercel Docs (Vercel) ✅ Open Preview

Preview updates automatically with new commits

…istic tests

CodeRabbit review follow-ups:
- Skip a session whose pid changed (or that was unregistered) while the
  shared table read was in flight — the stale tree must not be
  attributed to the replacement session.
- Remove wall-clock sleeps from the force-coalesce test (isScanning is
  set synchronously; the follow-up is awaited inside the periodic
  promise) and gate the pid-swap race test on a promise.
- Use process.execPath instead of `sleep` in the tree test (win32).
@Kitenite
Kitenite merged commit 393a581 into main Jul 17, 2026
18 checks passed
@Kitenite
Kitenite deleted the optimize-port-scanner-per branch July 17, 2026 00:23

This branch was previously deployed

1 inactive deployment
preview — 84e04268 Deployed Jul 17, 2026 by Kitenite via Deploy Admin #6564
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