Skip to content

iOS render-grid liveness watchdog: probe before teardown, consume during subscribe ack (fixes the 10.5s false-fire replay loop) - #5869

Merged
lawrencecchen merged 10 commits into
mainfrom
feat-ios-watchdog-falsefire
Jun 12, 2026
Merged

lawrencecchen merged 10 commits into
mainfrom
feat-ios-watchdog-falsefire

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the render-grid liveness watchdog false-fire found during the 2026-06-10 Release-sim bisect: the phone logged render-grid stream silent for 10499ms, re-subscribing every ~10.5s forever, plus subscribe failed reason=start: requestTimedOut, while the Mac demonstrably kept delivering (mobile.emit -> connection delivered=true). Every false fire made the Mac re-replay the full grid, a constant repaint every 10.5s (plausibly the reported "weird cursor paints") plus wasted battery and bandwidth.

Root cause

Two decouplings between the liveness recorder and reality:

  1. Silence is the normal state of a healthy idle terminal. The Mac dedupes render-grid emits by row signature and stateSeq (cursor blink changes neither), so an idle shell pushes zero events for arbitrarily long. The watchdog treated wall-clock silence alone as death, so it tore down and full-grid-replayed every healthy idle subscription once per threshold window, forever. The original watchdog commit (e3f418560) assumed an actively-streaming connection and never accounted for idle.
  2. The liveness clock was stamped only inside the listener's for await consumer loop, which did not start until resolveTerminalOutputTransport and the mobile.events.subscribe ack both completed. Events delivered during that establishment window piled up unconsumed in the subscription stream's buffer, invisible to the clock. Each watchdog fire then cancelled its own in-flight start subscribe; MobileCoreRPCSession.cancelPendingRequest surfaces that cancellation as requestTimedOut (the real wire timeout is 30s, so the 10.5s cadence of that log was the watchdog cancelling itself, not the network), and the dying generation called markMacConnectionUnavailable() underneath the fresh one.

Fix

  • One liveness ownership point. recordTerminalEventStreamLiveness() is stamped by every consumed envelope, by a successful probe, and (as the generation reset) when a watchdog generation is armed. The watchdog reads the same record; listenerID guards every transition; stopRenderGridLivenessWatchdog resets cleanly.
  • Silence is a suspicion, not a verdict. A threshold crossing now runs a bounded idempotent mobile.events.subscribe probe (same stream_id, current topics) instead of tearing down. A completed round-trip proves the transport the events ride on is alive AND that the registration is installed. Only a failed probe runs the existing teardown + re-subscribe + replay recovery, so the original ~85s silent-death case still heals (probe deadline default 3s, runtime-injectable, bounds the whole attempt including pre-wire token work).
  • Repaired registrations catch up. The Mac's subscribe ack now reports already_subscribed. When the probe reinstalls a registration the host had lost (already_subscribed: false), delta continuity is broken, so the phone replays every mounted surface and re-fetches the workspace list, without restarting the intact phone-side listener. Older Macs omit the field; their registrations cannot outlive the connection, so nil is treated as already-active.
  • The start subscribe ack runs concurrently with consumption (beginTerminalEventSubscriptionStart). The ack is a server-side enable handshake, not a delivery precondition, so the consumer loop starts immediately and the clock stays coupled to actual event arrival. Ack success/failure is acted on only while the generation is current, so a superseded ack can no longer mark the connection unavailable, and a cancelled ack logs as cancelled instead of a fake wire timeout.
  • Probe slot ownership token so a superseded probe completing late cannot clear or clobber a newer generation's single-flight slot.

Red/green

Commit 1 (4c57e66ba) adds the failing tests plus a DEBUG-only seam to run one watchdog evaluation deterministically against the injected clock; the scripted host drives the real MobileCoreRPCSession transport and the real consumer path. renderGridEventsArrivingDuringStartSubscribeAreConsumed and the healthy-idle watchdog test fail against pre-fix code (the false fire reproduces 1:1: sync.liveness re-subscribe silentMs=10000 plus the replay storm); watchdogStillResubscribesGenuinelyDeadStream pins the original recovery so the fix cannot regress it. The probe redesign retargeted the idle test's teardown signals to listener restarts and replay traffic (the probe itself legitimately re-sends mobile.events.subscribe); both liveness tests were re-verified red against the commit-1 sources after the amendment.

swift test --package-path Packages/CmuxMobileShell: 59 tests green. swift test --package-path Packages/CmuxMobileRPC: 23 green. Build-verified Debug and Release for arm64 simulator (/tmp/cmux-watchdog) and the macOS app (/tmp/cmux-watchdog-mac, host-side ack change).

Residual risk

A Mac-side producer wedge with the registration intact (demand tracker desync deeper than a re-subscribe replace can re-arm) is not phone-detectable and is not recovered by this watchdog; the input-driven seq self-heal still covers it during typing. Dead-stream detection now takes threshold + probe deadline (~12s) instead of ~9s.

🤖 Generated with Claude Code

Review fixes (autoreview round 1)

  • A successful probe now also calls markMacConnectionHealthy(): the round-trip is positive proof of the client/host connection, so a transient foreground RPC failure can no longer leave the status UI stuck unavailable on an idle terminal.
  • mobile.events.subscribe/unsubscribe are now non-interactive in MobileHostRequestActivity accounting (joining mobile.host.status and the replays), so the idle probe cannot keep the Mac permanently "recently active" and starve background work gated on mobile quiet (TabManager git/PR metadata refresh).

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


Note

Medium Risk
Touches mobile sync connection recovery and Mac RPC subscription semantics; behavior is heavily regression-tested but wrong probe/repair logic could still cause stale UI or excess replay traffic.

Overview
Fixes the iOS render-grid liveness watchdog falsely tearing down healthy idle Mac connections (~10.5s full-grid replay loop).

Watchdog behavior: Prolonged silence no longer immediately triggers re-subscribe + replay. It first runs a bounded idempotent mobile.events.subscribe probe (configurable via livenessProbeTimeoutNanoseconds, default 3s). Only a failed probe runs the existing resync path. A successful probe refreshes the liveness clock and marks the connection healthy; if the host reports already_subscribed: false, mounted surfaces are replayed and the workspace list is refreshed without restarting the listener.

Subscribe handshake: The start mobile.events.subscribe ack runs concurrently with the event consumer so buffered events during establishment update liveness. Stream end before that ack completes now marks the connection unavailable instead of reconnect looping. Cancelled subscribe requests log as cancelled, not wire timeouts.

Mac host: Subscribe RPC responses include already_subscribed. mobile.events.subscribe / unsubscribe are treated as non-interactive so idle probes do not block background Mac work.

Tests: New MobileShellRenderGridLivenessTests plus scripted host fixtures cover establish-window consumption, healthy idle, lost-registration repair, dead-stream recovery, and pre-ack stream close.

Reviewed by Cursor Bugbot for commit ab8f139. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Fixes the iOS render‑grid liveness watchdog false‑fires by probing before teardown and consuming events while the start‑subscribe ack is in flight. Also handles a stream that ends before the start ack by marking the Mac unavailable, preventing a reconnect loop.

  • Bug Fixes

    • Silence triggers a bounded, idempotent mobile.events.subscribe probe (same stream_id/topics) before recovery; only a failed probe re‑subscribes and replays. Deadline via MobileSyncRuntime.livenessProbeTimeoutNanoseconds (default 3s).
    • Start‑subscribe ack now runs in parallel with stream consumption; liveness stamps on each consumed envelope and on probe success. Cancelled acks log as cancelled, not requestTimedOut.
    • If the event stream ends before the start‑subscribe ack completes, treat it as a failed start: stop and mark the connection unavailable (prevents a reconnecting livelock).
    • Subscribe ack includes already_subscribed (decoded as alreadySubscribed); when false, replay mounted surfaces and refresh the workspace list without restarting the listener. nil is treated as already active for older hosts.
    • Single‑flight probe with an ownership token; successful probes mark the Mac healthy. mobile.events.subscribe/unsubscribe are counted as non‑interactive host activity. Probe deadline uses a one‑shot DispatchSourceTimer.
  • Tests

    • Added regression coverage for establish‑window consumption, healthy idle (no teardown), lost‑registration repair (replay + workspace refresh), dead‑stream recovery, and the “stream ends before start ack” case (converges to unavailable).

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

Review in cubic

Summary by CodeRabbit

  • New Features

    • Subscription responses now indicate whether a subscription was already present.
    • Runtime exposes a configurable liveness probe timeout with a sensible default.
  • Bug Fixes

    • Reduced false liveness timeouts by decoupling subscription acknowledgments from event consumption.
    • Host-side subscription loss now reinstates subscriptions, replays mounted terminal surfaces, and refreshes workspace lists without restarting listeners.
    • Watchdog uses bounded, cancellable probes to verify stream health and avoid late-state mutation.
  • Tests

    • Added comprehensive tests and test-support utilities for render-grid liveness and recovery scenarios.

@vercel

vercel Bot commented Jun 11, 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 Jun 12, 2026 1:20am
cmux-staging Building Building Preview, Comment Jun 12, 2026 1:20am

@coderabbitai

coderabbitai Bot commented Jun 11, 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

Decouples subscribe ack from event consumption, adds a bounded single‑flight liveness probe with configurable timeout, surfaces server-side already_subscribed in subscribe responses, and adds deterministic tests validating watchdog and recovery behaviors.

Changes

Render-grid liveness watchdog refactoring and testing

