Skip to content

Retire stale sidebar port badges despite vanished TTYs, privileged owners, and zombies - #9324

Merged
austinywang merged 12 commits into
manaflow-ai:mainfrom
mykmelez:myk/fix-stale-port-badges
Aug 5, 2026
Merged

austinywang merged 12 commits into
manaflow-ai:mainfrom
mykmelez:myk/fix-stale-port-badges

Conversation

@mykmelez

@mykmelez mykmelez commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #9152: sidebar port badges accumulated dead ports for the lifetime of the app — a port, once discovered, could never retire.

Root causes

Three independent mechanisms each left a panel's scan permanently incomplete, and PortScanSnapshotReconciler treats incomplete scans as non-evidence that only ever unions ports:

  1. One vanished pty poisoned the whole batch. BSD ps aborts an entire batched -t query when any listed terminal device is gone, so a single closed panel made every panel's scan look incomplete.
  2. Privileged process-group leaders were unidentifiable. proc_pidinfo refuses any process whose effective UID differs from ours, so the root-owned /usr/bin/login heading every terminal filed under incompletePIDs and scored its panel incomplete on every scan.
  3. Zombies read as present but unidentifiable. An unreaped child answers kill(pid, 0) like a running process while holding no readable identity — the same incompleteness, reachable through any unreaped child.

Fixes

  • runPS drops terminals ps reports as ENOENT and retries with the rest (bounded). All terminals gone reports authoritative emptiness — a freed pty can hold no process — so closed panels' stale badges clear too. Any other diagnostic still yields incomplete, retaining ports rather than dropping them on weak evidence.
  • Birth identities read through sysctl(KERN_PROC_PID) instead of proc_pidinfo: same birth timestamp regardless of owner, still nothing for an exited PID, SZOMB rejected explicitly so session restore does not treat a dead agent as alive.
  • PIDPresence routes through the same process-table read that supplies identities, so liveness and identity cannot drift apart; a zombie reads as absent to every caller weighing whether it might still own a socket.

Commit structure

Two commits per the regression-test policy: commit 1 adds nine failing tests (CI red) that reproduce all three mechanisms against main plus an end-to-end test driving the real scanner (registerTTY → kick → coalesce → burst → reconcile → publish); commit 2 adds the fix (CI green) with four additional tests pinning behavior the fix introduces (zombie identity rejection, diagnostic forms that must not trigger the retry, retry-budget exhaustion).

Verification

  • Red/green verified locally at each commit on the current main base (9 expected failures at commit 1, 27/27 pass at commit 2).
  • Dogfooded in a tagged Debug build: port published, then a workspace opened/closed to leave a freshly-vanished pty in the scan batch, listener killed — badge retired; rediscovery of a new listener still works.
  • Change-adjacent suites (AgentSessionAutoResumeSwiftTests, SessionPersistenceResumeBindingTests, ClaudeHookPIDAuthenticationTests) fail identically on this branch and main in the local environment (pre-existing/environmental only).
  • Localization audit: N/A — no user-facing strings changed (scanner internals and tests only).

Notes

🤖 Generated with Claude Code

https://claude.ai/code/session_016pmrs1n9Z2UKquWcbXhmBe


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Retires stale sidebar port badges so ports clear when listeners stop and when a terminal panel hibernates. Fixes #9152.

  • Bug Fixes
    • Process scan: drops vanished TTYs (ENOENT) with bounded retries; treats “all gone” as complete/empty; canonicalizes TTY names (full-path and short forms).
    • Identity/presence: reads via sysctl(KERN_PROC_PID); rejects zombies; PIDPresence and Workspace.agentPIDProcessIdentity share the same reader.
    • lsof: adds -w to suppress unrelated filesystem warnings.
    • Scheduling: guarantees at least three complete-miss scans per kick; late kicks trigger a follow-up burst.
    • Hibernation: clears panel ports, recomputes, and unregisters the panel from the scanner on hibernate; discards persisted ports on restore.
    • Tests: adds unit and end-to-end coverage for vanished/unreadable TTYs, persistent lsof warnings, late-burst kicks, zombies, full-path TTY attribution, and hibernation/restore port retirement.

Written for commit e7c4253. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved process detection across different permission levels, including system-owned processes.
    • Correctly treats exited zombie processes as no longer present.
    • Made terminal and port scanning more reliable when devices disappear during a scan.
    • Prevented stale port information from persisting after processes exit or panels hibernate.
    • Improved handling of incomplete, malformed, or warning-producing scan results.
    • Improved port refresh behavior during rapid updates and terminal changes.
  • Tests

    • Added coverage for process visibility, zombie handling, terminal changes, retries, hibernation, restoration, and port removal.

