Skip to content

Fix SSH reconnect churn after persistent daemon respawn - #9019

Merged
austinywang merged 7 commits into
mainfrom
issue-9015-ssh-slot-rejoin-respawned-daemon
Jul 28, 2026
Merged

austinywang merged 7 commits into
mainfrom
issue-9015-ssh-slot-rejoin-respawned-daemon

Conversation

@austinywang

@austinywang austinywang commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • admit stdio keepalive probes only when the pending-call registry and serialized write lane are genuinely idle
  • leave active application RPCs under their own method timeout instead of racing them against a shorter heartbeat watchdog
  • log persistent-daemon authentication rejection reasons to daemon.log without logging supplied tokens

Root cause

The stdio keepalive started after five seconds without an inbound frame and killed the transport after ten more seconds. Valid RPCs can take longer, and the daemon can queue the heartbeat behind cold-start work after a respawn. The heartbeat watchdog then killed a healthy SSH proxy while its application RPC was still progressing, producing the reported 20–40 second reconnect cycle.

Heartbeat admission is now atomic with write ordering. An earlier application call defers the probe; a probe that wins is written before any later application call. The existing idle-wedge watchdog behavior remains intact.

Tests

  • regression test that reproduces the transport death with a valid one-second RPC and 200 ms heartbeat timeout; committed separately before the fix
  • full CmuxRemoteDaemon Swift package suite: 22 tests passed
  • go test ./... in daemon/remote

The regression is deterministic transport-level coverage of the liveness race; it does not stand up a real SSH host or kill a real remote daemon.

Closes #9015


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