Layer / File(s) Summary
RPC response contract and protocol configuration
Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift, Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift
MobileEventSubscribeResponse adds an optional alreadySubscribed decoded from already_subscribed. MobileSyncRuntime adds livenessProbeTimeoutNanoseconds with default 3_000_000_000.
Host subscribe handler and classification
Sources/Mobile/MobileHostService.swift
mobile.events.subscribe now reports prior subscription presence via already_subscribed, and mobile.events.subscribe/mobile.events.unsubscribe are treated as non-interactive.
Subscription handshake concurrency and result semantics
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
Introduces tracked terminalSubscriptionStartTask, TerminalEventSubscriptionAck (.subscribed(alreadySubscribed:) / .failed), stronger response validation, cancellation-as-silent-failure, concurrent handshake kickoff, and task cancellation on stop.
Unified liveness tracking and enhanced watchdog probing
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
Liveness is updated on actual event consumption; watchdog runs a single‑flight, timer‑bounded probe (probeEventSubscriptionLiveness), interprets alreadySubscribed to repair/replay and refresh workspace, and cancels probe/start tasks on teardown.
Render-grid liveness test suite with scripted doubles
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/*
Adds TestClock, LivenessTestRuntime, LivenessHostRouter, LivenessTransport, helpers, OutputCollector, and six tests exercising in‑flight ack consumption, healthy-idle probes, repair replay, dead-stream resubscribe, and stream-end-before-ack behavior.

Sequence Diagram

sequenceDiagram
  participant Client as MobileShellComposite
  participant StartTask as Subscription Handshake
  participant Consumer as Event Consumer
  participant Host as MobileHostService
  participant Watchdog as Liveness Watchdog

  Client->>StartTask: beginTerminalEventSubscriptionStart (non-blocking)
  Client->>Consumer: startTerminalRefreshPolling concurrently
  StartTask->>Host: mobile.events.subscribe RPC
  Consumer->>Client: recordTerminalEventStreamLiveness() on each consumed event
  Host-->>StartTask: TerminalEventSubscriptionAck{subscribed(alreadySubscribed:Bool)|failed}
  loop Liveness monitoring
    Watchdog->>Watchdog: check silence threshold
    alt Silence exceeded
      Watchdog->>Host: probeEventSubscriptionLiveness (bounded timeout)
      Host-->>Watchdog: ack with already_subscribed flag
      alt already_subscribed=false
        Watchdog->>Client: replay mounted surfaces + refresh workspace
      else already_subscribed=true
        Watchdog->>Watchdog: healthy, reset liveness
      else probe timeout/failed
        Watchdog->>Client: teardown and re-subscribe
      end
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped beside the noisy stream,
And let the handshake wake from dream,
A gentle probe that checks the line,
Replays the lost, keeps flows in time,
Concurrently we sip the stream — hooray!


Important

Pre-merge checks failed

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

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error e3f4185..HEAD diff adds DispatchSourceTimer/ContinuousClock().sleep in MobileShellComposite.swift and Task.sleep/ContinuousClock().sleep in MobileHostService.swift (prod timing/blocking primitives). Refactor the liveness/probe timing to avoid production timers/sleeps (e.g., actor-owned async timeout via task cancellation/clock abstraction), or confine those primitives to deterministic tests only.
Cmux Swift @Concurrent ❌ Error MobileShellComposite.swift calls requestTerminalEventSubscription (client.sendRequest + MobileEventSubscribeResponse.decode) from Task { @MainActor } in probeEventSubscriptionLiveness/start; no @co... Hop RPC/decoding off @MainActor before client.sendRequest/response decode (e.g., capture needed values on main, then run requestTerminalEventSubscription/probe via @concurrent or Task.detached).
Cmux Swift File And Package Boundaries ❌ Error MobileShellComposite.swift in production already ~4.8k lines and now contains liveness probe/subscribe-handshake plus networking/parsing/persistence/UI/state in one file (summary +291 lines added). Split the render-grid liveness watchdog/probe + subscription-start logic into a small SwiftPM target with a protocol API, leaving MobileShellComposite mostly as app/UI wiring; move tests accordingly.
Docstring Coverage ⚠️ Warning Docstring coverage is 39.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (17 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main changes: introducing a probe mechanism before teardown and consuming events during subscribe ack to fix the false-fire replay loop.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Production changes add Sendable value fields and implement liveness/probe/start using @MainActor tasks/timers; no new Swift 6 actor-isolation issues (MainActor-by-default, unsafe shared Sendable, U...
Cmux Expensive Synchronous Load ✅ Passed Checked the PR’s changed production Swift files for RestorableAgentSessionIndex.load/sysctl/shared-agent index patterns; none found, so no expensive synchronous loader moved onto main/interactive p...
Cmux Cache Substitution Correctness ✅ Passed Reviewed the PR-touched Swift files; changes add alreadySubscribed decoding and liveness/probe task logic using in-memory state—no persistence/history/undo/snapshot cache substitution detected.
Cmux No Hacky Sleeps ✅ Passed PR #5869 changes only Swift files (no TS/JS/shell/build/runtime scripts). The no-hacky-sleeps rule scope excludes Swift, so no covered violations detected.
Cmux Algorithmic Complexity ✅ Passed PR doesn’t add new nested full-collection workspace scans: remoteWorkspacesPreservingSnapshots and scheduleWorkspaceListRefreshFromEvent are identical to main; new liveness probe loops are bounded...
Cmux Swift Concurrency ✅ Passed In the PR’s liveness-related Swift files, there’s no new DispatchQueue.global/custom background queues, no Combine (@Published/ObservableObject), and no completion-handler APIs; probe uses Dispatch...
Cmux Swift Logging ✅ Passed No disallowed production Swift logging added: checked MobileShellComposite.swift, MobileHostService.swift, MobileSyncRuntime.swift, and MobileEventSubscribeResponse.swift for print/debugPrint/dump/...
Cmux User-Facing Error Privacy ✅ Passed Checked touched production Swift files for user-facing L10n/recovery strings and API error text; only generic messages (e.g., invalid host/port, terminal queue full) found—no vendor/provider/secret...
Cmux Full Internationalization ✅ Passed Scanned production Swift files involved in the PR for user-facing Swift copy/localization APIs (String(localized:), NSLocalizedString, SwiftUI Text/alerts) and found none introduced; no web/i18n ca...
Cmux Swiftui State Layout ✅ Passed Checked PR’s touched Swift files for SwiftUI view/state-layout patterns (import SwiftUI, View/var body, @Published/@StateObject, GeometryReader, Lazy* rows) and found none.
Cmux Architecture Rethink ✅ Passed PR’s Swift architecture changes use single-flight ownership tokens + DispatchSourceTimer; forbidden sleep/locks/observers/polling only appear in test-only fixtures and helpers (allowed by rule).
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR #5869 only updates Mobile RPC liveness/watchdog code + tests; the diff contains no NSWindow/NSPanel/WindowGroup or close-shortcut / cmuxAuxiliaryWindowIdentifiers changes.
Cmux Source Artifacts ✅ Passed GitHub shows only Swift source/test files plus .github/swift-file-length-budget.tsv changed; no logs, binaries, temp/DerivedData, or generated screenshots/recordings were added.
Description check ✅ Passed The pull request description is comprehensive and well-structured, covering the root cause, the fix strategy, testing approach, and residual risks, though it exceeds template structure.
✨ 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 feat-ios-watchdog-falsefire

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 and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c73fbb335

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3021 to +3025
guard ack.isSubscribed else {
MobileDebugLog.anchormux("sync.subscribe_failed reason=start")
self.diagnosticLog?.record(DiagnosticEvent(.error))
self.stopTerminalRefreshPolling()
self.markMacConnectionUnavailable()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore stale start failures once events prove the stream

When the initial mobile.events.subscribe ack is delayed or dropped while a prior server subscription is still delivering events, the new consumer loop can now keep processing those events and liveness probes can succeed, but this start task still treats the later timeout as authoritative and stops the current listener. In that scenario the stream has already proven healthy via consumed envelopes/probe success, yet the delayed start failure tears it down and marks the Mac unavailable; recheck recent liveness/current probe success (or cancel/supersede the start ack) before stopping polling here.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 11, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the iOS render-grid liveness watchdog false-fire loop by replacing the "silence = death" heuristic with a probe-before-teardown strategy, and by decoupling the start-subscribe ack from event consumption so the liveness clock stays coupled to actual delivery.

  • Probe-before-teardown: checkRenderGridLiveness now runs a bounded idempotent mobile.events.subscribe round-trip when silence crosses the threshold; only a failed probe triggers the existing tear-down + replay recovery, while a successful probe (including already_subscribed: false lost-registration repair) resets the clock without restarting the listener.
  • Concurrent start ack: beginTerminalEventSubscriptionStart fires the enable handshake as an independent Task while the consumer for await loop starts immediately, ensuring events delivered during the establishment window update recordTerminalEventStreamLiveness() instead of buffering invisibly. A generation guard prevents a superseded ack from marking the connection unavailable.
  • Mac host change: MobileHostService adds already_subscribed to the subscribe response and marks mobile.events.subscribe/unsubscribe as non-interactive, preventing idle probes from blocking background work.

Confidence Score: 5/5

Safe to merge. The probe-before-teardown redesign is well-guarded with generation tokens on every state transition, backward-compatible with older Mac hosts, and backed by four targeted regression tests that reproduce the exact false-fire conditions found during bisect.

The generation-guard pattern (listenerID, probeID) is applied consistently across the ack task, the probe task, and the watchdog tick, preventing any superseded task from touching live state. The concurrent start-ack design is structurally sound: the consumer loop starts immediately, the ack acts only while the generation is current, and the stream-ended-before-ack path converges cleanly to unavailable. The already_subscribed field is nullable and the nil-as-active fallback is correct for older hosts. The DispatchSourceTimer deadline replaces the previously flagged Task.sleep and is the approved primitive. The one observation (inner probe task outliving outer-task cancellation by up to the probe deadline) is bounded, result-discarding, and well below the level of a blocking defect.

No files require special attention. The primary change in MobileShellComposite.swift is the most complex, but the generation guards and the new streamEndingBeforeStartAckMarksUnavailable test cover the main edge cases.

Important Files Changed

Filename Overview
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Core watchdog redesign: probe-before-teardown, concurrent start ack, single-ownership liveness clock, and probeID ownership token. Logic is well-guarded; one subtle resource note about inner probe task lifetime after outer task cancellation.
Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift Adds optional alreadySubscribed: Bool? decoded from already_subscribed; nil correctly treated as already active for older hosts. Clean additive change.
Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift Adds livenessProbeTimeoutNanoseconds to the protocol with a 3s default extension; cleanly injectable for tests.
Sources/Mobile/MobileHostService.swift Captures alreadySubscribed before idempotent replace and returns it in the response; adds subscribe/unsubscribe to non-interactive method list. Both changes are correct and backward-compatible.
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift Four regression tests covering establish-window consumption, healthy-idle no-teardown, lost-registration repair (replay + workspace refresh), genuinely dead stream recovery, and stream-ended-before-ack convergence.
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift Shared test scaffolding: injected clock, scripted host router, transport box, and connected-store builder. NSLock and Task.sleep are appropriate for test-only code per the blocking-runtime rule carve-out.
.github/swift-file-length-budget.tsv Budget entries updated to reflect new line counts for MobileShellComposite.swift (4827) and MobileHostService.swift (2340).

Sequence Diagram

sequenceDiagram
    participant W as WatchdogTimer
    participant C as checkRenderGridLiveness
    participant P as probeEventSubscriptionLiveness
    participant D as DeadlineTimer
    participant M as Mac (MobileHostConnection)
    participant R as resyncTerminalOutput

    W->>C: tick (listenerID matches)
    C->>C: "silent >= 9s threshold?"
    alt probe slot empty
        C->>P: spawn renderGridLivenessProbeTask
        P->>D: arm one-shot timer (livenessProbeTimeoutNanoseconds)
        P->>M: mobile.events.subscribe (liveness_probe)
        alt host answers in time
            M-->>P: "{stream_id, already_subscribed}"
            P->>D: deadline.cancel()
            alt "already_subscribed == true (or nil)"
                P->>C: ".subscribed => recordLiveness + markHealthy"
            else "already_subscribed == false (lost registration)"
                P->>C: ".subscribed => recordLiveness + replay surfaces + workspace refresh"
            end
        else timeout fires first
            D->>P: probe.cancel()
            P-->>C: .failed
            C->>C: "recheck silence still >= threshold?"
            C->>R: resyncTerminalOutput(reason: liveness)
        end
    else probe already in-flight
        C->>C: no-op (single-flight guard)
    end
Loading

Reviews (5): Last reviewed commit: "Treat a stream that ends before its subs..." | Re-trigger Greptile

Comment on lines +3266 to +3268
let deadline = Task {
try? await Task.sleep(nanoseconds: timeoutNanoseconds)
probe.cancel()

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 Task.sleep as a deadline in production code

cmux-swift-blocking-runtime explicitly lists Task.sleep as a flagged primitive in production Swift. The inline comment calls this "allowed: cancellation-wired timeout, not a polling sleep," but the pass list in the rule is limited to test-only scaffolding, CI YAML, and pure animation timing — a probe deadline doesn't fit any of those.

The file already uses DispatchSourceTimer for the watchdog tick; a one-shot DispatchSourceTimer (or a withThrowingTaskGroup-based structured-concurrency race, which is the idiomatic pattern in Swift 5.9+) would satisfy the rule without changing behaviour. If the team consciously accepts Task.sleep for bounded timeouts as a codebase idiom, that carve-out should be documented in the relevant rule file so future reviewers know the intent.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

Comment on lines +3184 to +3185
self.renderGridLivenessProbeTask = nil
self.renderGridLivenessProbeID = nil

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 Probe slot cleared before generation guards run

renderGridLivenessProbeTask and renderGridLivenessProbeID are set to nil (lines 3184-3185) before the generation-validity guards on lines 3186-3190. If stopRenderGridLivenessWatchdog fires on the MainActor between those two points (not possible with cooperative scheduling, but worth noting for auditability), a new probe could start before the old one's post-slot-clear logic completes. More concretely: if the probeID ownership guard (line 3183) passes but then Task.isCancelled fires (line 3186), the slot is already cleared — a subsequent watchdog tick's guard renderGridLivenessProbeTask == nil will pass and re-arm a probe with stale preconditions. Clearing the slot only after all guards pass would be safer.

@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 `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 2814-2829: The MobileShellComposite class contains the liveness
subscription/watchdog state machine (including the TerminalEventSubscriptionAck
enum and related orchestration spanning the MobileShellComposite methods around
the shown ranges) which should be extracted into a dedicated component; create a
new LivenessSupervisor (or LivenessSubscriptionManager) type and move the
TerminalEventSubscriptionAck enum, all state, timers/watchdog logic,
subscribe/ack handling, and probe/start-ack orchestration into it, expose a
concise interface (start/stop/handleIncomingAck/subscribeRequest) and replace in
MobileShellComposite any direct state accesses with calls to that interface,
update unit tests to target the new component, and ensure any dependencies
(loggers, dispatch queues, event emitters) are injected into the new type rather
than referenced from MobileShellComposite.
🪄 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

Run ID: b0f29381-185f-4c3c-a447-7785a28fcb3f

📥 Commits

Reviewing files that changed from the base of the PR and between 85ae932 and 8c73fbb.

📒 Files selected for processing (5)
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift
  • Sources/Mobile/MobileHostService.swift

Comment on lines +2814 to +2829
/// Outcome of a `mobile.events.subscribe` round-trip.
private enum TerminalEventSubscriptionAck {
case failed
/// The host registered (or re-asserted) the subscription.
/// `alreadySubscribed == false` means this acknowledgement INSTALLED
/// the registration, so events emitted while it was absent were never
/// delivered; `nil` means the host predates the field (treat as
/// already active).
case subscribed(alreadySubscribed: Bool?)

var isSubscribed: Bool {
if case .subscribed = self { return true }
return false
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract the liveness subscription/watchdog state machine from MobileShellComposite.

This PR adds a sizable new coordination subsystem into an already very large, multi-domain class. Please move this liveness/probe/start-ack orchestration into a dedicated component to keep responsibilities bounded and independently testable.

As per coding guidelines, production Swift files in {Sources,CLI,Packages,cmuxTests,cmuxUITests}/**/*.swift should be flagged when they are oversized and mixing responsibilities.

