Repository navigation
fix(daemon): a full disk under cmux-tui offers Restart with the reason, keeps starting it, and a refused New Workspace says why (restore fuzz h2, h3) - #30496
Conversation
…-tui offered with the reason after a failed ensure, and the reconnect keeps starting the daemon with backoff Restore fuzz finding h2 (STATUS 3149, chaos diskfull, feat d1f0e63): the daemon dies on a full disk, its reconnect's server ensure fails, and when the disk has space again the app neither starts a new daemon by itself nor (2 of 3) offers Restart cmux-tui. - restartIsOfferedWithTheReasonWhenTheReconnectsEnsureFailsOnAFullDisk: a stand-in ensure fails with server.spawn_failed (no space left on device); offersRestart stays false because it is set only for a stuck-owner failure (DaemonLauncher.isStuckOwnerFailure), and no reason reaches debug.daemon. - theAppKeepsStartingTheDaemonWithBackoffAfterAFullDiskAndReconnectsOnceAStartWorks: the app's local connection on a ManualClock; 30 failed starts then one that works. RetryPolicy.reconnect spends its 10 timed retries and parks for an event only, which a disk with space again never sends. Measurer commit: tests only, no product change.
…ed New Workspace shows one 'did not apply: the disk is full' refusal; a scripted one gets the typed error and no HUD Restore fuzz finding h3 (STATUS 3213): on a full daemon state disk the daemon refuses the person's New Workspace (pin-workspace or create-workspace: operation.failed, database or disk is full); the app keeps no row but the person sees nothing. A stand-in daemon refuses every create and pin that way. Measurer commit: tests only, no product change.
…Restart with the reason, and the app keeps starting it until it runs Restore fuzz h2 (STATUS 3149, chaos diskfull): the daemon died on a full disk, its reconnect's server ensure failed, and when the disk had space again the app neither started a new daemon by itself nor, in 2 of 3 runs, offered Restart cmux-tui. A window that showed content had no Restart control at all. - RetryPolicy.keepsTrying (localReconnect, localFirstConnect): a loop that starts its own peer keeps one attempt per backoff cap (30 s, on the injected clock) past its timed budget instead of waiting for an event only; an explicit event (activation, Retry Now, Restart) starts the spacing over. Remote machines and other loops keep event-only waits. - EnsureOutcome records every failed ensure (DaemonStartFailure: disk_full when the error says so or the state volume has under 64 MB free, else failed). DaemonRecovery offers Restart for any failed start, not only a stuck owner; the automatic restart still needs stuck-owner failures. - The reason reaches the person: the launch window's status line names a full disk, and a window that shows content gets DaemonRecoveryNotice, an until-closed toast with the reason and Restart cmux-tui (same confirm as the launch window's button). debug.daemon reports start_failure and start_failure_detail. - Two new strings in CmuxNextDaemonRecovery's catalog, all 21 locales it carries. - DaemonRestartTests asserted Restart hidden after a non-stuck start failure: that assertion encoded this bug and now expects Restart shown. No daemon state is deleted or reset.
…did not apply: the disk is full"; a scripted run keeps the typed error
Restore fuzz h3 (STATUS 3213): the daemon refused the person's create (pin-workspace or
create-workspace: operation.failed, database or disk is full) and the person saw nothing, because
newWorkspace handed its failure to registry.track, which keeps it only for a capturing caller.
- ActionRegistry.trackOrReport(_:as:): a capturing caller (CLI, socket, MCP) gets the typed failure;
a run nobody waits on shows it once where a failed change shows ("<action> did not apply:
<reason>"), unless the person already saw it. newWorkspace uses it.
- DaemonError.isDiskFull (SQLite "database or disk is full", ENOSPC; in the message, the details'
reason or a failed server ensure), shared with DaemonStartFailure.
- ActionWorkFailure.shownReason: the person's words where the message is the daemon's own error. A
daemon failure on a full disk gets RefusalStrings.diskFull ("the disk is full"), the one disk-full
reason; cmux-tui's start notice (h2) now formats the same reason.
- refusal.daemon.diskFull in Refusals.xcstrings (21 locales); daemon.recovery.diskFull becomes a
format string around it (21 locales).
… the notice is internal and holds its daemon Review of the h2 fix before its first build: no all-static public type (namespace convention), no weak capture inside the notice's Sendable observation (the app's local daemon lives as long as the app; a test stops the notice), DaemonStartFailure needs no nonisolated in a module that is not main-actor by default.
…he daemon, and names the commands the stand-in saw The first fleet run (job 17626e37) failed the person test on its own assertion, but the scripted test saw no failure at all in 0.1 s: these checks tell a create that never reached the daemon from a failure the app dropped.
…n the main actor (fleet compile, job b7b1b48e)
…the app does at launch
Job 271c01eb showed perform("newTab") returned false in both tests: AppServices alone binds no
action, so the earlier red (17626e37) failed without the action running at all.
…start already shows before it (h2: every failed start offers it) Job d6feab63: the wait for Restart shown returned at once (it shows since the first failed ensure), so the count was read before Retry Now's ensure ran.
…_failure, not English text (REVIEW.md rule 1)
There was a problem hiding this comment.
10 issues found across 23 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryNotice.swift">
<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryNotice.swift:123">
P2: If no visible workspace window exists when the offer starts, `show` returns without a toast and nothing retries it. The notice then never appears, even after a window opens while the daemon is still down. Retry `show` when a workspace window becomes available, for example on window key or visibility changes or on app activation.</violation>
<violation number="2" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryNotice.swift:136">
P2: `CmuxToastCenter` reports `.closed` for both the toast's close button and a closing host window. This sets the app-wide `closedByPerson` flag when a workspace window closes, suppressing Restart in later windows until the daemon reconnects; distinguish a person closing the toast from window teardown.</violation>
<violation number="3" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryNotice.swift:138">
P2: When the recovery toast is oldest and a fourth toast arrives, `CmuxToastCenter` removes it as `.replaced`; this callback only clears `handle`, while the watcher observes daemon state only. The Restart action then stays absent until daemon state changes; reapply the current offer after `.replaced`.</violation>
</file>
<file name="Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonRestartTests.swift">
<violation number="1" location="Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonRestartTests.swift:176">
P3: The struct doc at the top of this file still says Restart is offered only after a stuck-owner ensure failure. This change makes Restart appear after any failed start, so update that doc to match.</violation>
</file>
<file name="Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonStartFailure.swift">
<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonStartFailure.swift:32">
P2: The disk-full markers omit EDQUOT. cmux-tui's `is_disk_full` also matches "disk quota exceeded" and "disc quota exceeded", so a quota-full start is reported as a generic failure. Add those markers.</violation>
<violation number="2" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonStartFailure.swift:43">
P2: Any start failure becomes `.diskFull` when free space is under 64 MB, even when the error has nothing to do with space. Consider applying the free-space check only to ambiguous failures, such as the "owner did not become ready" case, so a missing binary or a stuck owner keeps the generic reason.</violation>
</file>
<file name="Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Replace.swift">
<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Replace.swift:62">
P2: `replaceUnresponsive()` records a success but not a failed start. A failure from Restart's own `server ensure` never reaches `ensureOutcome`, so no failure category or free-space check is recorded for it. Record the failure in the catch path here as `ensureRecordingOutcome` does, or the full-disk reason stays hidden behind the generic "failed" status.</violation>
<violation number="2" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Replace.swift:62">
P2: When `stateDirectory` is nil, this checks `runtimeBase` instead of cmux-tui’s default state volume, so a full state disk can be classified as a generic failure. Check the default state root’s volume in this case.</violation>
</file>
<file name="Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/RetryPacer.swift">
<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/RetryPacer.swift:107">
P3: `failed()` now keeps returning a delay past `timedRetries` for `keepsTrying`, but its method and wait-helper docs still promise event-only waiting. Update those docs to describe the exception.</violation>
</file>
<file name="Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryControls.swift">
<violation number="1" location="Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryControls.swift:77">
P3: A failed Restart can leave `restartFailure` set while a later ensure still reports a full disk, so this branch hides the more actionable disk-full status. Check `startFailure?.isDiskFull` before showing the generic restart error.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
|
|
||
| private func show(_ toast: CmuxToast) { | ||
| if let handle, !handle.isDismissed, handle.toast == toast { return } | ||
| guard let window = window() else { return } |
There was a problem hiding this comment.
P2: If no visible workspace window exists when the offer starts, show returns without a toast and nothing retries it. The notice then never appears, even after a window opens while the daemon is still down. Retry show when a workspace window becomes available, for example on window key or visibility changes or on app activation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryNotice.swift, line 123:
<comment>If no visible workspace window exists when the offer starts, `show` returns without a toast and nothing retries it. The notice then never appears, even after a window opens while the daemon is still down. Retry `show` when a workspace window becomes available, for example on window key or visibility changes or on app activation.</comment>
<file context>
@@ -0,0 +1,148 @@
+
+ private func show(_ toast: CmuxToast) {
+ if let handle, !handle.isDismissed, handle.toast == toast { return }
+ guard let window = window() else { return }
+ // The same id replaces a notice that shows in that window (a new reason), in place.
+ if let handle, !handle.isDismissed { end() }
</file context>
| /// `error`'s failure; `freeBytes` is the free space on the state's volume (nil: unknown). | ||
| init(_ error: any Error, freeBytes: UInt64?) { | ||
| let detail = String(describing: error) | ||
| let lowSpace = freeBytes.map { $0 < Self.lowSpaceBytes } ?? false |
There was a problem hiding this comment.
P2: Any start failure becomes .diskFull when free space is under 64 MB, even when the error has nothing to do with space. Consider applying the free-space check only to ambiguous failures, such as the "owner did not become ready" case, so a missing binary or a stuck owner keeps the generic reason.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonStartFailure.swift, line 43:
<comment>Any start failure becomes `.diskFull` when free space is under 64 MB, even when the error has nothing to do with space. Consider applying the free-space check only to ambiguous failures, such as the "owner did not become ready" case, so a missing binary or a stuck owner keeps the generic reason.</comment>
<file context>
@@ -0,0 +1,81 @@
+ /// `error`'s failure; `freeBytes` is the free space on the state's volume (nil: unknown).
+ init(_ error: any Error, freeBytes: UInt64?) {
+ let detail = String(describing: error)
+ let lowSpace = freeBytes.map { $0 < Self.lowSpaceBytes } ?? false
+ self.init(kind: Self.saysDiskFull(detail) || lowSpace ? .diskFull : .failed, detail: detail)
+ }
</file context>
| static let lowSpaceBytes: UInt64 = 64 << 20 | ||
|
|
||
| /// The words a failure caused by a full disk carries (ENOSPC, SQLite's SQLITE_FULL). | ||
| private static let diskFullMarkers = ["no space left on device", "os error 28", "enospc", "disk is full", "disk full"] |
There was a problem hiding this comment.
P2: The disk-full markers omit EDQUOT. cmux-tui's is_disk_full also matches "disk quota exceeded" and "disc quota exceeded", so a quota-full start is reported as a generic failure. Add those markers.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonStartFailure.swift, line 32:
<comment>The disk-full markers omit EDQUOT. cmux-tui's `is_disk_full` also matches "disk quota exceeded" and "disc quota exceeded", so a quota-full start is reported as a generic failure. Add those markers.</comment>
<file context>
@@ -0,0 +1,81 @@
+ static let lowSpaceBytes: UInt64 = 64 << 20
+
+ /// The words a failure caused by a full disk carries (ENOSPC, SQLite's SQLITE_FULL).
+ private static let diskFullMarkers = ["no space left on device", "os error 28", "enospc", "disk is full", "disk full"]
+
+ /// Whether `text` (an error's words) says the disk is full.
</file context>
| private static let diskFullMarkers = ["no space left on device", "os error 28", "enospc", "disk is full", "disk full"] | |
| private static let diskFullMarkers = ["no space left on device", "os error 28", "enospc", "disk is full", "disk full", "disk quota exceeded", "disc quota exceeded"] |
| // A cancelled ensure (its connection torn down, or shutdown) says nothing about the owner. | ||
| if !Task.isCancelled, !(error is CancellationError) { ensureOutcome.note(error) } | ||
| if !Task.isCancelled, !(error is CancellationError) { | ||
| let free = DaemonStartFailure.freeBytes(at: configuration.stateDirectory ?? configuration.runtimeBase) |
There was a problem hiding this comment.
P2: replaceUnresponsive() records a success but not a failed start. A failure from Restart's own server ensure never reaches ensureOutcome, so no failure category or free-space check is recorded for it. Record the failure in the catch path here as ensureRecordingOutcome does, or the full-disk reason stays hidden behind the generic "failed" status.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Replace.swift, line 62:
<comment>`replaceUnresponsive()` records a success but not a failed start. A failure from Restart's own `server ensure` never reaches `ensureOutcome`, so no failure category or free-space check is recorded for it. Record the failure in the catch path here as `ensureRecordingOutcome` does, or the full-disk reason stays hidden behind the generic "failed" status.</comment>
<file context>
@@ -58,7 +58,10 @@ extension DaemonLauncher {
// A cancelled ensure (its connection torn down, or shutdown) says nothing about the owner.
- if !Task.isCancelled, !(error is CancellationError) { ensureOutcome.note(error) }
+ if !Task.isCancelled, !(error is CancellationError) {
+ let free = DaemonStartFailure.freeBytes(at: configuration.stateDirectory ?? configuration.runtimeBase)
+ ensureOutcome.note(error, freeBytes: free)
+ }
</file context>
| } | ||
| shown.onDismiss = { [weak self, weak shown] reason in | ||
| guard let self, let shown, handle === shown else { return } | ||
| handle = nil |
There was a problem hiding this comment.
P2: When the recovery toast is oldest and a fourth toast arrives, CmuxToastCenter removes it as .replaced; this callback only clears handle, while the watcher observes daemon state only. The Restart action then stays absent until daemon state changes; reapply the current offer after .replaced.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryNotice.swift, line 138:
<comment>When the recovery toast is oldest and a fourth toast arrives, `CmuxToastCenter` removes it as `.replaced`; this callback only clears `handle`, while the watcher observes daemon state only. The Restart action then stays absent until daemon state changes; reapply the current offer after `.replaced`.</comment>
<file context>
@@ -0,0 +1,148 @@
+ }
+ shown.onDismiss = { [weak self, weak shown] reason in
+ guard let self, let shown, handle === shown else { return }
+ handle = nil
+ if reason == .closed { closedByPerson = true }
+ }
</file context>
| handle = nil | |
| handle = nil | |
| if reason == .replaced { | |
| Task { @MainActor [weak self] in | |
| guard let self, self.observation != nil else { return } | |
| self.apply(self.current) | |
| } | |
| } |
| shown.onDismiss = { [weak self, weak shown] reason in | ||
| guard let self, let shown, handle === shown else { return } | ||
| handle = nil | ||
| if reason == .closed { closedByPerson = true } | ||
| } |
There was a problem hiding this comment.
P2: CmuxToastCenter reports .closed for both the toast's close button and a closing host window. This sets the app-wide closedByPerson flag when a workspace window closes, suppressing Restart in later windows until the daemon reconnects; distinguish a person closing the toast from window teardown.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryNotice.swift, line 136:
<comment>`CmuxToastCenter` reports `.closed` for both the toast's close button and a closing host window. This sets the app-wide `closedByPerson` flag when a workspace window closes, suppressing Restart in later windows until the daemon reconnects; distinguish a person closing the toast from window teardown.</comment>
<file context>
@@ -0,0 +1,148 @@
+ apply(current)
+ })
+ }
+ shown.onDismiss = { [weak self, weak shown] reason in
+ guard let self, let shown, handle === shown else { return }
+ handle = nil
</file context>
| shown.onDismiss = { [weak self, weak shown] reason in | |
| guard let self, let shown, handle === shown else { return } | |
| handle = nil | |
| if reason == .closed { closedByPerson = true } | |
| } | |
| shown.onDismiss = { [weak self, weak shown, weak window] reason in | |
| guard let self, let shown, handle === shown else { return } | |
| handle = nil | |
| guard reason == .closed else { return } | |
| Task { @MainActor [weak self, weak window] in | |
| guard let self, window?.isVisible == true else { return } | |
| closedByPerson = true | |
| } | |
| } |
| // A cancelled ensure (its connection torn down, or shutdown) says nothing about the owner. | ||
| if !Task.isCancelled, !(error is CancellationError) { ensureOutcome.note(error) } | ||
| if !Task.isCancelled, !(error is CancellationError) { | ||
| let free = DaemonStartFailure.freeBytes(at: configuration.stateDirectory ?? configuration.runtimeBase) |
There was a problem hiding this comment.
P2: When stateDirectory is nil, this checks runtimeBase instead of cmux-tui’s default state volume, so a full state disk can be classified as a generic failure. Check the default state root’s volume in this case.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Launch/DaemonLauncher+Replace.swift, line 62:
<comment>When `stateDirectory` is nil, this checks `runtimeBase` instead of cmux-tui’s default state volume, so a full state disk can be classified as a generic failure. Check the default state root’s volume in this case.</comment>
<file context>
@@ -58,7 +58,10 @@ extension DaemonLauncher {
// A cancelled ensure (its connection torn down, or shutdown) says nothing about the owner.
- if !Task.isCancelled, !(error is CancellationError) { ensureOutcome.note(error) }
+ if !Task.isCancelled, !(error is CancellationError) {
+ let free = DaemonStartFailure.freeBytes(at: configuration.stateDirectory ?? configuration.runtimeBase)
+ ensureOutcome.note(error, freeBytes: free)
+ }
</file context>
| // A start failure that is not a stuck owner (a spawn that failed, as on a full disk) offers | ||
| // Restart too: no daemon runs, so the person needs a way out (restore fuzz h2). Until then | ||
| // this asserted Restart hidden here, which left a full disk with nothing to press. | ||
| await Self.wait { Self.shown("daemon.restart", in: view) != nil } |
There was a problem hiding this comment.
P3: The struct doc at the top of this file still says Restart is offered only after a stuck-owner ensure failure. This change makes Restart appear after any failed start, so update that doc to match.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonRestartTests.swift, line 176:
<comment>The struct doc at the top of this file still says Restart is offered only after a stuck-owner ensure failure. This change makes Restart appear after any failed start, so update that doc to match.</comment>
<file context>
@@ -170,17 +170,21 @@ struct DaemonRestartTests {
+ // A start failure that is not a stuck owner (a spawn that failed, as on a full disk) offers
+ // Restart too: no daemon runs, so the person needs a way out (restore fuzz h2). Until then
+ // this asserted Restart hidden here, which left a full disk with nothing to press.
+ await Self.wait { Self.shown("daemon.restart", in: view) != nil }
let retry = Self.shown("daemon.retryNow", in: view)
#expect(retry != nil)
</file context>
| wokeBySocket = false | ||
| return failures > policy.timedRetries ? nil : backoff.next() | ||
| // Past the budget a loop that keeps trying stays at the backoff's cap (`Backoff.next` caps it). | ||
| return failures > policy.timedRetries && !policy.keepsTrying ? nil : backoff.next() |
There was a problem hiding this comment.
P3: failed() now keeps returning a delay past timedRetries for keepsTrying, but its method and wait-helper docs still promise event-only waiting. Update those docs to describe the exception.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/RetryPacer.swift, line 107:
<comment>`failed()` now keeps returning a delay past `timedRetries` for `keepsTrying`, but its method and wait-helper docs still promise event-only waiting. Update those docs to describe the exception.</comment>
<file context>
@@ -89,7 +103,8 @@ public struct RetryPacer: Sendable {
wokeBySocket = false
- return failures > policy.timedRetries ? nil : backoff.next()
+ // Past the budget a loop that keeps trying stays at the backoff's cap (`Backoff.next` caps it).
+ return failures > policy.timedRetries && !policy.keepsTrying ? nil : backoff.next()
}
</file context>
| if recovery.restartFailure != nil { return DaemonRecoveryStrings.failed } | ||
| return recovery.startFailure?.isDiskFull == true ? DaemonRecoveryStrings.diskFull : nil |
There was a problem hiding this comment.
P3: A failed Restart can leave restartFailure set while a later ensure still reports a full disk, so this branch hides the more actionable disk-full status. Check startFailure?.isDiskFull before showing the generic restart error.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextDaemonRecovery/DaemonRecoveryControls.swift, line 77:
<comment>A failed Restart can leave `restartFailure` set while a later ensure still reports a full disk, so this branch hides the more actionable disk-full status. Check `startFailure?.isDiskFull` before showing the generic restart error.</comment>
<file context>
@@ -37,27 +55,33 @@ enum DaemonRestartPrompt {
static func status(_ recovery: DaemonRecovery) -> String? {
if recovery.isRestarting { return DaemonRecoveryStrings.restarting }
- return recovery.restartFailure == nil ? nil : DaemonRecoveryStrings.failed
+ if recovery.restartFailure != nil { return DaemonRecoveryStrings.failed }
+ return recovery.startFailure?.isDiskFull == true ? DaemonRecoveryStrings.diskFull : nil
}
</file context>
| if recovery.restartFailure != nil { return DaemonRecoveryStrings.failed } | |
| return recovery.startFailure?.isDiskFull == true ? DaemonRecoveryStrings.diskFull : nil | |
| if recovery.startFailure?.isDiskFull == true { return DaemonRecoveryStrings.diskFull } | |
| if recovery.restartFailure != nil { return DaemonRecoveryStrings.failed } | |
| return nil |
|
Review (claude-review-subagent): SHIP at da8798b Correctness first. I read the diff and the head files in a detached worktree at da8798b. I found no blocking defect. The follow-ups at the end do not block this PR. 1. Binding rules
2. Notice UI. It is a standard 3. DaemonRestartTests. The change is the minimum. One 4. Localization. I parsed the catalogs myself.
5. Shared behavior. The menu, shortcut, palette, sidebar 6. Evidence (checked with
Follow-ups (do not block)
|
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
Not verified: macOS jobs did not run: compile and app tests were skipped on CI passes on Written by Default changesThis pull request changes 1 default at
Signed off on this commit by Merge receipt: merged as Regenerate the snapshot with Shipped in:
|
…till restarting it (#33099) * test(next): a briefly failing cmux-tui restart shows no 'could not start again' notice Red on base: the notice shows on the first failed server ensure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-a73aaf1602dba90b5a63 * fix(next): show 'cmux-tui could not start again' only once its starts keep failing The window notice (#30496) showed on the first failed server ensure after the local daemon's connection dropped, while the reconnect was still retrying by itself (a loaded Mac, a version handoff). DaemonRecovery now counts the failed starts (EnsureOutcome.failedInARow) and their duration: the notice shows after three failed starts over two minutes, or at once on a full disk, or after the person's own Restart failed. Restart stays offered meanwhile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-a73aaf1602dba90b5a63 * fix(next): log each failed cmux-tui start at notice level The reconnect's lines are .info (memory only), and a failed server ensure was not logged, so log show found nothing for the notice afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-a73aaf1602dba90b5a63 * test(next): the notice test restarts on a fast fixed retry policy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-a73aaf1602dba90b5a63 * fix(next): only the person's failed Restart shows the cmux-tui notice at once A failed automatic restart waits for the sustained window like any failed start (review follow-up). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-a73aaf1602dba90b5a63 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Restore fuzz (chaos diskfull) found two ways a full disk under cmux-tui's state leaves the person with nothing to do and no reason:
server ensurefailed. After the disk had space again, the app started no new daemon by itself (none within 60 s), and in 2 of 3 runs it did not offer Restart cmux-tui. A window that showed content had no Restart control at all. Pressing Restart (debug.daemon {action: restart}) recovered in about 1 s.operation.failed, "database or disk is full"). The app correctly kept no row, but showed no refusal, toast or dialog.After this change:
RetryPolicy.localReconnect,.localFirstConnect:keepsTrying) keep one attempt per 30 s backoff cap past their 10 timed retries, on the injected clock, instead of waiting for an event only. A disk that has space again sends no event. App activation, Retry Now and Restart still wake the loop at once and start the spacing over. Remote machines and every other loop keep event-only waits.EnsureOutcomerecords each failed ensure as aDaemonStartFailure(disk_fullwhen the error says so or the state volume has under 64 MB free, elsefailed).DaemonRecovery.offersRestartis true for any failed start, not only a stuck owner. The automatic restart still needs stuck-owner failures (UnresponsiveOwnerWatchis unchanged).debug.daemonreportsstart_failureandstart_failure_detail.DaemonRecoveryNoticeshows one until-closed toast (the standardCmuxToastCenter, so the toast's own motion gate and keys apply) in the workspace window the key or main window acts for. It says why cmux-tui is not running ("cmux-tui could not start: the disk is full. Free some space and cmux starts it again by itself.", or a short generic reason), and its button is Restart cmux-tui, which asks the sameCmuxDialogSpecconfirm as the launch window's button. It ends when the daemon is back, a restart starts or the offer ends. A cancelled confirm shows it again. Closed by the person, it stays closed until the daemon connects again.action.effect.failedpath. A scripted run (CLI, socket, MCP) still gets the typed daemon error and shows no HUD (ActionRegistry.trackOrReport(_:as:)).No daemon state is deleted or reset.
Cause (on the base, 88dcb89)
RetryPacer.swift:92returns nil afterRetryPolicy.reconnect's 10 timed retries (RetryPacer.swift:32). The reconnect loop then waits for a socket change or an explicit event only (DaemonConnection.swift:400).DaemonRecovery.swift:116offers Restart only when the last ensure looked stuck or the daemon does not answer.DaemonLauncher+Replace.swift:84counts onlyrestore_timeout,timedOutandserver.unavailableas stuck, so a spawn that failed on a full disk offered nothing.WindowController.swift:240shows the connecting view, which holds the only Retry and Restart controls, only when the window has no content.AppActions+Workspaces.swift:163handed the create's failure (:175) toregistry.track, which keeps it only for a capturing caller (ActionRegistry+Work.swift:75-76). A keyboard, menu or palette run captures nothing, so the failure was dropped. The red below proves it: the action ran (performtrue), the stand-in daemon receivedcreate-workspaceand refused it, and the refusal HUD count stayed 0.Changed assertion in an existing test
DaemonRestartTests.restartIsOfferedOnlyAfterAPlainEnsureFailedAndReplacesThenConnectsAgainasserted at:176that Restart is hidden after a start failure that is not a stuck owner (server.spawn_failed). That assertion encoded this bug: a full disk fails exactly that way and left nothing to press. It now expects Restart shown. Its wait after Retry Now waits for Retry Now's own ensure, because Restart already shows before the press. The rest of the test (the replace runs the same ensure plus--replace-unresponsive, then connects again with a plain ensure, one replace only) is unchanged and passes.Testing
Reds (each fails on its own assertion on feat with the tests only):
CmuxNextAppTests.DaemonRecoveryAfterFailedStartsTestsat 4d96f95, jobebaff5a8a29162f4c7189400:offersRestartfalse after a disk-full ensure (:103),debug.daemonoffers_restartfalse (:115), no reason exposed (:117); the reconnect parked for an event after 10 failed starts, did not reconnect, and stopped at 11 starts (:208,:212,:213).CmuxNextAppTests.NewWorkspaceDiskFullRefusalTestsat 71665d8 (branchfuzz-red/restore-h3-red), jobb0cc51eb433543f2a3f59099: the person's create reached the daemon and was refused, and no refusal showed (:91,:93,:95,:96). The scripted test passes on the base, as it should: it guards that a caller keeps the typed error.Greens: all 8 suites below passed (8 passed, 0 failed), 5 of 5, at da8798b (head):
4eb957f0ce1924f5d3579bef,1e64070fbc06abd921ce7893,c97b5b57e4f1019e01d73392,ba699c66be77e730685205db,cd94fc92fe25605088cf1ed5. Suites:DaemonRecoveryAfterFailedStartsTests,DaemonRecoveryNoticeTests(new: a window with content shows the notice with the disk-full reason and Restart; Restart asks first; a cancelled confirm shows it again),NewWorkspaceDiskFullRefusalTests,DaemonRestartTests,DaemonAutoRestartTests,BusyRefusalReconnectTests,RetryPacerSocketBudgetTests,UnresponsiveOwnerWatchTests. The same 8 suites also passed 5 of 5 at 4c8a6c2 (1ec96cb8,28b51b2e,bde4ea06,7d87de79,5fdd58f2), before the last commit replaced English-text assertions with the localized string and the typedstart_failure.DaemonRestartTestsalone on the base 88dcb89 passed (job7f107cbf400e92fe232f0f64): the one red it had at fff3cc7 came from this branch's offer change and is fixed.Compile: fleet compile-check CmuxNext with tests at da8798b passed (
940c168b2ab8f1e126d18a9c); CmuxNext + app at 4c8a6c2 passed (10b6bc8ad7719763d8f0b86d); Release app compile at 4c8a6c2 passed (1c5b9112ecd1b3d74f2464ec).prepush-gatepasses on every commit.Localization audit: three keys, each with all 21 locales its catalog carries, every one
translated(no copied English):refusal.daemon.diskFull("the disk is full",CmuxNextDaemonService/Resources/Refusals.xcstrings), the one disk-full reason shared by h2 and h3;daemon.recovery.diskFull(a format string around it) anddaemon.recovery.stopped(CmuxNextDaemonRecovery/Resources/Localizable.xcstrings).scripts/localization_catalog.py checkreports no error for these keys../scripts/localize-changescannot prepareCmuxNextDaemonRecovery(the package has several string catalogs), so I wrote those entries by hand. The tool's other reports (debt over the baseline in CmuxNextActions, CmuxNextSettings and other catalogs) are base debt in files this PR does not touch.Observed / Not run
Observed: the reds and greens above, run on the fleet; the compile-checks;
prepush-gate.Display proof at the head (the real app on a display worker, through the mini scheduler, exclusive host):
6bc9adc8699e6e31d2c11401at da8798b (gui-session-v1, cmuxs-Mac-mini-3, console session,cmux-ci run --display, CMUX_TEST_REQUIRE_GUI=1), exit 0. All 8 PR suites passed, 31 tests, none skipped: DaemonRecoveryAfterFailedStartsTests 2, DaemonRecoveryNoticeTests 1, NewWorkspaceDiskFullRefusalTests 2, DaemonRestartTests 3, DaemonAutoRestartTests 2, BusyRefusalReconnectTests 6, RetryPacerSocketBudgetTests 3, UnresponsiveOwnerWatchTests 12.74bf492462d9ec4492560f1c(head da8798b). Bundleh2h3fix-da8798ba, commit da8798b.fgh-h2-head-4202, capture-slot leaseslot-cmux19-2ee9e0291d4aon cmux19-perf, restore fuzz chaos diskfull with daemon kills,--visible, harness 855dff1 (fuzz(restore): diskfull app create probe runs New Workspace as a person #30531). 23 judged, 13 daemon kills, 0 violations, app_daemon_differ 0. After a daemon kill on the full disk, the window showed "disconnected" with its content (66 samples, no blank pane). When the space came back, the daemon came back by itself in 18.2 s with no Restart press. On the base it never did.fgh-h3-head-4203, leaseslot-cmux22-0c6402a26e98on cmux22-perf. The person's New Workspace (debug.toast perform newTab, origin user) on the full disk showed exactly one refusal, "New Workspace did not apply: the disk is full". No ghost workspace stayed, now or after a relaunch. The 4202 probe gave the same result.Not run:
DaemonCrashLoopTests(needs the real cmux-tui binary;keepsTryingkeeps its crash-loop socket limit, but I did not run it).ActionRegistryuser.Read only: the h2 fuzz runs that failed with
server.unavailable(1 of 3) also offered Restart before this change; the cause of that one ensure failure is not known.Changelog
Fixed: When cmux-tui stops and cannot start again (for example because the disk is full), cmux says why, offers Restart cmux-tui in the open window, and starts it again by itself once it can; a New Workspace refused on a full disk now says "did not apply: the disk is full"
Proof
No capture: the new notice is a standard
CmuxToastCentertoast (no new view or style). It shows only after the local cmux-tui died on a full disk, and the capture minis cannot reach that state yet.DaemonRecoveryNoticeTestschecks its text, action and confirm. The lead's diskfull fuzz round on a fleet build of this head checks it in the real app.Need help on this PR? Tag
@codesmith-botwith what you need.Summary by cubic
Fixes two restore-fuzz findings where a full disk under cmux-tui's state left the person with no visible state and no way out: the app now keeps starting its local daemon, offers Restart cmux-tui with the reason in any open window, and shows a refusal when a person's New Workspace is refused.
Daemon recovery (h2)
debug.daemonreportsstart_failureandstart_failure_detail.New Workspace refusal (h3)
Written for commit da8798b. Summary will update on new commits.