Fixes the 20–40s SSH reconnect loop after a persistent daemon respawn (issue #9015) by sending keepalives only when the transport is idle and arming the watchdog only after admission. Also improves persistent-daemon auth diagnostics and surfaces response-write failures, logging clear rejection reasons without exposing user input.

  • Bug Fixes
    • CmuxRemoteDaemon: Added registerIfIdle/callIfIdle; probe admission and write are atomic on writeQueue; watchdog arms post-admission; active RPCs keep their own timeout.
    • Tests: Added idle-registration coverage, a slow-RPC regression to ensure keepalives don’t kill valid calls, auth tests for rejection reasons and redaction, and a writer fixture to verify wrapped response-write errors.
    • cmuxd-remote: Auth now returns explicit errors, logs rejection reasons to stderr, and reports wrapped errors when the auth response write fails; supplied tokens/methods are never logged.

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

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved transport keepalive probing to only proceed when an idle write slot is reserved, avoiding premature termination during slow in-flight RPCs.
    • Centralized daemon-RPC request encoding and error mapping, with safer cleanup when requests can’t be prepared or admitted.
    • Enhanced persistent-daemon authentication to provide clearer rejection reasons, close failed connections cleanly, and prevent sensitive token/method values from leaking in logs.
  • Tests

    • Added regression tests for deferred idle registration and for keepalive not killing slow RPCs.
    • Expanded authentication tests to verify rejection logging/reason handling and credential redaction.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The Swift client now admits keepalive RPCs only when no application calls are pending and centralizes payload encoding. The Go persistent-daemon authentication path returns explicit errors, logs rejected connections, and propagates authentication response write failures.

Changes

Idle RPC and transport keepalive

Layer / File(s) Summary
Pending-call idle admission
Packages/macOS/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Registry/..., Packages/macOS/CmuxRemoteDaemon/Tests/CmuxRemoteDaemonTests/RemoteDaemonPendingCallRegistryTests.swift
Registration is centralized in registerLocked(), and registerIfIdle() refuses admission while another call is pending. Tests verify deferred registration and request ID sequencing.
RPC payload and idle call flow
Packages/macOS/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+RPC.swift
RPC payload encoding is shared, failed registrations are removed, and idle calls write and await responses only after successful admission.
Keepalive watchdog integration
Packages/macOS/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+TransportKeepalive.swift, Packages/macOS/CmuxRemoteDaemon/Tests/CmuxRemoteDaemonTests/RemoteDaemonRPCClientKeepaliveTests.swift
Keepalive probes use callIfIdle, watchdog setup is separated into helpers, and slow application RPCs are covered by a regression test.

Persistent-daemon authentication handling

Layer / File(s) Summary
Authentication error propagation
daemon/remote/cmd/cmuxd-remote/main.go
Persistent connection handlers pass stderr, return authentication errors, log rejected connections, and stop before starting the RPC server.
Authentication failure and success results
daemon/remote/cmd/cmuxd-remote/main.go
Authentication frame, JSON, method, token, and response-write failures now return explicit errors; successful authentication returns after writing the authenticated response.
Authentication logging tests
daemon/remote/cmd/cmuxd-remote/main_test.go
Test daemon startup accepts a log writer, and rejected-token and authentication-method tests verify diagnostic output without logging supplied invalid values.

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

Sequence Diagram(s)

Idle keepalive admission

sequenceDiagram
  participant ApplicationRPC
  participant RemoteDaemonRPCClient
  participant PendingCallRegistry
  participant RemoteDaemon
  ApplicationRPC->>RemoteDaemonRPCClient: start application RPC
  RemoteDaemonRPCClient->>PendingCallRegistry: register pending call
  RemoteDaemonRPCClient->>RemoteDaemon: send application request
  RemoteDaemonRPCClient->>PendingCallRegistry: registerIfIdle()
  PendingCallRegistry-->>RemoteDaemonRPCClient: nil while application call is pending
  RemoteDaemon-->>RemoteDaemonRPCClient: application response
  RemoteDaemonRPCClient->>PendingCallRegistry: resolve pending call
  RemoteDaemonRPCClient->>PendingCallRegistry: registerIfIdle()
  PendingCallRegistry-->>RemoteDaemonRPCClient: admitted keepalive call
  RemoteDaemonRPCClient->>RemoteDaemon: send hello probe
Loading

Persistent authentication

sequenceDiagram
  participant persistentDaemonAcceptLoop
  participant handlePersistentDaemonConn
  participant authenticatePersistentDaemonConn
  participant stderr
  persistentDaemonAcceptLoop->>handlePersistentDaemonConn: pass accepted connection and stderr
  handlePersistentDaemonConn->>authenticatePersistentDaemonConn: authenticate connection
  authenticatePersistentDaemonConn-->>handlePersistentDaemonConn: success or error
  handlePersistentDaemonConn->>stderr: log rejection error
Loading

Suggested reviewers: lawrencecchen

🚥 Pre-merge checks | ✅ 24 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers summary and tests, but it omits the required Demo Video, Review Trigger, and Checklist sections. Add the missing template sections, including the review-trigger block and checklist items; include a demo video link if applicable.
✅ Passed checks (24 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #9015 by fixing idle keepalive admission, protecting active RPCs, and logging auth rejection reasons without leaking secrets.
Out of Scope Changes check ✅ Passed The added tests and auth diagnostics are all directly tied to the reconnect-churn and daemon logging fix, with no obvious unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Cmux Swift Actor Isolation ✅ Passed Changed Swift code remains queue-confined with no new MainActor/UI isolation; added helpers fit the existing @unchecked Sendable design.
Cmux Swift Blocking Runtime ✅ Passed The Swift patch only refactors existing queue/semaphore/timer coordination; it adds no new blocking waits, sleeps, or delayed dispatch primitives in production code.
Cmux Browser Automation Off-Main ✅ Passed PR only touches keepalive/auth/pending-call code; changed files contain no browser.* routing or WebKit/AppKit wait paths, so the browser-automation rule isn’t implicated.
Cmux Expensive Synchronous Load ✅ Passed No agent-history load, JSON/transcript scan, or similar expensive sync loader was added; changes are limited to RPC/keepalive/registry queue syncs.
Cmux Cache Substitution Correctness ✅ Passed Diff only changes keepalive/auth paths; no persistence/history/snapshot read is replaced by cached state. callIfIdle just gates heartbeat admission on idleness.
Cmux No Hacky Sleeps ✅ Passed PASS: PR adds no new TS/JS/shell/build-runtime waits; the only delay constructs are in tests, and the Go runtime change just logs auth failures.
Cmux Algorithmic Complexity ✅ Passed The diff adds only O(1) idle checks on the pending-call registry; no new nested scans, repeated sorts/filters, or per-target rescans appear in the hot paths.
Cmux Swift Concurrency ✅ Passed No new legacy async pattern is introduced; the Swift changes stay within the existing queue/semaphore RPC boundary and add no Combine or fire-and-forget Task usage.
Cmux Swift @Concurrent ✅ Passed Changed Swift code is synchronous and queue-based; no new nonisolated async work, @concurrent misuse, or UI-isolated heavy async call sites were introduced.
Cmux Swift Package Boundaries ✅ Passed PASS: The diff only changes the CmuxRemoteDaemon SwiftPM library target and its tests; no reusable logic was added to the app-root Sources/ tree.
Cmux Swiftpm Lockfiles ✅ Passed No Package.resolved or .gitignore files changed, and the Xcode project diff has no SwiftPM package-reference edits.
Cmux Swift Logging ✅ Passed No added Swift runtime logging found: the changed Swift files contain no print/debugPrint/NSLog/Logger calls or secret-bearing log output.
Cmux User-Facing Error Privacy ✅ Passed Auth logs and RPC errors stay generic; code/tests avoid leaking supplied tokens or methods, and no vendor-specific or raw upstream text is exposed.
Cmux Full Internationalization ✅ Passed No user-facing localized UI strings were added; changes are RPC/keepalive logic, tests, and debug/daemon log messages only, with no xcstrings edits.
Cmux Swiftui State Layout ✅ Passed No banned SwiftUI pattern was introduced: new views are stateless wrappers, and the touched ObservableObject/@published state is existing legacy or debug-only.
Cmux Architecture Rethink ✅ Passed The diff keeps ownership clear: pending-call registry owns admission, keepalive owns timeout, and tests cover the invariant; no architectural workaround pattern was introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PASS: the only new standalone window controller, PDFPreviewChromeDebugWindowController, uses cmux.pdfPreviewChromeDebug and is listed in cmuxAuxiliaryWindowIdentifiers; tests are fixtures.
Cmux Source Artifacts ✅ Passed Changed paths are source/docs/tests/localization plus a submodule pin and checksum/lockfile updates; no temp/log/build/artifact directories appear.
Cmux No Test Or Debug Seam In Production Source ✅ Passed PR Swift Sources diff only adds production RPC/keepalive logic and registerIfIdle; no #if DEBUG, debug*/ForTesting members, or test-only seams in production files.
Cmux No Ambient Global State ✅ Passed PASS: New behavior stays on instance-owned types; the only file-scope helper is private/pure, and no new global var/singleton/static-namespace surface appears.
Title check ✅ Passed The title is concise and accurately reflects the main fix: SSH reconnect churn after persistent daemon respawn.
✨ 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 issue-9015-ssh-slot-rejoin-respawned-daemon

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.

Caution

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

⚠️ Outside diff range comments (1)
daemon/remote/cmd/cmuxd-remote/main.go (1)

1165-1174: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Differentiate missing and invalid authentication methods.

A nonempty unsupported method also reaches this branch, but the emitted diagnostic always says it is missing. Log the actual rejection reason without echoing the supplied method.

Proposed fix
-if req.Method != persistentDaemonAuthMethod {
+if req.Method == "" {
+    // write unauthorized response
+    return errors.New("authentication method is missing")
+}
+if req.Method != persistentDaemonAuthMethod {
     // write unauthorized response
-    return errors.New("authentication method is missing")
+    return errors.New("authentication method is invalid")
 }

Based on the PR objective to log authentication rejection reasons.

🤖 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 `@daemon/remote/cmd/cmuxd-remote/main.go` around lines 1165 - 1174, Update the
authentication check in the request handling flow around
persistentDaemonAuthMethod to distinguish an empty method from a nonempty
unsupported method. Preserve the unauthorized response, but return a diagnostic
stating whether authentication is missing or invalid, without including the
supplied method value.
🤖 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 `@daemon/remote/cmd/cmuxd-remote/main.go`:
- Around line 1165-1174: Update the authentication check in the request handling
flow around persistentDaemonAuthMethod to distinguish an empty method from a
nonempty unsupported method. Preserve the unauthorized response, but return a
diagnostic stating whether authentication is missing or invalid, without
including the supplied method value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d2893842-27f8-4438-9a7a-936b719390b1

📥 Commits

Reviewing files that changed from the base of the PR and between 18396f2 and 29cb9cb.

📒 Files selected for processing (7)
  • Packages/macOS/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+RPC.swift
  • Packages/macOS/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+TransportKeepalive.swift
  • Packages/macOS/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Registry/RemoteDaemonPendingCallRegistry.swift
  • Packages/macOS/CmuxRemoteDaemon/Tests/CmuxRemoteDaemonTests/RemoteDaemonPendingCallRegistryTests.swift
  • Packages/macOS/CmuxRemoteDaemon/Tests/CmuxRemoteDaemonTests/RemoteDaemonRPCClientKeepaliveTests.swift
  • daemon/remote/cmd/cmuxd-remote/main.go
  • daemon/remote/cmd/cmuxd-remote/main_test.go

@cursor

cursor Bot commented Jul 28, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@austinywang

Copy link
Copy Markdown
Contributor Author

Addressed the outside-diff auth-method diagnostic finding in b9cbd0d. Empty methods now log authentication method is missing; nonempty unsupported methods log authentication method is invalid. The wire response remains the same generic unauthorized response, supplied method/token values are never logged, and focused tests cover both cases.

@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)
daemon/remote/cmd/cmuxd-remote/main.go (1)

1170-1178: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Propagate failed rejection-response writes.

This branch still discards writer.writeResponse errors, so a failed auth reply is logged as a method rejection rather than a response-write failure. Apply the same error propagation used by the success response to every unauthorized/invalid-request response path.

Proposed fix
-       _ = writer.writeResponse(rpcResponse{
+       if err := writer.writeResponse(rpcResponse{
                ID: req.ID,
                OK: false,
                Error: &rpcError{
                    Code:    "unauthorized",
                    Message: "persistent daemon authentication required",
                },
-       })
+       }); err != nil {
+           return fmt.Errorf("authentication response write failed: %w", err)
+       }
        return errors.New(reason)
🤖 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 `@daemon/remote/cmd/cmuxd-remote/main.go` around lines 1170 - 1178, Update the
unauthorized and invalid-request response paths in the request handler to return
or propagate the error from writer.writeResponse, matching the existing
success-response handling instead of discarding it. Preserve the rejection
response payload while ensuring response-write failures are surfaced as the
returned error.
🤖 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 `@daemon/remote/cmd/cmuxd-remote/main.go`:
- Around line 1170-1178: Update the unauthorized and invalid-request response
paths in the request handler to return or propagate the error from
writer.writeResponse, matching the existing success-response handling instead of
discarding it. Preserve the rejection response payload while ensuring
response-write failures are surfaced as the returned error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 72025f39-25f2-44e3-a129-6eca4d354b1f

📥 Commits

Reviewing files that changed from the base of the PR and between 29cb9cb and b9cbd0d.

📒 Files selected for processing (3)
  • Packages/macOS/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+RPC.swift
  • daemon/remote/cmd/cmuxd-remote/main.go
  • daemon/remote/cmd/cmuxd-remote/main_test.go

@austinywang

Copy link
Copy Markdown
Contributor Author

Regarding the automated Cmux Swift Blocking Runtime warning: we are consciously retaining these three queue boundaries. writeQueue.sync makes idle-probe reservation and wire write atomic; replacing it with actor/await would reopen the admission-ordering race. stateQueue.sync arms queue-confined watchdog state before that admitted write, avoiding an async arm/write race. The pending-call registry’s queue.sync is its documented synchronous semaphore-backed contract; converting it to actor/await would change callers and timeout/resolve semantics. The focused slow-RPC and wedged-idle tests cover the intended behavior, and the canonical Codex review found no actionable concurrency defect.

@austinywang

Copy link
Copy Markdown
Contributor Author

Addressed CodeRabbit’s refreshed rejection-write finding in 3e01767. All authentication rejection/invalid-request branches now surface a generic wrapped response-write error when the rejection frame cannot be sent, while preserving the existing wire payloads and secret-safe diagnostics. go test ./... passes, including focused failing-writer coverage.

@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 `@daemon/remote/cmd/cmuxd-remote/main_test.go`:
- Around line 31-37: Rename the test-local errorWriter type in main_test.go to a
distinct fixture name such as authErrorWriter, and update all references to it
in the affected tests; do not redeclare the existing package-level errorWriter
from main.go.
🪄 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: b75cbc84-7094-4626-8b8e-85850f3a9428

📥 Commits

Reviewing files that changed from the base of the PR and between b9cbd0d and 3e01767.

📒 Files selected for processing (2)
  • daemon/remote/cmd/cmuxd-remote/main.go
  • daemon/remote/cmd/cmuxd-remote/main_test.go

Comment thread daemon/remote/cmd/cmuxd-remote/main_test.go Outdated
@austinywang
austinywang merged commit 6cc224e into main Jul 28, 2026
5 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

1 participant