Also applies to: 2956-3032, 3138-3273

🤖 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 `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 2814 - 2829, The MobileShellComposite class contains the liveness
subscription/watchdog state machine (including the TerminalEventSubscriptionAck
enum and related orchestration spanning the MobileShellComposite methods around
the shown ranges) which should be extracted into a dedicated component; create a
new LivenessSupervisor (or LivenessSubscriptionManager) type and move the
TerminalEventSubscriptionAck enum, all state, timers/watchdog logic,
subscribe/ack handling, and probe/start-ack orchestration into it, expose a
concise interface (start/stop/handleIncomingAck/subscribeRequest) and replace in
MobileShellComposite any direct state accesses with calls to that interface,
update unit tests to target the new component, and ensure any dependencies
(loggers, dispatch queues, event emitters) are injected into the new type rather
than referenced from MobileShellComposite.

Source: Coding guidelines

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Addressed the Greptile P2: the probe deadline now uses a one-shot DispatchSourceTimer (the same sanctioned primitive as the watchdog tick) instead of Task.sleep, cancellation wired both directions. 59 package tests green.

lawrencecchen added a commit that referenced this pull request Jun 11, 2026
… watchdog fix, #5869)

Conflict resolution: keep both the DEV dogfood checklist listener fields
and the watchdog's terminalSubscriptionStartTask; subscribe path takes the
watchdog's concurrent-ack helper, with the notifications cold-attach fetch
moved into its ack-success branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lawrencecchen
lawrencecchen force-pushed the feat-ios-watchdog-falsefire branch from fd37830 to 40e6c53 Compare June 11, 2026 21:09

@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: 2

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

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 3417-3425: The start-ack path currently collapses
TerminalEventSubscriptionAck to ack.isSubscribed and misses the
.subscribed(alreadySubscribed: false) repair case; change the start-ack handler
to switch on TerminalEventSubscriptionAck and treat
.subscribed(alreadySubscribed: false) the same as the watchdog probe repair path
(invoke the shared re-subscribe/repair routine that refreshes mounted surfaces
and the workspace list), reusing the same method used by the watchdog probe to
avoid duplicated logic; also update the other subscribe-ack handling site
referenced around the 3624–3632 change to call that same shared repair routine
so all entrypoints (re-subscribe, watchdog, UI actions) converge on one code
path.
- Around line 237-240: isComposerPresented and the composer-presenting setter
currently read/write composerDismissedTerminalIDs via selectedTerminalID,
causing operations like toggleComposer(forTerminalID:) and
presentAndFocusComposer(forTerminalID:) to act on the wrong surface when
rendered != selected. Fix by making the composer-present state APIs use the
explicit terminal ID: change isComposerPresented to take a TerminalID (or add
isComposerPresented(forTerminalID:)) and change the setter to
setComposerPresented(forTerminalID:isPresented:) (or an overloaded version) so
they read/write composerDismissedTerminalIDs using terminalID.rawValue; update
toggleComposer(forTerminalID:) and presentAndFocusComposer(forTerminalID:) to
call these new/updated helpers instead of relying on selectedTerminalID, and
adjust any other callers (mentioned ranges 2209-2215, 2231-2234, 2292-2299)
accordingly.
🪄 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

Run ID: 24f9b86d-ecd1-4b22-8659-6e6a469a51a6

📥 Commits

Reviewing files that changed from the base of the PR and between fd37830 and 40e6c53.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (6)
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift
  • Sources/Mobile/MobileHostService.swift

@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

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 2

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

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 3417-3425: The start-ack path currently collapses
TerminalEventSubscriptionAck to ack.isSubscribed and misses the
.subscribed(alreadySubscribed: false) repair case; change the start-ack handler
to switch on TerminalEventSubscriptionAck and treat
.subscribed(alreadySubscribed: false) the same as the watchdog probe repair path
(invoke the shared re-subscribe/repair routine that refreshes mounted surfaces
and the workspace list), reusing the same method used by the watchdog probe to
avoid duplicated logic; also update the other subscribe-ack handling site
referenced around the 3624–3632 change to call that same shared repair routine
so all entrypoints (re-subscribe, watchdog, UI actions) converge on one code
path.
- Around line 237-240: isComposerPresented and the composer-presenting setter
currently read/write composerDismissedTerminalIDs via selectedTerminalID,
causing operations like toggleComposer(forTerminalID:) and
presentAndFocusComposer(forTerminalID:) to act on the wrong surface when
rendered != selected. Fix by making the composer-present state APIs use the
explicit terminal ID: change isComposerPresented to take a TerminalID (or add
isComposerPresented(forTerminalID:)) and change the setter to
setComposerPresented(forTerminalID:isPresented:) (or an overloaded version) so
they read/write composerDismissedTerminalIDs using terminalID.rawValue; update
toggleComposer(forTerminalID:) and presentAndFocusComposer(forTerminalID:) to
call these new/updated helpers instead of relying on selectedTerminalID, and
adjust any other callers (mentioned ranges 2209-2215, 2231-2234, 2292-2299)
accordingly.
🪄 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

Run ID: 24f9b86d-ecd1-4b22-8659-6e6a469a51a6

📥 Commits

Reviewing files that changed from the base of the PR and between fd37830 and 40e6c53.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (6)
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift
  • Sources/Mobile/MobileHostService.swift
🛑 Comments failed to post (2)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (2)

237-240: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the explicit terminal id for composer presentation state.

toggleComposer(forTerminalID:) / presentAndFocusComposer(forTerminalID:) claim to act on a specific surface, but isComposerPresented and setComposerPresented still read/write via selectedTerminalID. When the rendered terminal diverges from the selection, the compose button flips the wrong terminal's dismissed bit and can dismiss instead of focusing the visible composer.

Suggested direction
-public var isComposerPresented: Bool {
-    guard let terminalID = selectedTerminalID?.rawValue else { return false }
+public func isComposerPresented(forTerminalID terminalID: String? = nil) -> Bool {
+    guard let terminalID = terminalID ?? selectedTerminalID?.rawValue else { return false }
     return !composerDismissedTerminalIDs.contains(terminalID)
 }
 
-public func toggleComposer(forTerminalID terminalID: String? = nil) {
-    if isComposerPresented {
-        setComposerPresented(false)
+public func toggleComposer(forTerminalID terminalID: String? = nil) {
+    if isComposerPresented(forTerminalID: terminalID) {
+        setComposerPresented(false, forTerminalID: terminalID)
     } else {
-        setComposerPresented(true)
+        setComposerPresented(true, forTerminalID: terminalID)
         requestComposerFieldFocus(forTerminalID: terminalID)
     }
 }
 
-private func setComposerPresented(_ presented: Bool) {
-    guard let terminalID = selectedTerminalID?.rawValue,
-          presented != isComposerPresented else { return }
+private func setComposerPresented(_ presented: Bool, forTerminalID terminalID: String? = nil) {
+    guard let terminalID = terminalID ?? selectedTerminalID?.rawValue,
+          presented != isComposerPresented(forTerminalID: terminalID) else { return }
     ...
 }

Also applies to: 2209-2215, 2231-2234, 2292-2299

🤖 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 `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 237 - 240, isComposerPresented and the composer-presenting setter
currently read/write composerDismissedTerminalIDs via selectedTerminalID,
causing operations like toggleComposer(forTerminalID:) and
presentAndFocusComposer(forTerminalID:) to act on the wrong surface when
rendered != selected. Fix by making the composer-present state APIs use the
explicit terminal ID: change isComposerPresented to take a TerminalID (or add
isComposerPresented(forTerminalID:)) and change the setter to
setComposerPresented(forTerminalID:isPresented:) (or an overloaded version) so
they read/write composerDismissedTerminalIDs using terminalID.rawValue; update
toggleComposer(forTerminalID:) and presentAndFocusComposer(forTerminalID:) to
call these new/updated helpers instead of relying on selectedTerminalID, and
adjust any other callers (mentioned ranges 2209-2215, 2231-2234, 2292-2299)
accordingly.

