Skip to content

Keep remote workspace ports across transient empty scans - #7245

Closed
m2na7 wants to merge 2 commits into
manaflow-ai:mainfrom
m2na7:fix/remote-port-scan-retention
Closed

m2na7 wants to merge 2 commits into
manaflow-ai:mainfrom
m2na7:fix/remote-port-scan-retention

Conversation

@m2na7

@m2na7 m2na7 commented Jul 3, 2026 •

Copy link
Copy Markdown

Fixes #7244.

Summary

  • What: A remote SSH workspace's sidebar port badge (e.g. :3001) flickers — it disappears and reappears every few seconds — and because the ports row only occupies height when the port set is non-empty, every disappearance collapses the row and shifts the sibling rows down/up.
  • Why: The TTY-scoped port scan treats an empty-but-successful scan as authoritative "no ports" and immediately drops the port, even though the server is still listening. This keeps the last-known ports across a bounded number of consecutive empty scans so a transient miss no longer collapses the row, while a port that has genuinely stopped still clears.

The bug

With a remote SSH workspace running a dev server (a listening port under the session shell), the sidebar port badge flickers: :3001 shows, vanishes, shows again, repeatedly. The ports row is only laid out when the port list is non-empty (Sources/ContentView.swift, the // Ports row block gated on !listeningPorts.isEmpty), so each flicker removes and re-adds the row and its spacing, visibly shifting every row beneath it. The flicker is worst while the workspace is actively working (the agent is spawning subprocesses) and settles when activity calms.

Root cause

RemoteSessionCoordinator discovers listening ports per panel by attributing each listening socket to a tracked TTY through the emitting process's controlling terminal (readlink /proc/<pid>/fd/0, with an lsof fallback keyed by ps -t) — see remotePortScanScript in RemoteSessionCoordinator+PortScan.swift. That attribution is best-effort and races: a dev server frequently listens from a child process whose fd/0 is not the shell's TTY (redirected, detached, or reparented), and ss's pid= visibility and the process tree churn under load. So a scan can succeed yet report no port for a panel whose server is still listening.

performRemotePortScanLocked then replaces the per-panel port map wholesale with that scan result:

remoteScannedPortsByPanel = try scanRemotePortsByPanelLocked(ttyNamesByPanel: ttyNamesByPanel)
polledRemotePorts = []
publishPortsSnapshotLocked()

publishPortsSnapshotLocked publishes the union of remoteScannedPortsByPanel and polledRemotePorts, so when the scan comes back empty the published set is empty and the badge disappears until the next scan re-attributes the port. Notably, only an ssh failure (the catch) preserves the previous ports; an empty success clears them. The scan bursts on shell activity and polls every few seconds, so any single empty pass is republished immediately as a flicker.

The fix

Reconcile a fresh scan against the last-known ports instead of overwriting: a panel that scans non-empty adopts the fresh ports; a panel that scans empty keeps its previous ports until it has been empty more than remotePortScanEmptyRetentionLimit (2) scans in a row, then clears. This absorbs the attribution's transient misses (no flicker) while a port that stays gone past the limit still clears within a couple of scan cycles. The retention counter is cleared alongside the existing port teardown (scanner suspend and the empty-TTY branch), so a disabled or re-armed scanner starts clean.

The change is confined to the TTY-scoped scan path; the host-wide/delta poll modes, the settings gating (#6136), and connection-establishing ssh are untouched.

Testing

swift test --package-path Packages/macOS/CmuxRemoteSession — 42 tests pass.

New RemotePortScanRetentionTests drives the queue-confined performRemotePortScanLocked through a scripted process runner (scripts the ssh scan stdout: "ttys010\t3001\n", then "") and a recording host that captures the published port snapshot — fully deterministic, no wall-clock waits:

  • a scan that finds 3001 followed by one empty scan keeps 3001 (the flicker regression);
  • a scan followed by empty scans past the retention limit clears the port (a genuinely stopped server still drops).

The same regression test fails before the reconciliation and passes after it:

Before (wholesale replace — one empty scan drops the still-listening port):

✘ Test "A transient empty scan keeps a still-listening port": Expectation failed: (host.lastDetected → []) == [3001]
± removed [3001]
↳ a single empty scan must not drop a still-listening port
✘ Test run with 2 tests in 1 suite failed with 1 issue.

After (reconcile retains the port across the transient empty scan):

✔ Test "A transient empty scan keeps a still-listening port" passed.
✔ Test "A port absent past the retention limit is dropped" passed.
✔ Test run with 2 tests in 1 suite passed.

A demo video isn't included because the flicker is a timing-dependent detection race on a remote host rather than a deterministic UI action; the two tests reproduce the drop and its fix deterministically instead.

Scope notes

  • remotePortScanEmptyRetentionLimit = 2 trades up to ~2 extra scan cycles of a stale badge after a server genuinely stops for the elimination of the flicker. It is a named constant so the trade-off is easy to tune.
  • I deliberately did not reserve space / hold last ports at the view layer: the sidebar intentionally applies row changes in one discrete layout pass (no value animation), and masking the collapse there wouldn't fix the port disappearing from the published snapshot.

Review Trigger (Copy/Paste as PR comment)

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

Checklist

  • I tested the change locally (swift test --package-path Packages/macOS/CmuxRemoteSession, 42 tests)
  • I added or updated tests for behavior changes (RemotePortScanRetentionTests)
  • I updated docs/changelog if needed (refreshed swift-file-length-budget.tsv for the two touched files)
  • I requested bot reviews after my latest commit (copy/paste block above or equivalent)
  • All code review bot comments are resolved
  • All human review comments are resolved

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Stops the sidebar port badge from flickering in remote SSH workspaces by keeping last-known ports across transient empty TTY scans. Ports that are truly gone still clear after a short threshold.

  • Bug Fixes
    • Reconcile TTY-scoped scan results with previous state instead of replacing them.
    • Retain last-known ports across up to 2 consecutive empty scans; clear after the limit.
    • Clear miss counters on all teardown paths and when no TTYs are tracked; keep counters in sync with the port map.
    • Scope limited to TTY-based scans; polling and host-wide paths are unchanged.
    • Expanded RemotePortScanRetentionTests to cover teardown clearing and transient-miss retention.

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

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved remote port tracking by retaining previously detected listening ports through short-lived “empty/missed” scan gaps.
    • Ports are now cleared only after exceeding a consecutive-empty retention threshold, and retained tracking is properly reset on session shutdown or error.
  • Tests

    • Added automated coverage to ensure single-miss retention, eventual cleanup after repeated empty scans, and correct state clearing on teardown.

@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

@m2na7 is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@m2na7

m2na7 commented Jul 3, 2026

Copy link
Copy Markdown
Author

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds per-panel retention for remote port scans so transient empty TTY attribution does not immediately clear previously detected ports, and adds tests for the retention limit and reset paths.

Changes

Port Scan Retention

Layer / File(s) Summary
Coordinator state for empty-miss tracking
Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift
Adds remotePortScanEmptyMissByPanel and clears it when remote-port scan state resets during teardown or proxy error handling.
Retention limit and reconciliation logic
Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift
Introduces remotePortScanEmptyRetentionLimit, clears empty-miss tracking on scan suspension and no-tracked-TTY paths, and reconciles scanned ports through retainingTransientEmptyScansLocked before publishing them.
Retention behavior tests and stubs
Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanRetentionTests.swift
Adds deterministic tests for transient empty scans, sustained empty scans past the retention limit, and teardown clearing retained scan state, plus coordinator test doubles used to drive the scan path.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PortScanTask
  participant RemoteSessionCoordinator
  participant RemoteSessionHost

  PortScanTask->>RemoteSessionCoordinator: performRemotePortScanLocked()
  RemoteSessionCoordinator->>RemoteSessionCoordinator: scan ports per panel from TTY attribution
  RemoteSessionCoordinator->>RemoteSessionCoordinator: retainingTransientEmptyScansLocked(results)
  alt empty scan under retention limit
    RemoteSessionCoordinator->>RemoteSessionCoordinator: keep previous ports and increment miss count
  else empty scan over retention limit
    RemoteSessionCoordinator->>RemoteSessionCoordinator: clear ports for panel
  else non-empty scan
    RemoteSessionCoordinator->>RemoteSessionCoordinator: reset miss count and update ports
  end
  RemoteSessionCoordinator->>RemoteSessionHost: publish reconciled ports snapshot
Loading

Related issues: #7244

Suggested reviewers: N/A

🚥 Pre-merge checks | ✅ 25
✅ Passed checks (25 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code and tests address #7244 by retaining ports across transient empty TTY scans and clearing them after the retention limit.
Out of Scope Changes check ✅ Passed The shown changes stay focused on the port-retention fix and its tests, with no clear unrelated code changes in scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Cmux Swift Actor Isolation ✅ Passed PASS: The new production state stays queue-confined in the existing @unchecked Sendable coordinator; broader @MainActor changes are on UI-bound types with matching conformers.
Cmux Swift Blocking Runtime ✅ Passed Diff adds retention bookkeeping and deterministic test scaffolding only; no new production semaphores, sleeps, asyncAfter, main-queue sync, or locks were introduced.
Cmux Browser Automation Off-Main ✅ Passed PR only changes remote-session port-scan retention and tests; it does not touch browser automation routing or WebKit/AppKit paths covered by the rule.
Cmux Expensive Synchronous Load ✅ Passed Diff only changes remote port-scan retention and tests; no agent-history loads, main-actor hops, or JSON/transcript parsing were added.
Cmux Cache Substitution Correctness ✅ Passed Bounded in-memory retention for a live sidebar snapshot; cold/stale misses are handled and teardown clears the state, so no durable cache swap.
Cmux No Hacky Sleeps ✅ Passed Touched files are Swift + TSV only; the patch adds deterministic retention logic/tests and no new sleeps, timers, delays, or wall-clock waits.
Cmux Algorithmic Complexity ✅ Passed PASS: The new retention helper is a single pass over scanned panels with O(1) dictionary lookups; no nested rescans or added sort/filter hot-path work.
Cmux Swift Concurrency ✅ Passed Diff only adds port-retention state/logic and XCTest stubs; no new legacy DispatchQueue/Combine/completion-handler/fire-and-forget Task pattern is introduced.
Cmux Swift @Concurrent ✅ Passed No changed Swift file adds async/nonisolated functions or @concurrent annotations; the new port-scan work stays queue-confined and synchronous.
Cmux Swift File And Package Boundaries ✅ Passed The change stays in the CmuxRemoteSession package, adds a small focused fix to existing large port-scan/state files, and adds package-target tests.
Cmux Swiftpm Lockfiles ✅ Passed Diff only changes Swift source/test files; no .gitignore, Package.resolved, Package.swift, or Xcode package-reference paths are present.
Cmux Swift Logging ✅ Passed Diff adds only retention state/comments and tests; no new print/debugPrint/dump/NSLog/Logger calls or file-scoped logger changes.
Cmux User-Facing Error Privacy ✅ Passed The PR only adds internal port-retention state/logic, clears counters on teardown, and test-only code; no new user-facing error or alert text was introduced.
Cmux Full Internationalization ✅ Passed Only queue-confined port-scan logic, comments, and tests changed; no new user-facing localized text, catalogs, or web messages were added.
Cmux Swiftui State Layout ✅ Passed No new SwiftUI state/layout patterns: only queue-confined coordinator logic and tests changed; no ObservableObject, GeometryReader, or render-time writes found.
Cmux Architecture Rethink ✅ Passed PASS: The change is a small queue-confined correctness fix; the new miss counter is owned by the coordinator, cleared on teardown/error, and tests use only test-only sync.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PASS: The PR only changes remote-session port-scan logic and tests; no NSWindow/NSPanel/WindowGroup or cmuxAuxiliaryWindowIdentifiers code is introduced or modified.
Cmux Source Artifacts ✅ Passed Changed paths are intentional source/config files; the TSV is a tracked CI budget file referenced by workflows, not a scratch or build artifact.
Cmux No Test Or Debug Seam In Production Source ✅ Passed Production changes add real retention state/logic only; no new #if DEBUG or debug*/Test* seam appears, and tests observe internals via @testable import.
Cmux No Ambient Global State ✅ Passed The diff adds instance-scoped state/methods on RemoteSessionCoordinator and private test helpers; no new file-scope API, singleton, or static-namespace surface appears.
Title check ✅ Passed The title is concise and accurately summarizes the main change: retaining remote workspace ports across transient empty scans.
Description check ✅ Passed The description covers the template sections, explains the change and testing, includes review triggers, and reasonably notes the missing 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 commented Jul 3, 2026 •

Copy link
Copy Markdown

@m2na7 Got it, I'll review the PR now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 3, 2026

Copy link
Copy Markdown

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@m2na7 I have started the AI code review. It will take a few minutes to complete.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@greptile-apps

greptile-apps Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a sidebar port-badge flicker in remote SSH workspaces by retaining last-known ports across bounded consecutive empty TTY scans. The TTY-scoped attribution races under load and can return an empty-but-successful scan for a still-listening port, causing the badge to disappear and shift surrounding rows; the fix reconciles each fresh scan against the previous state instead of overwriting it wholesale.

  • Core logic (RemoteSessionCoordinator+PortScan.swift): introduces retainingTransientEmptyScansLocked to reconcile scan results with the previous state; panels with non-empty scans adopt the fresh result immediately; panels with empty scans retain their previous ports until they have been empty more than remotePortScanEmptyRetentionLimit (2) consecutive times.
  • State management (RemoteSessionCoordinator.swift): adds remotePortScanEmptyMissByPanel as a companion to remoteScannedPortsByPanel; both fields now include invariant documentation and are cleared together on every teardown path.
  • Tests (RemotePortScanRetentionTests.swift): adds three deterministic tests using a scripted process runner — transient miss retention, post-limit clearing, and teardown consistency — all without wall-clock waits.

Confidence Score: 5/5

Safe to merge. The change is tightly scoped to the TTY-scoped scan path and all four teardown paths now correctly clear both companion dictionaries in lockstep.

The reconciliation logic in retainingTransientEmptyScansLocked is correct: non-empty scans reset the miss counter implicitly via wholesale replacement of remotePortScanEmptyMissByPanel, the retention limit expires cleanly on the remotePortScanEmptyRetentionLimit + 1th consecutive empty scan, and stale miss counts for panels removed by TTY changes are naturally evicted on the next scan. All previously flagged teardown gaps (stopAllLocked and the proxy-error branch) are now addressed, and the three new deterministic tests cover the regression, expiry, and teardown invariant cases.

No files require special attention.

Important Files Changed

Filename Overview
Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift Adds remotePortScanEmptyRetentionLimit constant and retainingTransientEmptyScansLocked reconciliation function; logic is correct and retention limit expires cleanly.
Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift Declares remotePortScanEmptyMissByPanel with invariant documentation; adds removeAll() on previously-missed teardown paths (stopAllLocked and proxy-error branch).
Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanRetentionTests.swift Adds three deterministic regression tests covering transient retention, post-limit eviction, and teardown clearing; all stubs confined to the test target.
.github/swift-file-length-budget.tsv Line budgets updated to reflect actual post-change sizes for both touched production files.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[performRemotePortScanLocked] --> B{ttyNames empty?}
    B -- yes --> C[clear both dicts, publish empty]
    B -- no --> D[scanRemotePortsByPanelLocked]
    D -- throws --> E[log error, leave state intact]
    D -- scanned --> F[retainingTransientEmptyScansLocked]
    F --> G{ports non-empty?}
    G -- yes --> H[adopt fresh ports]
    G -- no --> I{previous non-empty AND missCount <= limit?}
    I -- yes --> J[retain previous, increment miss]
    I -- no --> K[omit panel, evict miss]
    H --> L[remotePortScanEmptyMissByPanel = misses]
    J --> L
    K --> L
    L --> M[remoteScannedPortsByPanel = reconciled]
    M --> N[publishPortsSnapshotLocked]
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"}}}%%
flowchart TD
    A[performRemotePortScanLocked] --> B{ttyNames empty?}
    B -- yes --> C[clear both dicts, publish empty]
    B -- no --> D[scanRemotePortsByPanelLocked]
    D -- throws --> E[log error, leave state intact]
    D -- scanned --> F[retainingTransientEmptyScansLocked]
    F --> G{ports non-empty?}
    G -- yes --> H[adopt fresh ports]
    G -- no --> I{previous non-empty AND missCount <= limit?}
    I -- yes --> J[retain previous, increment miss]
    I -- no --> K[omit panel, evict miss]
    H --> L[remotePortScanEmptyMissByPanel = misses]
    J --> L
    K --> L
    L --> M[remoteScannedPortsByPanel = reconciled]
    M --> N[publishPortsSnapshotLocked]
Loading

Reviews (5): Last reviewed commit: "Clear the port-scan miss counter on ever..." | Re-trigger Greptile

Comment on lines +250 to +265
private func retainingTransientEmptyScansLocked(_ scanned: [UUID: [Int]]) -> [UUID: [Int]] {
var reconciled: [UUID: [Int]] = [:]
var misses: [UUID: Int] = [:]
for (panelId, ports) in scanned {
if !ports.isEmpty {
reconciled[panelId] = ports
continue
}
let previous = remoteScannedPortsByPanel[panelId] ?? []
let missCount = (remotePortScanEmptyMissByPanel[panelId] ?? 0) + 1
guard !previous.isEmpty, missCount <= Self.remotePortScanEmptyRetentionLimit else { continue }
reconciled[panelId] = previous
misses[panelId] = missCount
}
remotePortScanEmptyMissByPanel = misses
return reconciled

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 Timing-repair cache leaves attribution miss representable

remotePortScanEmptyMissByPanel is a new retention counter that makes remoteScannedPortsByPanel deliberately stale for up to remotePortScanEmptyRetentionLimit cycles — the invariant "published ports reflect the latest scan" is now intentionally broken. Per the architectural rule this is a timing repair that patches the symptom (flickering badge) by adding a new side-channel cache, while the TTY attribution race in remotePortScanScript (child process fd/0 not matching the shell TTY under load) remains in place as the root cause. The fix is bounded and the constant is named, which mitigates the risk, but future readers maintaining the scan logic will need to account for the fact that remoteScannedPortsByPanel is no longer a faithful view of the last scan. Consider leaving an explicit invariant comment on both state fields noting that remoteScannedPortsByPanel may lag the last physical scan by up to remotePortScanEmptyRetentionLimit cycles, and that remotePortScanEmptyMissByPanel must be kept consistent with it at every mutation site.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

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

@greptile-apps

greptile-apps Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes sidebar port-badge flickering in remote SSH workspaces by changing the TTY-scoped port scan from a wholesale-replace to a reconcile-and-retain strategy: panels that report an empty scan keep their last-known ports for up to remotePortScanEmptyRetentionLimit (2) consecutive empty scans before clearing, absorbing the best-effort TTY attribution's transient misses without masking genuinely stopped servers.

  • Core change (RemoteSessionCoordinator+PortScan.swift): performRemotePortScanLocked now calls retainingTransientEmptyScansLocked instead of assigning the raw scan result; the new function reconciles per-panel, tracking consecutive misses in remotePortScanEmptyMissByPanel and retaining previous ports until the limit is exceeded.
  • State (RemoteSessionCoordinator.swift): adds remotePortScanEmptyMissByPanel: [UUID: Int], cleared in suspendRemotePortScanningLocked and the empty-TTY early-return; the proxy-error teardown and stopAllLocked omit the clear (harmless due to the previous.isEmpty guard in the reconcile loop, but inconsistent).
  • Tests (RemotePortScanRetentionTests.swift): two deterministic cases — transient miss retains the port, sustained absence past the limit clears it — using ScriptedProcessRunner and @testable import with no wall-clock waits.

Confidence Score: 4/5

Safe to merge; the reconcile logic is correct and the new tests deterministically cover the regression and the drop-after-limit case.

The reconcile logic, retention counter lifecycle, and test coverage are all solid. The only gap is that remotePortScanEmptyMissByPanel is not cleared in stopAllLocked and the proxy-error branch of handleProxyBrokerUpdateLocked, making the teardown contract inconsistent with suspendRemotePortScanningLocked and the empty-TTY path — though the previous.isEmpty guard in the reconcile loop prevents any observable misbehavior from this inconsistency.

RemoteSessionCoordinator.swift (stopAllLocked) and RemoteSessionCoordinator+PortScan.swift (handleProxyBrokerUpdateLocked error branch) — both are missing remotePortScanEmptyMissByPanel.removeAll() next to the existing remoteScannedPortsByPanel.removeAll() calls.

Important Files Changed

Filename Overview
Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift Adds retainingTransientEmptyScansLocked and the remotePortScanEmptyRetentionLimit constant. The reconcile logic is correct; the only gap is that remotePortScanEmptyMissByPanel is not cleared in the proxy-error teardown path (handleProxyBrokerUpdateLocked) as it is in suspendRemotePortScanningLocked and the empty-TTY early-return branch.
Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift Adds remotePortScanEmptyMissByPanel state variable. Missing .removeAll() calls in stopAllLocked() and the proxy-error branch of handleProxyBrokerUpdateLocked(), though harmless in practice due to the previous.isEmpty guard in the reconcile loop.
Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanRetentionTests.swift New deterministic test suite using ScriptedProcessRunner and RecordingRemoteSessionHost. Covers the regression (transient empty scan retains the port) and the drop-after-limit case. Accesses internal state via @testable import — the correct pattern.
.github/swift-file-length-budget.tsv Budget entries updated to match actual line counts after the additions (655→658 for Coordinator.swift, 586→621 for Coordinator+PortScan.swift). Both remain well under the 800-line ceiling.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant BurstTask as Burst Task
    participant Queue as coordinator.queue
    participant Scan as performRemotePortScanLocked
    participant Reconcile as retainingTransientEmptyScansLocked
    participant Host as RemoteSessionHosting

    BurstTask->>Queue: remotePortScanBurstStepElapsed
    Queue->>Scan: performRemotePortScanLocked()
    Scan->>Scan: scanRemotePortsByPanelLocked(ttyNamesByPanel)
    Note over Scan: SSH exec → parse TTY:port pairs
    Scan->>Reconcile: retainingTransientEmptyScansLocked(scanned)
    alt panel scanned non-empty
        Reconcile->>Reconcile: "reconciled[panelId] = freshPorts"
        Reconcile->>Reconcile: missCount reset (not in misses)
    else panel scanned empty
        Reconcile->>Reconcile: "previous = remoteScannedPortsByPanel[panelId]"
        Reconcile->>Reconcile: "missCount = (emptyMissByPanel[panelId] ?? 0) + 1"
        alt "previous non-empty AND missCount <= retentionLimit (2)"
            Reconcile->>Reconcile: "reconciled[panelId] = previous (retained)"
            Reconcile->>Reconcile: "misses[panelId] = missCount"
        else previous empty OR missCount exceeded
            Reconcile->>Reconcile: continue (port cleared)
        end
    end
    Reconcile->>Reconcile: "remotePortScanEmptyMissByPanel = misses"
    Reconcile-->>Scan: reconciled map
    Scan->>Scan: "remoteScannedPortsByPanel = reconciled"
    Scan->>Host: publishPortsSnapshotLocked()
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 BurstTask as Burst Task
    participant Queue as coordinator.queue
    participant Scan as performRemotePortScanLocked
    participant Reconcile as retainingTransientEmptyScansLocked
    participant Host as RemoteSessionHosting

    BurstTask->>Queue: remotePortScanBurstStepElapsed
    Queue->>Scan: performRemotePortScanLocked()
    Scan->>Scan: scanRemotePortsByPanelLocked(ttyNamesByPanel)
    Note over Scan: SSH exec → parse TTY:port pairs
    Scan->>Reconcile: retainingTransientEmptyScansLocked(scanned)
    alt panel scanned non-empty
        Reconcile->>Reconcile: "reconciled[panelId] = freshPorts"
        Reconcile->>Reconcile: missCount reset (not in misses)
    else panel scanned empty
        Reconcile->>Reconcile: "previous = remoteScannedPortsByPanel[panelId]"
        Reconcile->>Reconcile: "missCount = (emptyMissByPanel[panelId] ?? 0) + 1"
        alt "previous non-empty AND missCount <= retentionLimit (2)"
            Reconcile->>Reconcile: "reconciled[panelId] = previous (retained)"
            Reconcile->>Reconcile: "misses[panelId] = missCount"
        else previous empty OR missCount exceeded
            Reconcile->>Reconcile: continue (port cleared)
        end
    end
    Reconcile->>Reconcile: "remotePortScanEmptyMissByPanel = misses"
    Reconcile-->>Scan: reconciled map
    Scan->>Scan: "remoteScannedPortsByPanel = reconciled"
    Scan->>Host: publishPortsSnapshotLocked()
Loading

Comments Outside Diff (1)

  1. Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift, line 243-280 (link)

    P2 Missing remotePortScanEmptyMissByPanel teardown in two paths

    stopAllLocked() and the proxy-error branch in handleProxyBrokerUpdateLocked both call remoteScannedPortsByPanel.removeAll() but do not clear remotePortScanEmptyMissByPanel. The PR description states the counter is cleared alongside every port teardown, but these two paths are missed.

    In practice this is harmless: retainingTransientEmptyScansLocked reads previous = remoteScannedPortsByPanel[panelId] ?? [], and since remoteScannedPortsByPanel was cleared, previous is always [], which makes the guard !previous.isEmpty guard fail and prevents any stale retention. The stale counts are also replaced wholesale on the first scan after reconnect. Still, suspendRemotePortScanningLocked and the empty-TTY early-return in performRemotePortScanLocked both clear it explicitly — adding remotePortScanEmptyMissByPanel.removeAll() to stopAllLocked (around line 261, next to remoteScannedPortsByPanel.removeAll()) and to the .error case in handleProxyBrokerUpdateLocked (around line 458, next to remoteScannedPortsByPanel.removeAll()) would make the teardown contract consistent across all paths.

Reviews (2): Last reviewed commit: "Keep remote workspace ports across trans..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a sidebar port-badge flicker on remote SSH workspaces by retaining a panel's last-known ports across a bounded number of consecutive empty scans, rather than immediately dropping them on the first empty-but-successful attribution miss. The change is confined to the TTY-scoped scan path (performRemotePortScanLocked), introduces the new remotePortScanEmptyMissByPanel counter dictionary, and is covered by two deterministic new tests.

  • Adds remotePortScanEmptyRetentionLimit = 2 and retainingTransientEmptyScansLocked(_:) to reconcile fresh scan results against last-known ports, retaining a panel's ports through transient empty results and clearing them only after the limit is exceeded.
  • Adds remotePortScanEmptyMissByPanel: [UUID: Int] to track consecutive misses per panel, cleared in the scanner-suspend and empty-TTY-map teardown paths, along with new RemotePortScanRetentionTests that drive the queue-confined scan directly through a scripted runner.

Confidence Score: 4/5

The port-scan retention logic is sound and the new tests deterministically cover both the flicker fix and the sustained-drop case; two teardown paths that reset the scanned-ports map do not also reset the miss-count map, which is inconsistent but guarded by the existing !previous.isEmpty check.

The reconciliation loop in retainingTransientEmptyScansLocked is correct, and both suspendRemotePortScanningLocked and the empty-TTY branch of performRemotePortScanLocked clear the miss counter correctly. However, stopAllLocked and the proxy-error branch of handleProxyBrokerUpdateLocked each tear down remoteScannedPortsByPanel without clearing remotePortScanEmptyMissByPanel. A future change that consults the miss counter without first verifying the scanned-ports map could produce spurious retention after a stop or a proxy error cycle.

Both teardown sites in RemoteSessionCoordinator.swift — stopAllLocked and the .error case of handleProxyBrokerUpdateLocked — need remotePortScanEmptyMissByPanel.removeAll() added alongside the existing remoteScannedPortsByPanel.removeAll() calls.

Important Files Changed

Filename Overview
Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift Introduces the retention constant, adds retainingTransientEmptyScansLocked, and wires it into performRemotePortScanLocked. The miss counter is cleared correctly in the scanner-suspend and empty-TTY branches, but the proxy error teardown in handleProxyBrokerUpdateLocked does not clear it.
Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift Adds remotePortScanEmptyMissByPanel state variable. stopAllLocked tears down remoteScannedPortsByPanel but does not clear remotePortScanEmptyMissByPanel, leaving stale miss counts after a coordinator stop.
Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanRetentionTests.swift New deterministic test suite with two cases — transient miss retention and sustained-miss drop — driven through a scripted process runner with no wall-clock waits. Test scaffolding is correctly contained in the test target.
.github/swift-file-length-budget.tsv Budget entries updated for both touched Swift files to match their new line counts.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[performRemotePortScanLocked] --> B{ttyNamesByPanel empty?}
    B -- Yes --> C[removeAll: scanned + misses\npublishPortsSnapshotLocked]
    B -- No --> D[scanRemotePortsByPanelLocked]
    D --> E{ssh throws?}
    E -- Yes --> F[keep previous state\nlog error]
    E -- No --> G[retainingTransientEmptyScansLocked]
    G --> H{ports non-empty\nfor panel?}
    H -- Yes --> I[adopt fresh ports\nmiss count implicitly reset]
    H -- No --> J{previous non-empty AND missCount le 2?}
    J -- Yes --> K[retain previous ports\nincrement miss count]
    J -- No --> L[drop port\nmiss count not stored]
    I --> M[remoteScannedPortsByPanel = reconciled]
    K --> M
    L --> M
    M --> N[publishPortsSnapshotLocked]
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"}}}%%
flowchart TD
    A[performRemotePortScanLocked] --> B{ttyNamesByPanel empty?}
    B -- Yes --> C[removeAll: scanned + misses\npublishPortsSnapshotLocked]
    B -- No --> D[scanRemotePortsByPanelLocked]
    D --> E{ssh throws?}
    E -- Yes --> F[keep previous state\nlog error]
    E -- No --> G[retainingTransientEmptyScansLocked]
    G --> H{ports non-empty\nfor panel?}
    H -- Yes --> I[adopt fresh ports\nmiss count implicitly reset]
    H -- No --> J{previous non-empty AND missCount le 2?}
    J -- Yes --> K[retain previous ports\nincrement miss count]
    J -- No --> L[drop port\nmiss count not stored]
    I --> M[remoteScannedPortsByPanel = reconciled]
    K --> M
    L --> M
    M --> N[publishPortsSnapshotLocked]
Loading

Comments Outside Diff (2)

  1. Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift, line 260-262 (link)

    remotePortScanEmptyMissByPanel is not cleared in stopAllLocked(), leaving stale miss counts in the dictionary after a coordinator stop. Two teardown paths do this: stopAllLocked (here) and the .error branch of handleProxyBrokerUpdateLocked (around line 458), which also clears remoteScannedPortsByPanel without clearing the miss counter. The !previous.isEmpty guard in retainingTransientEmptyScansLocked prevents a correctness bug today — but if either teardown path is ever called while remoteScannedPortsByPanel is still partially populated (or if the retention logic is later extended), the stale counts will produce unexpected retention behavior. Both suspendRemotePortScanningLocked and the empty-TTY branch of performRemotePortScanLocked already clear both dictionaries together; the same pattern should apply here and in the proxy error path.

  2. Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift, line 457-462 (link)

    The proxy error teardown clears remoteScannedPortsByPanel but not remotePortScanEmptyMissByPanel. See the companion note on stopAllLocked — both clearings should be kept in sync with suspendRemotePortScanningLocked.

Reviews (2): Last reviewed commit: "Keep remote workspace ports across trans..." | Re-trigger Greptile

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

No issues found across 4 files

Re-trigger cubic

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

No issues found across 4 files

Re-trigger cubic

@m2na7

m2na7 commented Jul 3, 2026

Copy link
Copy Markdown
Author

Pushed a18ee88 addressing the review feedback:

  • Clear remotePortScanEmptyMissByPanel on every teardown path (stopAllLocked and the proxy-error branch, alongside the two paths already handled), keeping it in sync with remoteScannedPortsByPanel.
  • Document the retention invariant on both state fields.
  • New test teardownClearsRetentionState covers the teardown clearing. swift test --package-path Packages/macOS/CmuxRemoteSession — 43 tests pass.

@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Pushed a18ee88 addressing the review feedback:

  • Clear remotePortScanEmptyMissByPanel on every teardown path (stopAllLocked and the proxy-error branch, alongside the two paths already handled), keeping it in sync with remoteScannedPortsByPanel.
  • Document the retention invariant on both state fields.
  • New test teardownClearsRetentionState covers the teardown clearing. swift test --package-path Packages/macOS/CmuxRemoteSession — 43 tests pass.
    ...

@m2na7 I have started the AI code review. It will take a few minutes to complete.

@coderabbitai

coderabbitai Bot commented Jul 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
gh pr diff 7245 --repo manaflow-ai/cmux | head -400

Length of output: 17852


🏁 Script executed:

#!/bin/bash
set -euo pipefail
gh pr diff 7245 --repo manaflow-ai/cmux | wc -l
gh pr diff 7245 --repo manaflow-ai/cmux | tail -80

Length of output: 3374


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cd /tmp 2>/dev/null || true
gh repo clone manaflow-ai/cmux -- --depth 50 2>/dev/null || true

Length of output: 261


🏁 Script executed:

#!/bin/bash
set -euo pipefail
git clone --depth 50 https://github.com/manaflow-ai/cmux.git repo 2>&1 | tail -5
cd repo
git fetch origin pull/7245/head:pr7245 --depth 50 2>&1 | tail -5
git checkout pr7245 2>&1 | tail -5
sed -n '1,40p' Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift | head -5
grep -n "func stopAllLocked" -n Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift
sed -n '440,480p' Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift

Length of output: 4305


🏁 Script executed:

#!/bin/bash
cd repo
sed -n '245,270p' Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift

Length of output: 1186


Verified. The follow-up commit does exactly what's described: remotePortScanEmptyMissByPanel is now cleared alongside remoteScannedPortsByPanel at all four sites — stopAllLocked() (Line 267 in RemoteSessionCoordinator.swift), the proxy .error branch (Line 461), the "no tracked TTYs" fast path, and teardown(), both in RemoteSessionCoordinator+PortScan.swift. That closes the gap where a stale miss counter could otherwise linger across a stop/reconnect cycle and cause incorrect retention behavior on the next scan. The added invariant docs on both fields ("Kept in sync with remotePortScanEmptyMissByPanel... every site that clears one clears the other") make the contract explicit for future changes. The new teardownClearsRetentionState test directly exercises stopAllLocked() and asserts both maps are empty afterward, which is a solid regression guard.

The rest of the retention logic (retainingTransientEmptyScansLocked, remotePortScanEmptyRetentionLimit) is unchanged from the previous pass and looks correct: non-empty scans adopt fresh ports immediately, empty scans retain the previous ports up to the limit, and the miss counter only persists for panels that are actively retained (dropped panels are implicitly zeroed via the misses dictionary being rebuilt each call).

This looks good to merge from my side.

[approve]

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

No issues found across 4 files

Re-trigger cubic

@vercel

vercel Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 4, 2026 5:05am

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Thank you for this! Remote port badges now retain bounded transient scan misses in a023bef. You had this first :)

This branch was successfully deployed

1 active deployment
Preview – cmux — a18ee881 Deployed Jul 4, 2026 by vercel[bot]
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 badge for remote SSH workspaces flickers and shifts the sidebar layout

2 participants