mykmelez and others added 2 commits July 31, 2026 16:53
Sidebar port badges accumulate dead ports for the lifetime of the app
because three independent mechanisms each leave a panel's scan
permanently incomplete, and PortScanSnapshotReconciler treats incomplete
scans as non-evidence that only ever unions ports:

- BSD `ps` aborts an entire batched `-t` query when any listed terminal
  device is gone, so one closed pty makes every panel's port scan look
  incomplete.
- `proc_pidinfo` refuses any process whose effective UID differs from
  ours, so the root-owned `/usr/bin/login` heading every terminal reads
  as unidentifiable, files under `incompletePIDs`, and scores its panel
  incomplete on every scan.
- A zombie answers `kill(pid, 0)` like a running process while holding
  no readable identity — the same incompleteness, reachable through any
  unreaped child, though a zombie has exited and can hold no socket.

An end-to-end test also drives the real scanner — registerTTY, kick,
coalesce, burst, reconcile, publish — and asserts a port is retired once
its process stops listening, since these bugs left every stage passing
its own unit test while the feature was fully broken. Identity and
presence deliberately stay on the real providers: an earlier draft
injected them and passed against the broken tree.

Covers github.com/manaflow-ai/issues/9152.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pmrs1n9Z2UKquWcbXhmBe
One fix per mechanism the failing tests pin:

- `runPS` drops the terminals `ps` reports as ENOENT and retries with
  the rest, bounded so a pty churning mid-scan cannot spin. Terminals
  that are all gone report authoritative emptiness — a freed pty can
  hold no process — so their stale badges clear too, while any other
  diagnostic still yields incomplete, which retains ports rather than
  dropping them on weak evidence. Vanished terminals are matched by
  device name, so the two-device diagnostic form (`/dev/ttyX and
  /dev/X`) and TTYs registered by full device path are recognized, and
  "every terminal is gone" outranks the retry budget so authoritative
  emptiness does not depend on which attempt the final pty closed
  during.
- Birth identities are read through `sysctl(KERN_PROC_PID)` instead of
  `proc_pidinfo`. It reports the same birth timestamp for any live
  process regardless of owner, and still reports nothing for an exited
  PID, so recycling detection keeps working and now covers privileged
  PIDs it previously had to guess about. `SZOMB` is rejected
  explicitly: sysctl also describes an exited-but-unreaped process, and
  session restore treats a matching identity as proof the agent is
  alive.
- `PIDPresence` routes through the same process-table read that
  supplies birth identities, so liveness and identity cannot drift
  apart, and an unreaped process reads as absent to every caller
  weighing whether it might still own something.
  `Workspace.agentPIDProcessIdentity(pid:)` reads through that single
  reader as well instead of keeping its own `proc_pidinfo` copy.

The tests added here pin behavior this fix introduces rather than the
original bug: zombie identities reading as absent, diagnostic forms
that must not trigger the retry, and the retry budget staying
incomplete when a TTY never becomes scannable.

Fixes manaflow-ai#9152.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pmrs1n9Z2UKquWcbXhmBe
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change uses sysctl process-table data for process identity and zombie detection. It adds bounded retries for vanished TTY devices, suppresses lsof warnings, and updates scan-burst accounting. Tests cover process handling, scan classification, retries, hibernation cleanup, and port retirement.

Changes

Process identity and zombie handling

Layer / File(s) Summary
Process identity and presence checks
Sources/AgentPIDProcessIdentity.swift, Sources/PIDPresence.swift, Sources/Workspace+PanelLifecycle.swift, cmuxTests/PortScannerTests.swift
Process identity construction reads process-table data and rejects invalid, exited, or zombie processes. Workspace identity lookup delegates to the shared implementation. Zombie processes return .absent.
TTY scan retry and classification
Sources/PortScanner+Process.swift, cmuxTests/PortScannerTests.swift
TTY inputs are normalized and deduplicated. Confirmed vanished devices are removed before bounded retries. Scans distinguish complete empty results from malformed, unreadable, or exhausted scans. lsof suppresses warning diagnostics.
Port retirement lifecycle
Sources/PortScanner.swift, cmuxTests/PortScannerTests.swift, cmuxTests/AgentHibernationTests.swift
PortScanner retains missing ports for two incomplete scans and requires three scan attempts per kick. Lifecycle tests validate retirement after listeners stop, late-burst kicks, and panel hibernation or restoration.

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

Sequence Diagram(s)