3417-3425: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle alreadySubscribed == false through one shared subscribe-ack path.

TerminalEventSubscriptionAck now carries the “registration was missing and got reinstalled” case, but the start-ack handler still collapses everything to ack.isSubscribed. Right now only the watchdog probe path repairs alreadySubscribed == false; a plain re-subscribe that repairs a lost registration can still leave mounted surfaces and the workspace list stale until a later watchdog cycle.

As per coding guidelines, "When a behavior is exposed through multiple entrypoints (keyboard shortcut, command palette, context menu, CLI, settings, debug menu), implement one shared action/model path and verify every entrypoint that should invoke it. Do not patch one surface while leaving the others with duplicated logic."

Also applies to: 3624-3632

🤖 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 `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 3417 - 3425, The start-ack path currently collapses
TerminalEventSubscriptionAck to ack.isSubscribed and misses the
.subscribed(alreadySubscribed: false) repair case; change the start-ack handler
to switch on TerminalEventSubscriptionAck and treat
.subscribed(alreadySubscribed: false) the same as the watchdog probe repair path
(invoke the shared re-subscribe/repair routine that refreshes mounted surfaces
and the workspace list), reusing the same method used by the watchdog probe to
avoid duplicated logic; also update the other subscribe-ack handling site
referenced around the 3624–3632 change to call that same shared repair routine
so all entrypoints (re-subscribe, watchdog, UI actions) converge on one code
path.

Source: Coding guidelines

lawrencecchen and others added 8 commits June 11, 2026 17:29
The Release-sim bisect (2026-06-10) caught the liveness watchdog firing
"render-grid stream silent for 10499ms, re-subscribing" every ~10.5s
forever, plus "subscribe failed reason=start: requestTimedOut", while
the Mac kept delivering events. Each false fire makes the Mac re-replay
the full grid (constant repaint, battery and bandwidth waste).

Two reproduced defects, expressed through the real RPC transport and the
real consumer path against a scripted host:

1. renderGridEventsArrivingDuringStartSubscribeAreConsumed: events the
   transport delivers while the mobile.events.subscribe ack is still in
   flight pile up unconsumed in the subscription stream buffer, because
   the listener task awaits the ack before starting the for-await
   consumer loop that stamps the liveness clock. A healthy establishing
   stream therefore looks silent to the watchdog, and the watchdog's
   resync then cancels its own in-flight subscribe, which surfaces as
   requestTimedOut.

2. watchdogDoesNotResubscribeHealthyIdleStream: a healthy idle terminal
   legitimately emits zero events (the Mac dedupes render-grid frames by
   row signature and stateSeq), so wall-clock silence alone cannot
   distinguish idle from dead. The watchdog tears down and full-replays
   a perfectly healthy subscription every threshold window.

watchdogStillResubscribesGenuinelyDeadStream pins the watchdog's
original purpose (the ~85s silent-death hang) so the fix cannot regress
it. Includes a DEBUG-only seam to run one watchdog evaluation
deterministically with the injected clock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ribe ack

Fixes the watchdog false-fire loop the 2026-06-10 Release-sim bisect
caught: "render-grid stream silent for 10499ms, re-subscribing" every
~10.5s forever on a healthy connection, plus "subscribe failed
reason=start: requestTimedOut", with the Mac re-replaying the full grid
each cycle (constant repaint, battery and bandwidth waste).

Root cause, two halves:

1. Silence is not death. A healthy idle terminal pushes zero events
   (MobileTerminalRenderObserver dedupes unchanged frames by row
   signature and stateSeq, and cursor blink changes neither), so the
   watchdog's wall-clock silence check fired on every idle stream once
   per threshold window, forever. The watchdog had no positive liveness
   signal at all.

2. The liveness clock was stamped only inside the listener's for-await
   loop, which did not start until resolveTerminalOutputTransport and
   the mobile.events.subscribe ack both completed. Events delivered
   during that establishment window were buffered unconsumed in the
   subscription stream, invisible to the clock; each watchdog fire then
   cancelled its own in-flight start subscribe, which
   MobileCoreRPCSession.cancelPendingRequest surfaces as requestTimedOut
   (the real wire timeout is 30s, so the 10.5s cadence of that log was
   the watchdog, not the network), and the dying generation marked the
   connection unavailable underneath the fresh one.

Fix, structurally:

- One liveness ownership point: recordTerminalEventStreamLiveness() is
  stamped by every consumed envelope, by a successful host probe, and
  (as the generation reset) when a watchdog generation is armed; the
  watchdog reads the same record, and generation identity (listenerID)
  guards every transition.
- The silence threshold is now a suspicion, not a verdict: a crossing
  runs a bounded auth-exempt mobile.host.status probe over the same
  transport the events ride on. Probe answered = stream healthy but
  quiet, stamp and stay quiet. Probe failed = re-check silence (events
  may have resumed mid-probe), then run the existing teardown +
  re-subscribe + replay recovery. The ~85s silent-death case still
  heals: dead transport fails the probe at livenessProbeTimeoutNanoseconds
  (default 3s, runtime-injectable).
- The start subscribe ack now runs concurrently with consumption
  (beginTerminalEventSubscriptionStart): the ack is a server-side enable
  handshake, not a delivery precondition, so the consumer loop starts
  immediately and the clock stays coupled to actual event arrival.
  Success/failure is acted on only while the generation is current, so
  a superseded ack can no longer mark the connection unavailable, and a
  cancelled ack logs as cancelled instead of a fake wire timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…status

Autoreview P1 on the previous probe design: a mobile.host.status answer
proves the RPC channel but not the event subscription, so a dropped or
wedged server-side registration behind a live channel would reset the
liveness clock forever and mask the staleness.

The probe is now an idempotent mobile.events.subscribe for the SAME
stream id and current topics. A completed round-trip proves the
transport the events ride on is alive AND (re)installs the registration,
and the host's subscription tracker re-evaluates producer demand on
every replace, so the failure mode the finding describes self-heals
instead of being masked. The probe still restarts nothing: no listener
teardown, no replay, no stream interruption. The deadline bounds the
whole attempt including pre-wire token work, so a wedged token provider
cannot pin the single-flight probe slot.

Tests updated to the final semantics: the teardown signals on a healthy
idle stream are now listener restarts (a second mobile.host.status
capability resolve) and replay traffic, since the probe itself
legitimately re-sends mobile.events.subscribe. Both liveness tests
remain red against the pre-fix sources (verified by checking out the
commit-1 code under the amended tests: idle stream restarts plus
replayCount 2, and the establishment-window event never delivered).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…irs a lost registration

Autoreview P1 round 2: a successful probe could have just REINSTALLED a
registration the host had lost, and render-grid deltas emitted during
the gap were never delivered, so stamping liveness and returning would
leave the phone stale until unrelated activity happened to repaint the
affected rows.

The Mac's mobile.events.subscribe acknowledgement now reports
already_subscribed (whether the stream id was registered on the
connection before the idempotent replace). The phone's probe
distinguishes the outcomes: already_subscribed=true (or absent, for
older Macs whose registrations cannot outlive the connection anyway) is
the healthy-idle case, stamp and stay quiet; already_subscribed=false
means the probe repaired a lost registration, so it stamps AND requests
a catch-up replay for every mounted surface, without restarting the
listener (the phone-side stream is intact; only the host-side
registration was missing).

New regression test probeRepairingLostSubscriptionReplaysMountedSurfaces
drives the lost-registration case end to end through the scripted host:
replay re-requested, no listener restart, original stream still
consuming afterwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sh on repair

Two autoreview P2 fixes:

1. A cancelled probe from a previous watchdog generation could complete
   late and nil out a newer generation's in-flight probe handle, breaking
   the single-flight guard and orphaning the newer probe from lifecycle
   cancellation. The slot now carries an ownership token
   (renderGridLivenessProbeID): only the probe holding it may clear the
   slot; superseded probes return without touching it.

2. The repaired-registration path only replayed terminal surfaces, but
   the same registration carries workspace.updated, so workspace
   create/rename/delete events emitted during the gap were missed too.
   The repair now also re-fetches the authoritative workspace list via
   the existing scheduleWorkspaceListRefreshFromEvent path, asserted in
   the repair regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ubscribe RPCs are non-interactive host activity

Review fixes: a successful liveness probe now calls markMacConnectionHealthy() so a transient RPC failure can't leave the status UI stuck unavailable on an idle terminal, and mobile.events.subscribe/unsubscribe no longer count as interactive mobile activity so the ~9s idle probe can't starve host work gated on mobile quiet (TabManager background git/PR refresh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Greptile P2: Task.sleep is a flagged primitive in production Swift; the
file's sanctioned timing primitive is DispatchSourceTimer (the watchdog
tick already uses it). Same behavior: deadline cancels the in-flight
probe, probe completion cancels the deadline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…refresh budget

Split MobileShellRenderGridLivenessTests.swift (583 > 500 new-file
threshold) into the 4 regression tests (212 lines) plus
MobileShellRenderGridLivenessTestSupport.swift (381 lines: injected
clock, scripted host router, transport mocks, connected-store builder).

Budget refresh accepts the remaining growth as known debt:
MobileShellComposite.swift +238 (the watchdog probe machinery is
single-flight state coupled to the composite's private connection
state; extracting it would force ~15 members internal — precedent:
the composer merge refreshed this same file's budget) and
MobileHostService.swift +14 (alreadySubscribed ack field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lawrencecchen
lawrencecchen force-pushed the feat-ios-watchdog-falsefire branch from 40e6c53 to bb1e595 Compare June 12, 2026 00:31
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Merge-train adjudication of the two open review findings, deferred as follow-ups rather than blockers:

  1. CodeRabbit: start-ack collapses the subscription ack to isSubscribed and skips the alreadySubscribed == false replay/repair. For reason=start a fresh install is the expected outcome of a brand-new listener generation, and the resync path replays the requested surfaces unconditionally right after refreshTerminalEventSubscription; the input_seq_wait refresh escalates to a full restart+replay via the pending-seq tracker when a surface stays behind, so missed deltas self-heal. Converging the start path onto the probe path is the right refactor and belongs to the liveness-subsystem extraction follow-up.

  2. Codex: a stale start-task timeout can stop a listener whose events already flow. Real but narrow edge (start ack lost while events ride the same healthy connection). A correct guard needs a saw-event-this-generation flag because arming the watchdog stamps the liveness clock, which is new state and new test surface; bolting it onto this stabilization PR risks more than the edge costs. Same follow-up as (1).

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

Caution

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

⚠️ Outside diff range comments (1)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

2794-2799: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Revalidate the connection after awaiting paired-Mac persistence.

connect(...) is generation-guarded before this await, but not after it. If signOut() or another teardown runs while persistPairedMacFromTicket(ticket) is in flight, this stale continuation still reapplies the old workspace list and flips connectionState back to .connected even though remoteClient was already cleared.

Suggested fix
                     clearPairingError()
                     await persistPairedMacFromTicket(ticket)
+                    guard generation == connectionGeneration,
+                          isSignedIn,
+                          remoteClient === client else { return nil }
                     applyRemoteWorkspaceList(response, preferActiveTicketTarget: workspaceListRequest.preferActiveTicketTarget)
                     syncSelectedTerminalForWorkspace()
                     connectionState = .connected
🤖 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 `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 2794 - 2799, The continuation after await
persistPairedMacFromTicket(ticket) can run against a torn-down client; capture
and revalidate the connection generation (or check remoteClient) before applying
state. In MobileShellComposite's connect(...) flow, save a local snapshot of the
current generation/token or the presence of remoteClient before the await, and
after await compare it to the current generation (and ensure remoteClient is
still non-nil); only then call applyRemoteWorkspaceList(...),
syncSelectedTerminalForWorkspace(), set connectionState = .connected and
markMacConnectionHealthy(); otherwise abort the continuation to avoid reapplying
stale state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1847-1868: The task may resume after awaiting
pairedMacStore.loadAll and then perform an upsert despite the serialized
"currentness" check; re-check the ifStillCurrent condition after the await and
before calling pairedMacStore.upsert so the task aborts if it is no longer the
current writer. In practice, inside the closure passed to
performSerializedPairedMacWrite (the block that uses pairedMacStore.loadAll and
then upsert), call the same ifStillCurrent check again (awaiting it if it's
async) after computing displayName and before executing
pairedMacStore.upsert(macDeviceID:..., markActive: true, ...), and return early
if the check indicates this operation is stale.

In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift`:
- Around line 46-67: The OutputCollector mounted with collector.mount(store:
store, surfaceID: "live-terminal") isn't guaranteed to be unmounted on failures;
add a defer { collector.unmount() } immediately after the mount call so
unmount() always runs even if subsequent helpers like `#require`(box.get()) or
renderGridEventFrame throw, ensuring the store and terminalOutputStream task are
released on error paths.

In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift`:
- Around line 159-160: The unsubscribe branch for "mobile.events.unsubscribe"
should clear the scripted subscription state so the router's
hasActiveSubscription is set to false (or call the existing
clearScriptedSubscription/clearSubscription helper) before returning the result;
modify the case that currently returns try? Self.resultFrame(id: id, result:
[:]) to first clear the subscription state (referencing hasActiveSubscription or
clearScriptedSubscription) and then return Self.resultFrame(id: id, result:
[:]).
- Around line 229-279: The transport currently allows late frames to be consumed
after close because receive() drains pendingFrames before checking isClosed and
deliver(_:) still appends frames when closed; update receive() to check isClosed
first and return nil immediately if closed (rather than draining pendingFrames),
change deliver(_:) to drop frames when isClosed is true instead of appending to
pendingFrames or resuming waiters, and have close() clear pendingFrames as well
as resume and clear receiveWaiters; reference the functions receive(),
deliver(_:), close() and the properties pendingFrames, receiveWaiters, isClosed
when making these changes.
- Around line 378-380: Replace the non-fatal test check so the helper aborts
immediately on a failed scripted connect: where the code currently does let
connected = await store.connectPairingURL(try attachURL(for: ticket)) followed
by `#expect`(connected, "scripted connect must succeed"), change that `#expect` to a
hard failure (e.g. precondition(connected, "scripted connect must succeed") or
fatalError with the same message) so connectPairingURL/attachURL failures stop
the test run instead of returning a possibly disconnected store.

In `@Sources/Mobile/MobileHostService.swift`:
- Around line 320-387: This file mixes new status/auth logic with an oversized
MobileHostService; extract the status shaping and verification policy into one
or more sibling types under Sources/Mobile (e.g., a MobileHostStatusManager or
MobileHostStatusPayload and MobileHostStatusVerifier types) and move
implementations of publicStatusPayload(routesPayload:),
identityStatusPayload(routesPayload:), networkStatusResult(for:), and any
verifier-cache/limiter logic (MobileHostStatusVerificationLimiter,
MobileHostPublicStatusCache, verifiedStackCaller(for:)) into those new files;
then have MobileHostService call the new type(s) for payload creation and
verification so the service file keeps only lifecycle/transport/RPC dispatch
concerns and the status/auth logic becomes unit-testable and localized.
- Around line 2180-2184: The response payload currently returns "stream_id" in
MobileHostService.handleSubscriptionRPC but the absence of "stream_id" observed
in daemon/remote/cmd/cmuxd-remote/main.go refers to proxy.stream.subscribe (not
mobile.events.subscribe), so update the handling to: make
MobileHostService.handleSubscriptionRPC explicitly produce the
MobileEventSubscribeResponse for mobile.events.subscribe (always include
stream_id when applicable) and ensure proxy.stream.subscribe responses omit
stream_id; refactor MobileHostService by extracting RPC subscription logic into
smaller methods (e.g., separate handleSubscriptionRPC,
buildMobileEventSubscribeResponse, buildProxyStreamSubscribeResponse) to
separate concerns and avoid mixing protocol paths, and update the response
construction to clearly document/emit the correct keys per RPC type.

---

Outside diff comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 2794-2799: The continuation after await
persistPairedMacFromTicket(ticket) can run against a torn-down client; capture
and revalidate the connection generation (or check remoteClient) before applying
state. In MobileShellComposite's connect(...) flow, save a local snapshot of the
current generation/token or the presence of remoteClient before the await, and
after await compare it to the current generation (and ensure remoteClient is
still non-nil); only then call applyRemoteWorkspaceList(...),
syncSelectedTerminalForWorkspace(), set connectionState = .connected and
markMacConnectionHealthy(); otherwise abort the continuation to avoid reapplying
stale state.
🪄 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

Run ID: 588b5735-55ba-4786-9cae-eda2468b2263

📥 Commits

Reviewing files that changed from the base of the PR and between 40e6c53 and bb1e595.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (6)
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift
  • Sources/Mobile/MobileHostService.swift

@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

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 7

Caution

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

⚠️ Outside diff range comments (1)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

2794-2799: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Revalidate the connection after awaiting paired-Mac persistence.

connect(...) is generation-guarded before this await, but not after it. If signOut() or another teardown runs while persistPairedMacFromTicket(ticket) is in flight, this stale continuation still reapplies the old workspace list and flips connectionState back to .connected even though remoteClient was already cleared.

Suggested fix
                     clearPairingError()
                     await persistPairedMacFromTicket(ticket)
+                    guard generation == connectionGeneration,
+                          isSignedIn,
+                          remoteClient === client else { return nil }
                     applyRemoteWorkspaceList(response, preferActiveTicketTarget: workspaceListRequest.preferActiveTicketTarget)
                     syncSelectedTerminalForWorkspace()
                     connectionState = .connected
🤖 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 `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 2794 - 2799, The continuation after await
persistPairedMacFromTicket(ticket) can run against a torn-down client; capture
and revalidate the connection generation (or check remoteClient) before applying
state. In MobileShellComposite's connect(...) flow, save a local snapshot of the
current generation/token or the presence of remoteClient before the await, and
after await compare it to the current generation (and ensure remoteClient is
still non-nil); only then call applyRemoteWorkspaceList(...),
syncSelectedTerminalForWorkspace(), set connectionState = .connected and
markMacConnectionHealthy(); otherwise abort the continuation to avoid reapplying
stale state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1847-1868: The task may resume after awaiting
pairedMacStore.loadAll and then perform an upsert despite the serialized
"currentness" check; re-check the ifStillCurrent condition after the await and
before calling pairedMacStore.upsert so the task aborts if it is no longer the
current writer. In practice, inside the closure passed to
performSerializedPairedMacWrite (the block that uses pairedMacStore.loadAll and
then upsert), call the same ifStillCurrent check again (awaiting it if it's
async) after computing displayName and before executing
pairedMacStore.upsert(macDeviceID:..., markActive: true, ...), and return early
if the check indicates this operation is stale.

In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift`:
- Around line 46-67: The OutputCollector mounted with collector.mount(store:
store, surfaceID: "live-terminal") isn't guaranteed to be unmounted on failures;
add a defer { collector.unmount() } immediately after the mount call so
unmount() always runs even if subsequent helpers like `#require`(box.get()) or
renderGridEventFrame throw, ensuring the store and terminalOutputStream task are
released on error paths.

In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift`:
- Around line 159-160: The unsubscribe branch for "mobile.events.unsubscribe"
should clear the scripted subscription state so the router's
hasActiveSubscription is set to false (or call the existing
clearScriptedSubscription/clearSubscription helper) before returning the result;
modify the case that currently returns try? Self.resultFrame(id: id, result:
[:]) to first clear the subscription state (referencing hasActiveSubscription or
clearScriptedSubscription) and then return Self.resultFrame(id: id, result:
[:]).
- Around line 229-279: The transport currently allows late frames to be consumed
after close because receive() drains pendingFrames before checking isClosed and
deliver(_:) still appends frames when closed; update receive() to check isClosed
first and return nil immediately if closed (rather than draining pendingFrames),
change deliver(_:) to drop frames when isClosed is true instead of appending to
pendingFrames or resuming waiters, and have close() clear pendingFrames as well
as resume and clear receiveWaiters; reference the functions receive(),
deliver(_:), close() and the properties pendingFrames, receiveWaiters, isClosed
when making these changes.
- Around line 378-380: Replace the non-fatal test check so the helper aborts
immediately on a failed scripted connect: where the code currently does let
connected = await store.connectPairingURL(try attachURL(for: ticket)) followed
by `#expect`(connected, "scripted connect must succeed"), change that `#expect` to a
hard failure (e.g. precondition(connected, "scripted connect must succeed") or
fatalError with the same message) so connectPairingURL/attachURL failures stop
the test run instead of returning a possibly disconnected store.