sequenceDiagram
  participant PortScanner
  participant ps
  participant ProcessIdentity
  participant PanelSnapshotReconciler
  PortScanner->>ps: scan normalized TTYs
  ps-->>PortScanner: process output and diagnostics
  PortScanner->>ProcessIdentity: validate process identity
  ProcessIdentity-->>PortScanner: valid identity or absent process
  PortScanner->>PanelSnapshotReconciler: publish complete or incomplete scan
  PanelSnapshotReconciler-->>PortScanner: retain or retire missing ports
Loading

Possibly related issues

  • manaflow-ai/cmux-dev-artifacts#8325: Covers the same process identity, zombie detection, vanished-TTY retry, completeness, and port-retirement behavior.
  • manaflow-ai/cmux-dev-artifacts#8359: Covers port-retirement behavior and related lifecycle test coverage.
  • manaflow-ai/cmux-dev-artifacts#8328: Covers port-retirement behavior and related test coverage.
  • manaflow-ai/cmux-dev-artifacts#8117: Covers lsof-based process and port scanning.

Possibly related PRs

Suggested reviewers: austinywang, lawrencecchen


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift @Concurrent ❌ Error I/O-heavy async functions (runPS, runLsof, finishScan) lack @concurrent annotation; they spawn external processes and must indicate explicit actor boundary crossing per swift-concurrent-annotation.... Add @concurrent to async functions that perform I/O-heavy operations (runPS, runLsof, finishScan, expandAgentProcessTree, finalizeAgentPIDOwnership) per rule requirements for external process execution and file I/O from dispatch queue co...
Cmux Swift Package Boundaries ❌ Error The PR expands app-target Sources with independently testable ps/lsof parsing, TTY normalization, retry logic, and process-table logic; CmuxCore already owns port-scan domain types. Extract ps/lsof parsing, TTY normalization, and Darwin identity/PID presence into a CmuxPortScanning target exposing ProcessScanEngine first; keep scanner callbacks and Workspace lifecycle glue in the app.
Docstring Coverage ⚠️ Warning Docstring coverage is 17.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (22 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #9152 by improving scan completeness, process identity handling, retry behavior, and scan scheduling so stale ports can be removed.
Out of Scope Changes check ✅ Passed The implementation and tests remain within the linked issue scope of retiring stale sidebar port badges and correcting incomplete process scans.
Cmux Swift Actor Isolation ✅ Passed Production changes add no new actor-isolated models or UI-store crossings; existing Sendable types remain unchanged, and PortScanner’s added state stays behind its documented serial queue.
Cmux Swift Blocking Runtime ✅ Passed The production diff adds no semaphore, blocking wait, sleep, delayed-dispatch, main-queue sync, or manual-lock primitive; Task.sleep/usleep and polling occur only in tests.
Cmux Browser Automation Off-Main ✅ Passed The PR diff changes only port/process sources and tests; it adds no browser commands or changes to TerminalController, the socket-worker policy, WebKit routing, or policy tests.
Cmux Expensive Synchronous Load ✅ Passed The production diff changes process-table syscalls and port-scan scheduling only; it adds no agent-history loader, transcript/JSON parsing, directory scan, or synchronous load on an interactive path.
Cmux Cache Substitution Correctness ✅ Passed The PR changes live process and port-scan reads; it does not replace an authoritative read with a cache in persistence, history, undo, or snapshot paths.
Cmux No Hacky Sleeps ✅ Passed The PR diff changes only Swift files; runtime-no-hacky-sleeps.md scopes this check to non-Swift TypeScript, JavaScript, shell, and build/runtime scripts.
Cmux Algorithmic Complexity ✅ Passed Production changes use linear Set/dictionary joins and a fixed three-attempt ps retry; no new sorting, per-target rescans, or nested scans over the same scalable collection were added.
Cmux Swift Concurrency ✅ Passed The production diff adds no legacy async pattern: the scanner Task and DispatchQueue existed unchanged, and the only new async line awaits CommandRunning.run; test async code is explicitly allowed.
Cmux Swiftpm Lockfiles ✅ Passed The PR diff changes only Swift source and test files; it has no Package.swift, Package.resolved, Xcode project, .gitignore, workflow, or dependency changes.
Cmux Swift Logging ✅ Passed The full PR production diff adds no print, debugPrint, dump, NSLog, ad hoc logging, or Logger declarations; stdout/stderr changes only parse command results, and test fixtures are allowed.
Cmux User-Facing Error Privacy ✅ Passed Changed production code only parses or suppresses internal ps/lsof diagnostics; it adds no user-facing errors, alerts, command output, or recovery text. Diagnostic literals are confined to develope...
Cmux Full Internationalization ✅ Passed The complete PR diff changes scanner/process internals and tests only; added strings are protocol or diagnostic tokens, comments, or test fixtures, with no user-facing text or catalog/web/metadata...
Cmux Swiftui State Layout ✅ Passed PR contains no SwiftUI view changes. All modified files are model-layer and test code for process scanning, identity lookup, and TTY management. No @State, @Published, @Observable, or related Swift...
Cmux Architecture Rethink ✅ Passed The PR applies root-cause fixes with clear invariants: switching process identity to sysctl (required Darwin bridge), zombie detection, bounded TTY retry (3 attempts, no timing deps), and scan obli...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The PR changes only process-scanning, lifecycle, and test code; it adds no standalone window code or close-shortcut workaround. The auxiliary-window lint also passes.
Cmux Source Artifacts ✅ Passed All seven changed paths are tracked Swift source or test files; no artifact directories, binary files, generated logs, or copied outputs were added. /tmp strings are deliberate test fixture metad...
Cmux No Test Or Debug Seam In Production Source ✅ Passed The PR adds no DEBUG/test guard or test-named member in production Sources. New helpers have production callers, and all test scaffolding remains in cmuxTests.
Cmux No Ambient Global State ✅ Passed Production changes add no file-scope functions or mutable globals; new behavior and private state belong to AgentPIDProcessIdentity or PortScanner, and static lets are constants.
Title check ✅ Passed The title clearly summarizes the primary fix for stale sidebar port badges caused by vanished TTYs, privileged owners, and zombie processes.
Description check ✅ Passed The description explains the causes, fixes, testing, verification, and scope, although it omits the template checklist and demo video.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@Sources/PortScanner`+Process.swift:
- Around line 374-397: Update the ps invocation used by runPS and commandRunner
so its child environment explicitly sets a deterministic message locale (LC_ALL
or LC_MESSAGES) before executing /bin/ps, following the runner’s existing
environment-construction pattern. Add a focused test asserting the command
runner receives that locale override, while preserving existing environment
values and vanishedTTYNames parsing 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d72f3035-1a14-4cff-8099-54879c6f3c5e

📥 Commits

Reviewing files that changed from the base of the PR and between 820aa65 and a5dc5af.

📒 Files selected for processing (5)
  • Sources/AgentPIDProcessIdentity.swift
  • Sources/PIDPresence.swift
  • Sources/PortScanner+Process.swift
  • Sources/Workspace+PanelLifecycle.swift
  • cmuxTests/PortScannerTests.swift

Comment thread Sources/PortScanner+Process.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

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 5 files

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

Re-trigger cubic

Comment thread Sources/PortScanner+Process.swift
Comment thread Sources/PortScanner+Process.swift
Review asked to pin LC_ALL on the ps invocation because vanishedTTYNames
matches the English strerror(ENOENT) text. Darwin libc ships no localized
message catalogs, so ps emits this exact string under any locale (verified
empirically with LC_ALL=ja_JP.UTF-8); record that constraint instead of
widening the CommandRunning API for an unreachable failure mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDYSKWqKSHrr6MAuMtou9C

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
Sources/PortScanner+Process.swift (1)

340-352: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject mixed or failed ps results before returning complete emptiness.

runPS returns .complete when vanishedTTYNames removes every remaining TTY. This branch does not verify parsedEveryRow, result.executionError, result.timedOut, or whether stderr contains unrecognized diagnostics. A result with a recognized ENOENT line plus another error, malformed output, or a timeout can therefore retire ports as if the scan were authoritative. This contradicts the comment on Lines 372-373.

Make the parser report whether all diagnostics were recognized. Require a healthy, fully parsed command result before returning complete emptiness. Add regression coverage for mixed diagnostics and failed or malformed results.

Also applies to: 367-401

🤖 Prompt for 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.

In `@Sources/PortScanner`+Process.swift around lines 340 - 352, Update the runPS
result parsing around vanishedTTYNames to report whether every stderr diagnostic
was recognized, while preserving malformed-row detection via parsedEveryRow.
Before returning .complete for empty remaining TTYs, require a healthy execution
with no executionError, no timeout, fully parsed rows, and no unrecognized
diagnostics; otherwise continue returning .incomplete. Add regression coverage
for mixed diagnostics, failed or timed-out commands, and malformed output.
🤖 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.

Outside diff comments:
In `@Sources/PortScanner`+Process.swift:
- Around line 340-352: Update the runPS result parsing around vanishedTTYNames
to report whether every stderr diagnostic was recognized, while preserving
malformed-row detection via parsedEveryRow. Before returning .complete for empty
remaining TTYs, require a healthy execution with no executionError, no timeout,
fully parsed rows, and no unrecognized diagnostics; otherwise continue returning
.incomplete. Add regression coverage for mixed diagnostics, failed or timed-out
commands, and malformed output.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ae4fadf1-ac83-4245-8db2-d5635f6ecc0a

📥 Commits

Reviewing files that changed from the base of the PR and between a5dc5af and 92fa684.

📒 Files selected for processing (1)
  • Sources/PortScanner+Process.swift

Review claimed a vanished TTY could launder another terminal's failed
diagnostic into a complete scan. It cannot: non-ENOENT diagnostics never
remove a terminal from the retry set, so completeness requires either a
clean re-query or explicit ENOENT for every terminal. Pin the exact
mixed case: ENOENT for one TTY plus Permission denied for another
re-queries only the unreadable terminal and stays incomplete when its
diagnostic persists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDYSKWqKSHrr6MAuMtou9C

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 (3)
cmuxTests/PortScannerTests.swift (3)

865-885: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make unexpected scripted invocations fail with an explicit error.

ScriptedCommandRunner repeats its final result after the script is exhausted. An extra ps or lsof retry can therefore look valid. Make exhaustion fail by default, or add an explicit repeat mode only for tests that need steady-state polling.

🤖 Prompt for 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.

In `@cmuxTests/PortScannerTests.swift` around lines 865 - 885, The
ScriptedCommandRunner.run method currently reuses the final scripted result
after exhaustion, allowing unexpected invocations to pass. Change it to fail
explicitly when recordedArguments exceeds results, or add an opt-in repeat mode
while keeping strict exhaustion as the default; preserve repeating behavior only
for tests that explicitly request it.

111-121: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the root-owned identity test prove the fallback path.

The test only checks that AgentPIDProcessIdentity(pid: 1) returns a value. It does not verify that proc_pidinfo is unavailable. If the test target runs as root, the old lookup can also pass this test. Run this case as an unprivileged user and assert the old lookup is denied, or obtain a process owned by another user before testing the sysctl fallback.

🤖 Prompt for 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.

In `@cmuxTests/PortScannerTests.swift` around lines 111 - 121, Update
otherUsersProcessHasReadableIdentity to explicitly verify the unprivileged
fallback scenario: ensure the test runs without root privileges and assert the
direct proc_pidinfo lookup for the selected root-owned process is denied before
validating AgentPIDProcessIdentity(pid: 1). If root execution cannot be
controlled, select a process owned by another user and assert the old lookup
fails while the sysctl-backed identity succeeds.

120-145: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Import Darwin for the POSIX Darwin symbols.

cmuxTests/PortScannerTests.swift uses getpid, posix_spawn, waitpid, environ, usleep, sysctl, kinfo_proc, SZOMB, and pid_t, but only imports framework modules. Add import Darwin so these tests compile without depending on an accidental re-export.

🤖 Prompt for 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.

In `@cmuxTests/PortScannerTests.swift` around lines 120 - 145, Add import Darwin
to PortScannerTests.swift so the POSIX and Darwin symbols used by
zombieProcessHasNoReadableIdentity and the surrounding process identity
tests—such as getpid, posix_spawn, waitpid, environ, usleep, sysctl, kinfo_proc,
SZOMB, and pid_t—are explicitly available.
🤖 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.

Outside diff comments:
In `@cmuxTests/PortScannerTests.swift`:
- Around line 865-885: The ScriptedCommandRunner.run method currently reuses the
final scripted result after exhaustion, allowing unexpected invocations to pass.
Change it to fail explicitly when recordedArguments exceeds results, or add an
opt-in repeat mode while keeping strict exhaustion as the default; preserve
repeating behavior only for tests that explicitly request it.
- Around line 111-121: Update otherUsersProcessHasReadableIdentity to explicitly
verify the unprivileged fallback scenario: ensure the test runs without root
privileges and assert the direct proc_pidinfo lookup for the selected root-owned
process is denied before validating AgentPIDProcessIdentity(pid: 1). If root
execution cannot be controlled, select a process owned by another user and
assert the old lookup fails while the sysctl-backed identity succeeds.
- Around line 120-145: Add import Darwin to PortScannerTests.swift so the POSIX
and Darwin symbols used by zombieProcessHasNoReadableIdentity and the
surrounding process identity tests—such as getpid, posix_spawn, waitpid,
environ, usleep, sysctl, kinfo_proc, SZOMB, and pid_t—are explicitly available.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 01ecb718-3f45-49a9-8082-8bd566862c4f

📥 Commits

Reviewing files that changed from the base of the PR and between 92fa684 and 15ca94f.

📒 Files selected for processing (1)
  • cmuxTests/PortScannerTests.swift

@mykmelez

mykmelez commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai Regarding the outside-diff finding on runPS ("Reject mixed or failed ps results before returning complete emptiness"): the .complete verdict on the all-vanished path rests solely on every requested terminal being explicitly named in an ENOENT diagnostic — not on stdout or overall process health — so none of the listed conditions can produce a false complete:

  • executionError: a launch or cancellation failure returns stderr: nil (CommandRunner.swift), so no ENOENT lines can exist, vanished stays empty, and the scan returns .incomplete.
  • timedOut: a kill mid-write can only omit ENOENT lines. Any terminal without one stays in remaining, so the scan retries or returns .incomplete — and ps never emits ENOENT for a live terminal, so the lines that did arrive are genuine per-terminal stat evidence.
  • Unrecognized diagnostics: a non-ENOENT diagnostic never removes a terminal from remaining, so it blocks the all-vanished branch. Pinned by mixedVanishedAndUnreadableDiagnosticsStayIncomplete (15ca94f): ENOENT for one TTY plus Permission denied for another stays .incomplete.
  • parsedEveryRow: BSD ps stats -t devices during option processing and aborts before emitting any rows when one is missing, so the all-vanished case coexists with empty stdout. The branch discards mapping regardless — emptiness follows from the freed ptys, not from stdout parsing.

The comment on lines 372-373 describes exactly this: only ENOENT is treated as absence; every other diagnostic leaves the scan incomplete. No change made.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a -t f 'PortScanner\+Process\.swift|CommandRunner\.swift|PortScannerTests\.swift' . \
  | sort \
  | while IFS= read -r f; do
      echo "===== OUTLINE: $f ====="
      ast-grep outline "$f" --items all --view compact || true
    done

echo "===== runPS and nearby logic ====="
rg -n -C 18 '\brunPS\b|vanished|parsedEveryRow|remaining|ENOENT|\.complete|\.incomplete' Sources cmuxTests \
  -g 'PortScanner+Process.swift' -g 'CommandRunner.swift' -g 'PortScannerTests.swift'

echo "===== cited regression test ====="
rg -n -A 70 -B 12 '\bmixedVanishedAndUnreadableDiagnosticsStayIncomplete\b' cmuxTests -g 'PortScannerTests.swift'

Length of output: 103424


@mykmelez Confirmed. I withdraw the finding.

runPS returns all-vanished .complete only after vanishedTTYNames identifies every requested TTY from an ENOENT diagnostic. This path does not use stdout, parsedEveryRow, or the normal isCompletePSResult verdict.

A non-ENOENT diagnostic does not remove its TTY from remaining. The cited mixedVanishedAndUnreadableDiagnosticsStayIncomplete test verifies this behavior. A timeout or execution failure without qualifying ENOENT diagnostics also remains .incomplete.

The all-vanished result is authoritative negative evidence for the queried TTY set.

🐇


✏️ Learnings added
Learnt from: mykmelez
URL: https://github.com/manaflow-ai/cmux/pull/9324

Timestamp: 2026-08-03T03:03:02.803Z
Learning: In `Sources/PortScanner+Process.swift`, `PortScanner.runPS(ttyList:)` can return `([:], .complete)` after `ps` reports ENOENT for every requested TTY. This result is authoritative because each freed pty cannot have attached processes. The branch does not depend on stdout parsing or overall command success. Only requested TTYs explicitly identified by `vanishedTTYNames(inStderr:requested:)` as `": No such file or directory"` are removed; all other diagnostics retain `.incomplete`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Issue manaflow-ai#9152 reports a Time Machine filesystem warning on every lsof invocation. Model that warning in the full port lifecycle harness and prove PID-scoped TCP evidence must remain authoritative, while tightening the existing process-identity and retry test seams.

This is the tests-only red half of the regression pair.
Pass -w to the PID-scoped TCP listener query so unrelated filesystem mount warnings cannot poison every scan. Actual command failures, timeouts, malformed output, and PID-scoped uncertainty remain incomplete.

This is the green half for the persistent-warning regression in 6b54e60.
A single kick near the end of an existing scan burst can receive fewer than the three authoritative misses required to retire a stale port. Pin that idle-workspace timing path before changing the scheduler.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
Sources/PortScanner+Process.swift (1)

334-334: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Normalize live TTY identities before joining scan results.

A panel can register /dev/ttys001, but ps -o tty= returns ttys001. Line 334 preserves the latter form. Later exact comparisons against the registered TTY then find no panel PIDs or ports.

The new test at cmuxTests/PortScannerTests.swift Lines 476-505 covers only the vanished-device case. Add coverage for a live full-path TTY. Use one canonical device name when building pidsByTTY, calculating panel completeness, and joining portsByTTY.

As per path instructions, pane identity must use one reliable structured source instead of disagreeing representations. <path_instructions>

🤖 Prompt for 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.

In `@Sources/PortScanner`+Process.swift at line 334, Normalize the TTY value
assigned in the process-mapping logic at mapping[pid] so live ps values such as
ttys001 use the same canonical device representation as registered panel TTYs,
such as /dev/ttys001. Apply that canonical name consistently when building
pidsByTTY, calculating panel completeness, and joining portsByTTY. Extend
PortScannerTests with coverage for a live full-path TTY, not only a vanished
device.

Source: Path instructions

🤖 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 `@cmuxTests/PortScannerTests.swift`:
- Line 1041: Update waitForLsofInvocation and its call sites around lines 1041
and 1119-1124 so waiting for lsofInvocationCount is deadline-bounded and fails
when the target count is not reached. Replace the indefinitely suspended
continuation-dictionary approach with a bounded poll of the real
invocation-count predicate, or add equivalent cancellation and timeout handling
to every waiter, and require the bounded wait to complete successfully at the
affected test points.

---

Outside diff comments:
In `@Sources/PortScanner`+Process.swift:
- Line 334: Normalize the TTY value assigned in the process-mapping logic at
mapping[pid] so live ps values such as ttys001 use the same canonical device
representation as registered panel TTYs, such as /dev/ttys001. Apply that
canonical name consistently when building pidsByTTY, calculating panel
completeness, and joining portsByTTY. Extend PortScannerTests with coverage for
a live full-path TTY, not only a vanished device.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a2c5c020-9624-4c18-aea3-4619108fb663

📥 Commits

Reviewing files that changed from the base of the PR and between 15ca94f and f6b382a.

📒 Files selected for processing (3)
  • Sources/PortScanner+Process.swift
  • Sources/PortScanner.swift
  • cmuxTests/PortScannerTests.swift

Comment thread cmuxTests/PortScannerTests.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@cmuxTests/AgentHibernationTests.swift`:
- Around line 948-960: Update Workspace.applySessionPanelMetadata so snapshots
with terminal?.hibernation != nil remove the target panel from
surfaceListeningPorts instead of restoring snapshot.listeningPorts. Preserve the
existing persisted-port restoration behavior for non-hibernated panels so
recomputeListeningPorts excludes stale ports and badges after restore.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3bede7d6-f8ef-4062-b8a0-06fee7d5858a

📥 Commits

Reviewing files that changed from the base of the PR and between f6b382a and b554509.

📒 Files selected for processing (4)
  • Sources/PortScanner+Process.swift
  • Sources/PortScanner.swift
  • cmuxTests/AgentHibernationTests.swift
  • cmuxTests/PortScannerTests.swift

Comment thread cmuxTests/AgentHibernationTests.swift
@austinywang

austinywang commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Adversarial maintainer verification

Verdict: fixes #9152.

I traced the full local path (report_tty / ports_kick → batched ps → identity capture → lsof → per-panel completeness → PortScanSnapshotReconciler → lifecycle-gated publication) and confirmed all three root causes claimed in the PR:

  1. A vanished TTY poisons BSD ps -t batches. On macOS 26.4.1, a live -t ttys072 query returned its processes, while the same query with ttys9152 appended exited 1, emitted only ps: /dev/ttys9152: No such file or directory, and returned none of the live TTY's rows. The test-only commit fails the retry/authoritative-empty expectations; the fix passes them.
  2. proc_pidinfo cannot identify privileged leaders. As an unprivileged user, proc_pidinfo(1, PROC_PIDTBSDINFO, ...) returned 0/136 bytes, while sysctl(KERN_PROC_PID) returned launchd's PID and birth timestamp. That is the same ownership boundary as the root-owned login/session leader in a terminal process group. The shared sysctl identity reader removes the permanent incompletePIDs classification without weakening PID-reuse fencing.
  3. Zombies are present to kill(pid, 0) but own nothing. A live macOS 26 check left /usr/bin/true unreaped: p_stat == SZOMB, kill(pid, 0) == 0, and proc_pidinfo still returned no identity. The new reader explicitly rejects SZOMB, and PIDPresence therefore treats it as absent. The behavioral test pins the resulting complete negative port evidence.

Additional gaps found and fixed

I appended four test-first/fix pairs, preserving the contributor's approach:

  • 6b54e60f76 / 9f88b6d71a: the reporter's persistent Time Machine SMB warning made every otherwise-valid lsof result globally incomplete. The PID-scoped TCP query now uses lsof -w; real errors remain incomplete.
  • 9fcfb5f498 / f6b382aaee: a kick near the end of an existing burst could receive only one later scan, but retirement requires three complete misses. Every kick now remains pending until three scan attempts have begun; a follow-up burst pays any remainder. Normal retirement is about 3.2 seconds after coalescing, rather than unbounded.
  • 54854e33a4 / 4636dd9353: shells report /dev/ttys…, while ps -o tty= reports ttys…. Canonicalizing both sides fixes attribution as well as vanished-TTY matching.
  • b554509c73 / e7c4253c71: hibernation destroys the PTY but previously retained surfaceListeningPorts, allowed an older queued publication to republish them, and persisted them into a snapshot that has no shell left to kick the scanner. Successful local hibernation now clears/recomputes ports and invalidates the scanner lifecycle; restored hibernated snapshots discard legacy persisted ports.

The corrected hibernation red run is important: the earlier method-filtered runs selected zero Swift Testing tests and were not valid evidence. The class-level test-only run executed 36 tests and failed only the two new regressions (six concrete expectations); the final-head run executed the same 36 and passed.

Safety, race, and performance checks

  • No badge flapping: incomplete scans still never remove a known port. Two consecutive complete misses retain it; the third retires it. Positive observations remain immediate. Timeout, malformed output, unrecognized ps diagnostics, and real lsof errors stay incomplete.
  • Lifecycle races: panel close, TTY replacement, and hibernation invalidate the MainActor lifecycle revision before queued scanner work can publish. Queue-side TTY/revision checks and the final PortScanPublicationState filter reject stale in-flight results.
  • Scale: the vanished-TTY fallback is at most three batched ps calls, not one scan per TTY. Joins remain linear set/map work. Scanner/process work remains off the MainActor; no sidebar rendering path or keystroke-sensitive path changed.
  • Persistence/relaunch: ordinary restored terminals spawn a fresh shell/TTY and report into a new scanner lifecycle. The uncovered no-future-kick case was a hibernated terminal; both live hibernation and legacy restored snapshots are now covered.
  • Remote/SSH scope: remote workspaces use a separate marker-based script in CmuxRemoteSession; its BSD-ps/lsof fallback still deserves the same hardening in a follow-up. It is not the path in Sidebar port badges never clear: stopped servers stay listed indefinitely and accumulate #9152's local workspace create --cwd /tmp/... repro, so it does not block this verdict, but this PR should not be cited as remote-port parity.

I did not take the automated suggestion to extract a small CmuxCore process package. CmuxCore is deliberately pure values/protocol seams; Darwin process-table and subprocess I/O are infrastructure. A tiny partial port-scanning package would violate the repository's whole-domain package policy and is unrelated to this bug fix. I also did not add annotation-only @concurrent changes to the existing non-actor-isolated Swift-5 app-target methods: they execute on the generic executor, and CommandExecution explicitly bridges blocking descriptor reads to utility queues; this diff adds no MainActor process work.

Red/green and final validation

The explicit macOS-26 test-e2e jobs did not reach test discovery: Xcode failed the app-host build with duplicate Sentry framework copy tasks on both attempts (retirement, process capture); the macos-latest/Xcode 27 image failed the same way. I did not count those as green.

I then bypassed that unrelated app-host path with an isolated SwiftPM executable harness on this macOS 26.4.1 host. The harness compiled the branch exact PortScanner+Process.swift, AgentPIDProcessIdentity.swift, PIDPresence.swift, and PortLsofScanResult.swift together with the real CmuxCore and CmuxFoundation packages. Against live processes, it recovered the harness PID from a mixed live/vanished TTY batch, joined /dev/ttys… registration to abbreviated ps output, captured PID 1 without an incomplete classification, rejected an unreaped zombie as absent, found a real listener through runLsof, then observed complete emptiness after close. The real PortScanSnapshotReconciler retained that port across an incomplete miss and the first two complete misses, then retired it on the third. The temporary harness was removed and was not committed. This adds executable macOS 26 coverage of the OS-sensitive production implementations, while the linked Xcode 26.3 CI runs remain the evidence for the app-host scheduling and hibernation paths.

git diff --check, test wiring (644 files), package-lock policy, workspace package grouping, and pbxproj normalization all pass. The current origin/main merge-tree is conflict-free; PR checks roll up SUCCESS, there are no unresolved review threads, and GitHub reports MERGEABLE. Localization audit: N/A — no user-facing strings changed.

Verdict: fixes #9152.

@austinywang
austinywang merged commit ec0cd33 into manaflow-ai:main Aug 5, 2026
6 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.

Sidebar port badges never clear: stopped servers stay listed indefinitely and accumulate

2 participants