In `@Sources/Mobile/MobileHostService.swift`:
- Around line 320-387: This file mixes new status/auth logic with an oversized
MobileHostService; extract the status shaping and verification policy into one
or more sibling types under Sources/Mobile (e.g., a MobileHostStatusManager or
MobileHostStatusPayload and MobileHostStatusVerifier types) and move
implementations of publicStatusPayload(routesPayload:),
identityStatusPayload(routesPayload:), networkStatusResult(for:), and any
verifier-cache/limiter logic (MobileHostStatusVerificationLimiter,
MobileHostPublicStatusCache, verifiedStackCaller(for:)) into those new files;
then have MobileHostService call the new type(s) for payload creation and
verification so the service file keeps only lifecycle/transport/RPC dispatch
concerns and the status/auth logic becomes unit-testable and localized.
- Around line 2180-2184: The response payload currently returns "stream_id" in
MobileHostService.handleSubscriptionRPC but the absence of "stream_id" observed
in daemon/remote/cmd/cmuxd-remote/main.go refers to proxy.stream.subscribe (not
mobile.events.subscribe), so update the handling to: make
MobileHostService.handleSubscriptionRPC explicitly produce the
MobileEventSubscribeResponse for mobile.events.subscribe (always include
stream_id when applicable) and ensure proxy.stream.subscribe responses omit
stream_id; refactor MobileHostService by extracting RPC subscription logic into
smaller methods (e.g., separate handleSubscriptionRPC,
buildMobileEventSubscribeResponse, buildProxyStreamSubscribeResponse) to
separate concerns and avoid mixing protocol paths, and update the response
construction to clearly document/emit the correct keys per RPC type.

---

Outside diff comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 2794-2799: The continuation after await
persistPairedMacFromTicket(ticket) can run against a torn-down client; capture
and revalidate the connection generation (or check remoteClient) before applying
state. In MobileShellComposite's connect(...) flow, save a local snapshot of the
current generation/token or the presence of remoteClient before the await, and
after await compare it to the current generation (and ensure remoteClient is
still non-nil); only then call applyRemoteWorkspaceList(...),
syncSelectedTerminalForWorkspace(), set connectionState = .connected and
markMacConnectionHealthy(); otherwise abort the continuation to avoid reapplying
stale state.
🪄 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

Run ID: 588b5735-55ba-4786-9cae-eda2468b2263

📥 Commits

Reviewing files that changed from the base of the PR and between 40e6c53 and bb1e595.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (6)
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift
  • Sources/Mobile/MobileHostService.swift
🛑 Comments failed to post (7)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

1847-1868: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-check ifStillCurrent after the loadAll await.

The new display-name fallback adds a suspension between the serialized currentness check and the upsert(markActive: true). If the user disconnects, forgets the Mac, or switches to another Mac during loadAll, this stale task still writes the old row back as active.

Suggested fix
         await performSerializedPairedMacWrite(ifStillCurrent: ifStillCurrent) { [weak self] in
             guard let self else { return }
             var displayName = ticketDisplayName
             if displayName == nil {
                 let knownMacs = (try? await pairedMacStore.loadAll(stackUserID: nil)) ?? []
                 let matches = knownMacs.filter { $0.macDeviceID == ticket.macDeviceID }
                 displayName = (matches.first { $0.stackUserID == stackUserID } ?? matches.first)?
                     .displayName
             }
+            if let ifStillCurrent, !ifStillCurrent() { return }
             do {
                 try await pairedMacStore.upsert(
                     macDeviceID: ticket.macDeviceID,
                     displayName: displayName,
                     routes: ticket.routes,
                     markActive: true,
                     stackUserID: stackUserID
                 )
-                self.hasKnownPairedMac = true
+                if let ifStillCurrent, !ifStillCurrent() { return }
+                self.hasKnownPairedMac = true
             } catch {
                 mobileShellLog.error("paired mac store upsert failed: \(String(describing: error), privacy: .public)")
             }
         }
🤖 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 `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1847 - 1868, The task may resume after awaiting
pairedMacStore.loadAll and then perform an upsert despite the serialized
"currentness" check; re-check the ifStillCurrent condition after the await and
before calling pairedMacStore.upsert so the task aborts if it is no longer the
current writer. In practice, inside the closure passed to
performSerializedPairedMacWrite (the block that uses pairedMacStore.loadAll and
then upsert), call the same ifStillCurrent check again (awaiting it if it's
async) after computing displayName and before executing
pairedMacStore.upsert(macDeviceID:..., markActive: true, ...), and return early
if the check indicates this operation is stale.
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift (1)

46-67: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tear down each mounted OutputCollector with defer.

mount(store:surfaceID:) starts an unstructured task over terminalOutputStream. Right now cleanup only happens on the happy path, so any thrown #require/helper failure after mount leaves the collector subscribed and the store retained until the stream terminates. Put defer { collector.unmount() } immediately after each mount to keep failures isolated.

🤖 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
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift`
around lines 46 - 67, The OutputCollector mounted with collector.mount(store:
store, surfaceID: "live-terminal") isn't guaranteed to be unmounted on failures;
add a defer { collector.unmount() } immediately after the mount call so
unmount() always runs even if subsequent helpers like `#require`(box.get()) or
renderGridEventFrame throw, ensuring the store and terminalOutputStream task are
released on error paths.
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift (3)

159-160: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear the scripted subscription on mobile.events.unsubscribe.

The router keeps hasActiveSubscription == true even after an unsubscribe succeeds. That makes the next subscribe after a client-driven teardown look like already_subscribed: true, which collapses the distinction between a clean restart and the “lost registration repaired in place” path this PR is exercising.

Suggested fix
-        case "mobile.events.unsubscribe", "mobile.terminal.replay", "mobile.terminal.viewport":
+        case "mobile.events.unsubscribe":
+            hasActiveSubscription = false
+            return try? Self.resultFrame(id: id, result: [:])
+        case "mobile.terminal.replay", "mobile.terminal.viewport":
             return try? Self.resultFrame(id: id, result: [:])
🤖 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
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift`
around lines 159 - 160, The unsubscribe branch for "mobile.events.unsubscribe"
should clear the scripted subscription state so the router's
hasActiveSubscription is set to false (or call the existing
clearScriptedSubscription/clearSubscription helper) before returning the result;
modify the case that currently returns try? Self.resultFrame(id: id, result:
[:]) to first clear the subscription state (referencing hasActiveSubscription or
clearScriptedSubscription) and then return Self.resultFrame(id: id, result:
[:]).

229-279: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Drop late frames once the transport is closed.

close() marks the transport closed, but receive() still drains pendingFrames before honoring isClosed, and deliver(_:) still queues frames after close. If a held probe/subscribe response is released after teardown, the old client can still consume that stale ack from a “closed” transport, which weakens the late-completion/generation-ownership coverage these tests are supposed to validate.

Suggested fix
     func receive() async throws -> Data? {
-        if !pendingFrames.isEmpty {
-            return pendingFrames.removeFirst()
-        }
         if isClosed {
             return nil
         }
+        if !pendingFrames.isEmpty {
+            return pendingFrames.removeFirst()
+        }
         return await withCheckedContinuation { continuation in
             receiveWaiters.append(continuation)
         }
@@
     func close() async {
         isClosed = true
+        pendingFrames = []
         let waiters = receiveWaiters
         receiveWaiters = []
         for waiter in waiters {
             waiter.resume(returning: nil)
         }
@@
     func deliver(_ frame: Data) {
+        guard !isClosed else { return }
         if receiveWaiters.isEmpty {
             pendingFrames.append(frame)
             return
         }
🤖 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
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift`
around lines 229 - 279, The transport currently allows late frames to be
consumed after close because receive() drains pendingFrames before checking
isClosed and deliver(_:) still appends frames when closed; update receive() to
check isClosed first and return nil immediately if closed (rather than draining
pendingFrames), change deliver(_:) to drop frames when isClosed is true instead
of appending to pendingFrames or resuming waiters, and have close() clear
pendingFrames as well as resume and clear receiveWaiters; reference the
functions receive(), deliver(_:), close() and the properties pendingFrames,
receiveWaiters, isClosed when making these changes.

378-380: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fail fast when the scripted connection precondition breaks.

#expect(connected, ...) records the setup failure but still returns a possibly disconnected store. Since every liveness test shares this helper, a connect regression turns into several follow-on poll timeouts instead of stopping at the real broken precondition.

Suggested fix
     let connected = await store.connectPairingURL(try attachURL(for: ticket))
-    `#expect`(connected, "scripted connect must succeed")
+    try `#require`(connected, "scripted connect must succeed")
     return store
🤖 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
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift`
around lines 378 - 380, Replace the non-fatal test check so the helper aborts
immediately on a failed scripted connect: where the code currently does let
connected = await store.connectPairingURL(try attachURL(for: ticket)) followed
by `#expect`(connected, "scripted connect must succeed"), change that `#expect` to a
hard failure (e.g. precondition(connected, "scripted connect must succeed") or
fatalError with the same message) so connectPairingURL/attachURL failures stop
the test run instead of returning a possibly disconnected store.
Sources/Mobile/MobileHostService.swift (2)

320-387: 🧹 Nitpick | 🔵 Trivial | 🏗️ Heavy lift

Split the new status/auth logic out of this file instead of growing MobileHostService.swift further.

This PR adds more status shaping, verification-policy, and verifier-cache code into a file that already owns listener lifecycle, connection tracking, RPC dispatch, and per-connection transport. Moving the status/auth pieces into sibling types under Sources/Mobile/ would keep this path testable without deepening an already oversized service file. As per coding guidelines, production Swift files over 800 lines should be flagged, and files that mix responsibilities should be split. Based on learnings, keep that split local to Sources/ rather than proposing a new SwiftPM package here.

Also applies to: 1227-1279, 1725-1742

🤖 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/Mobile/MobileHostService.swift` around lines 320 - 387, This file
mixes new status/auth logic with an oversized MobileHostService; extract the
status shaping and verification policy into one or more sibling types under
Sources/Mobile (e.g., a MobileHostStatusManager or MobileHostStatusPayload and
MobileHostStatusVerifier types) and move implementations of
publicStatusPayload(routesPayload:), identityStatusPayload(routesPayload:),
networkStatusResult(for:), and any verifier-cache/limiter logic
(MobileHostStatusVerificationLimiter, MobileHostPublicStatusCache,
verifiedStackCaller(for:)) into those new files; then have MobileHostService
call the new type(s) for payload creation and verification so the service file
keeps only lifecycle/transport/RPC dispatch concerns and the status/auth logic
becomes unit-testable and localized.

Sources: Coding guidelines, Learnings


2180-2184: 🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 'already_subscribed|stream_id|subscribed' \
  Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift \
  Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift \
  Sources/Mobile/MobileHostService.swift \
  daemon/remote/cmd/cmuxd-remote/main.go

Repository: manaflow-ai/cmux

Length of output: 17889


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find the actual producer for `mobile.events.subscribe` (if any) in cmuxd-remote.
rg -n --hidden "mobile\.events\.subscribe" daemon/remote/cmd/cmuxd-remote/main.go

# 2) See whether `mobile.events.subscribe` is implemented via/proxies to `proxy.stream.subscribe`.
rg -n --hidden "proxy\.stream\.subscribe|handleProxyStreamSubscribe" daemon/remote/cmd/cmuxd-remote/main.go

# 3) Get the current line count for the oversized-file concern.
wc -l Sources/Mobile/MobileHostService.swift

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "== matches for mobile.events.subscribe in daemon/remote/cmd/cmuxd-remote/main.go =="
rg -n --hidden "mobile\.events\.subscribe" daemon/remote/cmd/cmuxd-remote/main.go || true

echo "== matches for proxy.stream.subscribe in daemon/remote/cmd/cmuxd-remote/main.go =="
rg -n --hidden "proxy\.stream\.subscribe|handleProxyStreamSubscribe" daemon/remote/cmd/cmuxd-remote/main.go || true

echo "== line count =="
wc -l Sources/Mobile/MobileHostService.swift

Repository: manaflow-ai/cmux

Length of output: 518


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Where is `mobile.events.subscribe` handled/produced?
rg -n --hidden "case \"mobile\.events\.subscribe\"|mobile\.events\.subscribe" daemon/remote cmd Sources Packages cmuxd ghostty ghosttyKit 2>/dev/null || true

# Does any code map `mobile.events.subscribe` -> `proxy.stream.subscribe`?
rg -n --hidden "mobile\.events\.subscribe.*proxy\.stream\.subscribe|proxy\.stream\.subscribe.*mobile\.events\.subscribe" . || true

# Where is `MobileEventSubscribeResponse` used (ensures what fields are expected)?
rg -n --hidden "MobileEventSubscribeResponse" Packages Sources daemon/remote . || true

Repository: manaflow-ai/cmux

Length of output: 5714


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== proxy.stream.subscribe call sites =="
rg -n --hidden "\"proxy\.stream\.subscribe\"|proxy\.stream\.subscribe" Sources Packages daemon/remote cmd . || true

echo "== MobileEventSubscribeResponse decode call sites =="
rg -n --hidden "MobileEventSubscribeResponse\.decode|MobileEventSubscribeResponse" Sources Packages daemon/remote cmd . || true

echo "== proxy.stream.subscribe ack decoding =="
rg -n --hidden "proxy\.stream\.subscribe.*decode|decode.*proxy\.stream\.subscribe|handleProxyStreamSubscribe" Sources Packages . || true

Repository: manaflow-ai/cmux

Length of output: 5307


Clarify stream_id rollout: omission is for proxy.stream.subscribe, not mobile.events.subscribe

  • MobileHostService.handleSubscriptionRPC returns mobile.events.subscribe success with a non-empty "stream_id".
  • daemon/remote/cmd/cmuxd-remote/main.go success payload lacking "stream_id" is from proxy.stream.subscribe (not mobile.events.subscribe), so it’s not the MobileEventSubscribeResponse ack path.
  • Sources/Mobile/MobileHostService.swift is 2340 lines; split/extract RPC subscription/protocol handling into smaller units to reduce mixed responsibilities and improve maintainability.
🤖 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/Mobile/MobileHostService.swift` around lines 2180 - 2184, The
response payload currently returns "stream_id" in
MobileHostService.handleSubscriptionRPC but the absence of "stream_id" observed
in daemon/remote/cmd/cmuxd-remote/main.go refers to proxy.stream.subscribe (not
mobile.events.subscribe), so update the handling to: make
MobileHostService.handleSubscriptionRPC explicitly produce the
MobileEventSubscribeResponse for mobile.events.subscribe (always include
stream_id when applicable) and ensure proxy.stream.subscribe responses omit
stream_id; refactor MobileHostService by extracting RPC subscription logic into
smaller methods (e.g., separate handleSubscriptionRPC,
buildMobileEventSubscribeResponse, buildProxyStreamSubscribeResponse) to
separate concerns and avoid mixing protocol paths, and update the response
construction to clearly document/emit the correct keys per RPC type.

lawrencecchen and others added 2 commits June 11, 2026 18:11
Reproduces the ipad CI failure of
macConnectionStatusMarksUnavailableWhenEventStreamCloses: with the start
handshake made concurrent, a transport that drops before the subscribe
ack lands lets the stream-end restart supersede the listener generation;
the parked ack's failure verdict is then silently dropped by its
generation guard and recovery loops in reconnecting forever instead of
reaching unavailable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the event stream ends while the generation's enable handshake is
still in flight, stop and mark the Mac unavailable instead of restarting:
the restart superseded the generation, swallowed the handshake's failure
verdict, and livelocked reconnecting against a closed transport. A
healthy stream cannot hit this path (it only ends when the transport
drops), and a mid-life stream end with a completed handshake still takes
the existing restart path. MobileShellComposite budget +15 for the guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Update to the adjudication above: the start-path concern family was partially RIGHT, with a different mechanism than either bot described. The ipad run failed macConnectionStatusMarksUnavailableWhenEventStreamCloses deterministically: when the transport drops before the start ack lands, the stream-end restart supersedes the listener generation, the parked ack's failure verdict gets dropped by its generation guard, and recovery livelocks in reconnecting (iphone passed only because the ack failure sometimes wins the race). Fixed in two commits, red test (98dbf2c) then fix (ab8f139): a stream that ends before its handshake completes is treated as a failed start (stop + unavailable) instead of restarting. Deferral of the two follow-ups stands; the convergence refactor now has a pinned regression test.

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ab8f139. Configure here.

diagnosticLog?.record(DiagnosticEvent(.error))
stopTerminalRefreshPolling()
markMacConnectionUnavailable()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pending ack misread as failed start

Medium Severity

When the terminal event stream ends, the handler treats any in-flight terminalSubscriptionStartTask as a failed start and calls markMacConnectionUnavailable with connectionRecoveryFailed, instead of using the reconnecting path. That check is only “subscribe ack not finished,” not “handshake never delivered.” If events were already consumed while the start ack was still in flight, the stream can end with a healthy consumer but the UI still lands in failed recovery with polling stopped.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ab8f139. Configure here.

@lawrencecchen
lawrencecchen merged commit dcab2aa into main Jun 12, 2026
28 checks passed
lawrencecchen added a commit that referenced this pull request Jun 12, 2026
…watchdog, #5869)

Branch advanced past the 11b carry: stream-end-before-start-ack now fails
the start (converges to unavailable instead of reconnect livelock), and
liveness test fixtures split into MobileShellRenderGridLivenessTestSupport.
Composite keeps dog's dismiss-sync notification refresh lines the branch
lacks; the liveness test file takes the branch's current form.
lawrencecchen added a commit that referenced this pull request Jun 12, 2026
Keeps dog's dismiss-sync extras over main's pre-dismiss watchdog form
and deduplicates the reconcile-sweep call that two dismiss folds each
inserted into the subscribe path.
lawrencecchen added a commit that referenced this pull request Jun 12, 2026
The reconcile sweep now runs on every successful subscribe ack, and the
render-grid liveness tests (post-#5869) drive full subscribe flows on
MobileShellComposite.preview(), whose default
SystemDeliveredNotificationClearer hits
UNUserNotificationCenter.current(), which traps in processes without a
bundle proxy (swift test) and would mutate the real notification
center in SwiftUI previews. preview() now injects a no-op clearer; the
app composition root still gets the system clearer by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 12, 2026
The reconcile sweep now runs on every successful subscribe ack, and the
render-grid liveness tests (post-#5869) drive full subscribe flows on
MobileShellComposite.preview(), whose default
SystemDeliveredNotificationClearer hits
UNUserNotificationCenter.current(), which traps in processes without a
bundle proxy (swift test) and would mutate the real notification
center in SwiftUI previews. preview() now injects a no-op clearer; the
app composition root still gets the system clearer by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 12, 2026
The reconcile sweep now runs on every successful subscribe ack, and the
render-grid liveness tests (post-#5869) drive full subscribe flows on
MobileShellComposite.preview(), whose default
SystemDeliveredNotificationClearer hits
UNUserNotificationCenter.current(), which traps in processes without a
bundle proxy (swift test) and would mutate the real notification
center in SwiftUI previews. preview() now injects a no-op clearer; the
app composition root still gets the system clearer by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 12, 2026
The reconcile sweep now runs on every successful subscribe ack, and the
render-grid liveness tests (post-#5869) drive full subscribe flows on
MobileShellComposite.preview(), whose default
SystemDeliveredNotificationClearer hits
UNUserNotificationCenter.current(), which traps in processes without a
bundle proxy (swift test) and would mutate the real notification
center in SwiftUI previews. preview() now injects a no-op clearer; the
app composition root still gets the system clearer by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 13, 2026
The reconcile sweep now runs on every successful subscribe ack, and the
render-grid liveness tests (post-#5869) drive full subscribe flows on
MobileShellComposite.preview(), whose default
SystemDeliveredNotificationClearer hits
UNUserNotificationCenter.current(), which traps in processes without a
bundle proxy (swift test) and would mutate the real notification
center in SwiftUI previews. preview() now injects a no-op clearer; the
app composition root still gets the system clearer by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 13, 2026
The reconcile sweep now runs on every successful subscribe ack, and the
render-grid liveness tests (post-#5869) drive full subscribe flows on
MobileShellComposite.preview(), whose default
SystemDeliveredNotificationClearer hits
UNUserNotificationCenter.current(), which traps in processes without a
bundle proxy (swift test) and would mutate the real notification
center in SwiftUI previews. preview() now injects a no-op clearer; the
app composition root still gets the system clearer by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 13, 2026
The reconcile sweep now runs on every successful subscribe ack, and the
render-grid liveness tests (post-#5869) drive full subscribe flows on
MobileShellComposite.preview(), whose default
SystemDeliveredNotificationClearer hits
UNUserNotificationCenter.current(), which traps in processes without a
bundle proxy (swift test) and would mutate the real notification
center in SwiftUI previews. preview() now injects a no-op clearer; the
app composition root still gets the system clearer by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 13, 2026
…adge (#5916)

* Notifications: cross-device dismiss-sync + stable id end-to-end

A Mac notification now carries its stable TerminalNotification.id through
the APNs push (cmux.notificationId + apns-collapse-id), so a dismiss on one
surface targets the same notification on the other.

iOS->Mac: the iOS cmux.terminal category gets .customDismissAction; a swipe
(or tap) routes UNNotificationDismissActionIdentifier -> MobilePushCoordinator
-> a new notification.dismiss mobile-host RPC -> TerminalNotificationStore
.markRead. Mac->iOS: user-driven dismiss/clear emits a notification.dismissed
peer event the phone uses to removeDeliveredNotifications. Echo loop broken by
the markRead/remove no-op guards.

Also fixes show-on-iOS: the push never carried the id, so the delivered banner
had an OS-random identifier unlinked from the Mac store.

Caveat (Phase 1): iOS->Mac dismiss only fires while the phone is attached +
foregrounded; the detached/lock-screen case needs a reverse web/APNs path
(deferred follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Notifications: authoritative unread badge + reconcile sweep + cold dismiss lane

Badge: the iOS app icon badge mirrors the Mac's unread-notification-entry
count. Every lane SETS the absolute server-/Mac-computed total, never local
arithmetic, so drift self-heals:
- live: new notification.badge peer event emitted from
  refreshUnreadPresentation() (the same chokepoint as the Dock badge), and
  notification.dismissed now carries unread_count too
- push: every APNs push (notify + dismiss) carries badgeCount, stamped by the
  web service as aps.badge
- reconcile: new notification.reconcile mobile-host RPC; on every
  (re)subscribe the phone sends its delivered banner ids, the Mac answers
  with the handled subset (read, or tombstoned dismissed/removed) plus the
  unread count, and the phone clears those banners and sets the badge

Cold dismiss lane: when the Mac dismisses/clears while no phone is
live-subscribed, PhonePushClient.forwardDismissed sends a silent
content-available push (priority 5, no alert, aps.badge + cmux.dismissedIds,
no collapse id) so a pocketed phone drops the badge instantly and removes
banners when iOS grants the strictly budgeted background wake; the reconcile
sweep heals anything iOS defers.

Bursts coalesce structurally in a drain task chunked to the server's
MAX_PUSH_DISMISS_IDS cap. Dismiss pushes carry only opaque UUIDs and a count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Tests: badge derivation, reconcile classification, dismiss-push policy

Web (bun test): parsePushPayload dismiss kind (text-free, ids required,
bounded), badge-count tolerance (malformed ignored, runaway clamped),
dismiss payload shape (content-available + badge + dismissedIds, nothing
visible), sender headers (dismiss never collapses onto the banner and
downgrades to apns-priority 5; notify keeps immediate priority), and the
two parse-shape tests updated for the new fields.

Mac (cmuxTests): notification.reconcile RPC classifies handled =
read-in-store or tombstoned-removed, leaves unread and foreign ids alone,
and returns the authoritative unread count; empty delivered_ids is a valid
badge-only sync; a markUnread resurrect beats a stale dismiss tombstone;
the phone badge counts unread notification entries only (workspace manual
unread indicators feed the Dock badge, not the phone).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WIP snapshot: rate-limit outage safety push

* Notifications: durable phone→Mac dismiss outbox (enqueue-first send, flush on resubscribe)

A banner swipe can arrive when the dismiss cannot be sent: the app may be
background-launched from Notification Center before any scene (and store)
exists, and the attach channel is usually down in the background. Park
every phone-side dismiss in PendingNotificationDismissQueue (UserDefaults-
backed) before attempting the notification.dismiss RPC, remove it only on
confirmed delivery, and flush the queue at the start of every successful
(re)subscribe, before the reconcile sweep so the Mac's answer already
reflects the replayed swipes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notifications: send the cold-lane dismiss push unconditionally (multi-device)

The APNs push route fans out to every iOS device token registered for the
user, but the cold-lane dismiss push was suppressed whenever ANY client was
live-subscribed to notification.dismissed. With two devices, the attached
one starved the offline one: it got neither the live event nor the silent
dismiss+badge push and kept stale banners until its next reconcile. Drop
the subscriber gate; the push is idempotent on the live device (removing
an already-removed banner is a no-op, the badge is an absolute SET) and
bursts coalesce in PhonePushClient.forwardDismissed.

Found by autoreview (P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notifications: persist dismiss tombstones; cap+dedupe notification.dismiss ids

Two autoreview P2s. The reconcile lane's tombstone ring was in-memory
only, so a Mac relaunch forgot that removed/cleared notifications had been
handled and a phone whose silent dismiss push was dropped kept the stale
banner forever; the bounded ring is now write-through persisted to
UserDefaults and lazy-loaded (session restore keeps notification ids, so
the persisted ids stay meaningful across relaunch). notification.dismiss
now caps its id array at 256 like notification.reconcile and dedupes ids
before processing, so a malformed frame cannot force unbounded main-actor
work and a duplicated id cannot double-count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notifications: defer superseded-banner dismiss behind the replacement push

Autoreview P1. The supersede path dismissed the old phone banner
unconditionally, but the replacement banner push goes through
PhonePushClient.forward's 1s per-tab/surface throttle; in a rapid burst
the phone lost its only banner for a still-unread notification. forward
now reports whether the push was queued, recordNotification stashes the
superseded ids in a bounded per-key buffer (tombstoning them immediately
so reconcile stays correct), and deliverNotificationSideEffects emits the
dismiss only after a replacement push is queued, so clear+replace is
atomic from the phone's perspective. A throttled burst now degrades to
the pre-existing behavior (stale-text banner stays visible) instead of
no banner at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notifications: map dismisses via cmux.notificationId; emit superseded dismiss when no replacement will forward

Two autoreview findings. The iOS clearer trusted that a delivered remote
notification's request identifier equals the Mac UUID (apns-collapse-id
equivalence), which is observed OS behavior, not a contract; it now
enumerates delivered notifications and maps the authoritative
cmux.notificationId payload key to actual request identifiers for removal,
and reports Mac ids (payload key, request-identifier fallback) to the
reconcile sweep. And the superseded-banner dismiss deferral could leave
ids stuck forever on paths where no replacement push is forwarded
(suppressed/focused, non-desktop effects, forwarding off); those paths now
emit the dismiss immediately, and only a genuinely throttled replacement
defers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notifications: await banner removal so background dismiss wakes finish their work

Autoreview P1. application(_:didReceiveRemoteNotification:) returned its
fetch result while removeDelivered was still an unstructured Task, so iOS
could suspend the process before the delivered-banner enumeration/removal
ran, silently breaking the cold dismiss lane it exists for. The
DeliveredNotificationClearing removal is now async and awaited end to end
(app delegate → coordinator → clearer, and composite event/reconcile
paths), so the delegate reports completion only after the banners are
gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notifications: spell out UUID(uuidString:) in tombstone load for style consistency

The unlabeled initializer reference compiled fine, but every other
UUID-parsing site in this file uses the explicit closure form; match it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notifications: drain stale superseded-banner stash on user-driven read/clear paths

Autoreview P2. Ids stashed after a throttled replacement push only
flushed on a future successful forward for the same key; if the user then
read, removed, or cleared the current notification, those paths emitted
only the current ids, so an offline phone kept the stale old banner until
its next reconcile. Every user-driven read/clear/remove now drains the
buffer at the matching scope (precise tab/surface key for single-entry
ops, tab-wide for tab ops, everything for clear-all/mark-all-read) and
rides the drained ids along on its emit. Once the current notification is
handled there is no replacement left to wait for, so emitting the
stragglers is exactly right.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notifications: DocC docs for public package symbols (policy)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split over-budget files for the length guard; no behavior changes

The dismiss-sync feature pushed five tracked files over their length
budgets. Extractions instead of budget refreshes:

- cmuxTests/TerminalAndGhosttyTests.swift: the dismiss-sync tests move
  to cmuxTests/NotificationDismissSyncTests.swift (wired in pbxproj).
- Sources/TerminalController.swift: the mobile notification.dismiss /
  notification.reconcile handlers and the mobile workspace-action gate
  move to Sources/TerminalController+MobileNotificationSync.swift.
- Sources/Mobile/MobileHostService.swift: mobileHostCapabilities moves
  to Sources/Mobile/MobileHostService+Capabilities.swift.
- MobileShellComposite.swift: the dismiss-sync section moves to
  MobileShellComposite+NotificationDismissSync.swift and the dogfood
  feedback round-trip to MobileShellComposite+DogfoodFeedback.swift;
  four members lose `private` (same module) for the split.
- Sources/TerminalNotificationStore.swift: the pre-existing 600-line
  NotificationSoundSettings enum moves wholesale to
  Sources/NotificationSoundSettings.swift. Its call graph is too
  entangled to split under 500 lines without ~12 access relaxations,
  so it becomes a tracked budget entry at its actual 603 lines;
  TerminalNotificationStore's budget tightens 2527 -> 2274. The TSV is
  re-sorted (it had drifted out of size order).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retrigger checks (Actions never created a check suite for 5bd0f90)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Preview stores get a no-op delivered-notification clearer

The reconcile sweep now runs on every successful subscribe ack, and the
render-grid liveness tests (post-#5869) drive full subscribe flows on
MobileShellComposite.preview(), whose default
SystemDeliveredNotificationClearer hits
UNUserNotificationCenter.current(), which traps in processes without a
bundle proxy (swift test) and would mutate the real notification
center in SwiftUI previews. preview() now injects a no-op clearer; the
app composition root still gets the system clearer by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Notifications: gate superseded-banner buffering on the real send decision

The superseded-banner dismiss was deferred whenever forwarding was merely
enabled, but PhonePushClient.forward also drops the replacement push when the
.onlyWhenAway presence gate sees the Mac as active. In that path the old phone
banner was stashed waiting for a replacement push that never came, so a phone
that received the earlier banner (while the Mac was away) kept a stale banner
after the Mac cleared it, until the next foreground reconcile.

Add PhonePushClient.willForwardReplacement(), which mirrors forward's enable +
presence gate but ignores the burst throttle (the throttle is the one
legitimate defer-and-flush case). recordNotification now uses it, so a
presence-suppressed supersede emits the dismiss immediately (live + cold APNs
lanes) instead of stashing it. Unit-tested against the shared client with an
injected presence monitor and scratch defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Notifications: extract SupersededPhoneDismissBuffer to its own file

File-organization policy: the per-tab/surface superseded-banner stash was a
second major type appended to TerminalNotificationStore.swift. Move it to
SupersededPhoneDismissBuffer.swift (no behavior change) and wire it into the
cmux target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 13, 2026
…#5596/#5625/#5628) over current main

Beta queue (#5876/#5872/#5869/#5875/#5927/#5912/#5726/#5776/#5916) is now on
main; conflicts resolved by taking main as authoritative for the merged
workspace-list/notifications/read-state/close surface, while preserving the
carry-set: terminal.paste capability (#5572), hidden-input strings (#5596),
smooth-scroll/scroll-to-bottom (#5628), and the live notifications feed
(notificationsStore + mobile.notifications.list/mark_read dispatch). Dropped
the superseded mute design. Capability flags unified onto main's computed
supportedHostCapabilities set (added computed supportsTerminalPaste +
DEBUG supportsDogfoodChecklist). xcstrings merged (HEAD-precedence union,
mute keys dropped). pbxproj took HEAD consistently; budget regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — ab8f1395 Deployed Jun 12, 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.

1 participant