Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
2d038f7
test(cloud): reject unresolved startup attaches
austinywang Sep 14, 2026
36e7091
fix(cloud): make manual mirror startup readiness event driven
austinywang Sep 14, 2026
9cb904c
fix(cloud): finish startup presentation safeguards
austinywang Sep 14, 2026
54d5dec
fix(cloud): preserve provider recovery wiring
austinywang Sep 14, 2026
ed7a347
fix(cloud): allow readiness deadline to report failure
austinywang Sep 14, 2026
97897d0
fix(cloud): fence readiness across pane reveals
austinywang Sep 14, 2026
b186d4f
fix(cloud): document startup observer isolation
austinywang Sep 14, 2026
4a42807
fix(cloud): release startup observers on session teardown
austinywang Sep 14, 2026
d90d40d
fix(cloud): keep teardown cleanup within file budget
austinywang Sep 14, 2026
bb25cfd
fix(cloud): satisfy session file budget
austinywang Sep 14, 2026
bc1e148
fix(cloud): rearm startup deadline after reconnect reset
austinywang Sep 14, 2026
45f9b0c
Merge renderer health owner and retire parallel startup gate
austinywang Sep 14, 2026
7be826e
fix(cloud): refuse unresolved manual attachment IDs
austinywang Sep 14, 2026
77a80a8
fix(cloud): isolate terminal creation from provider refresh latency
austinywang Sep 14, 2026
af9a79d
test(cloud): reproduce duplicate creation during an in-flight retry
austinywang Sep 14, 2026
750b97f
fix(cloud): keep one active creation attempt per intent
austinywang Sep 14, 2026
b3909bb
test(cloud): preserve known terminal creation failure causes
austinywang Sep 14, 2026
0e0d51d
fix(cloud): preserve terminal intents through inline retry
austinywang Sep 14, 2026
381ba15
refactor(cloud): keep terminal request values in dedicated files
austinywang Sep 14, 2026
8dd76bd
test(cloud): retain cancellation ownership across retry and new intent
austinywang Sep 14, 2026
bd75fb1
fix(cloud): retain active retries until their lifecycle ends
austinywang Sep 14, 2026
655e930
fix(cloud): resolve explicit creation retries from daemon receipts
austinywang Sep 14, 2026
7f529ea
fix(build): make cloud leaf row inference explicit
austinywang Sep 14, 2026
342fd7e
fix(build): separate cloud port help visibility from its callback
austinywang Sep 14, 2026
8356a69
test(cloud): assert unresolved attachment refusal synchronously
austinywang Sep 14, 2026
95a1c36
fix(build): expose web view ownership to the extracted replay guard
austinywang Sep 14, 2026
ff78182
fix(build): move replay guard lookup out of the private extension
austinywang Sep 14, 2026
48dacae
test(build): avoid the NSWindow keyWindow property in drag fixture
austinywang Sep 14, 2026
6ab2c8d
Merge current main compile repairs and preserve Cloud ownership
austinywang Sep 14, 2026
475bae8
test(build): use the NSWindow rectangle conversion in drag fixture
austinywang Sep 14, 2026
a95b3a5
test(cloud): align fixtures with the post-revert retry owner
austinywang Sep 14, 2026
61af63f
fix(cloud): keep placement failures inline and bind Retry to its card
austinywang Sep 14, 2026
c74835b
Merge main's Cloud test fixture repairs
austinywang Sep 14, 2026
539cefa
test(cloud): reject duplicate fallback for an accepted split action
austinywang Sep 14, 2026
1dda34b
fix: preserve asynchronous Cloud split acceptance
austinywang Sep 14, 2026
a686548
fix: import split direction dependency
austinywang Sep 14, 2026
6cc99bc
test: import split direction in the shortcut regression
austinywang Sep 14, 2026
7dafd1a
test(cloud): exercise repeated reconnect and visibility cycles
austinywang Sep 14, 2026
a0f0ba7
fix(cloud): ignore session envelope churn in rename refresh
austinywang Sep 14, 2026
d127054
test(cloud): reject the connection card flash and false unavailable
austinywang Sep 14, 2026
885db29
fix(cloud): grace the connection card and reserve panes optimistically
austinywang Sep 14, 2026
5fa0ec7
fix(cloud): suppress transient terminal loading cards
austinywang Sep 14, 2026
373a53a
Merge origin/main and remove transient Cloud loading overlays
austinywang Sep 14, 2026
f28b3b2
fix(cloud): keep terminal recovery and creation indicators silent
austinywang Sep 14, 2026
1a2496f
l10n(cloud): localize the reconnect card and drop pending-panel keys
austinywang Sep 14, 2026
0f57bf7
fix(cloud): clear stale link errors after recovery
austinywang Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
785 changes: 420 additions & 365 deletions Resources/Localizable.xcstrings

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16969,11 +16969,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
direction: direction.canvasDirection
) != nil
}
return terminalContext.tabManager.createSplit(
return terminalContext.tabManager.createSplitOutcome(
tabId: terminalContext.workspaceId,
surfaceId: terminalContext.panelId,
direction: direction
) != nil
).isAccepted
}
if let workspace = tabManager?.selectedWorkspace,
workspace.layoutMode == .canvas {
Expand All @@ -16983,7 +16983,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
direction: direction.canvasDirection
) != nil
}
return tabManager?.createSplit(direction: direction) != nil
return tabManager?.createSplitOutcome(direction: direction).isAccepted ?? false
}()
#if DEBUG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
Expand Down
11 changes: 11 additions & 0 deletions Sources/Cloud/CloudDiagnosticFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ enum CloudDiagnosticFailure: String, Codable, Sendable, Error {
case .clientMissing, .spawnFailed, .exited: return .process
}
}
if let error = error as? CmuxTuiSurfaceProvider.ProviderError {
switch error {
case .notSignedIn: return .authentication
case .machineAsleep, .remoteWorkspaceNotFound, .remoteTabNotFound,
.noWorkspaceOnMachine, .remotePlacementUnavailable, .terminalExited: return .notFound
case .terminalNotCreated, .invalidSnapshot, .stateUnavailable,
.invalidPreviewURL, .localForwardURLUnavailable: return .response
case .terminalAttachTimedOut: return .timeout
case .snapshotOnly, .hubUnavailable: return .unsupported
}
}
if error is CloudMachineLinkManager.ManagerError { return .connectFailure(error) }
if error is DecodingError { return .response }
return .unknown
Expand Down
4 changes: 4 additions & 0 deletions Sources/Cloud/CloudTerminalAttachmentStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import Observation
final class CloudTerminalAttachmentStatus {
let machineID: String
private(set) var state: CloudTerminalAttachmentState = .attaching(attempt: 1)
/// One-shot style hook for owners that are not SwiftUI views (the workspace
/// clearing an optimistic pane's tab spinner). Set by the pane owner only.
@ObservationIgnored var onStateChange: (@MainActor (CloudTerminalAttachmentState) -> Void)?

init(machineID: String) {
self.machineID = machineID
Expand All @@ -19,5 +22,6 @@ final class CloudTerminalAttachmentStatus {
func update(_ state: CloudTerminalAttachmentState) {
guard self.state != state else { return }
self.state = state
onStateChange?(state)
}
}
77 changes: 77 additions & 0 deletions Sources/Cloud/CloudTerminalConnectionPresentationPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Foundation

/// When a native cloud pane shows its connection card.
///
/// The pane never shows a progress card: an attachment that is connecting or
/// reconnecting keeps its last frame (or stays blank when brand new) and simply
/// fills in when the stream is usable. A byte attachment normally becomes usable
/// within a few hundred milliseconds, and the provider re-runs `reconnect` on
/// every graph refresh, so any card on the first `.connecting` or
/// `.disconnected` sample flashed for the duration of a healthy handoff and
/// called a pane "unavailable" that automatic recovery repaired a moment later.
/// The only card is the actionable one: Reconnect, shown once automatic
/// recovery has kept failing for `failureGrace`, or at once when recovery has
/// been given up. A usable attachment clears it immediately.
struct CloudTerminalConnectionPresentationPolicy: Equatable, Sendable {
/// How long automatic recovery may keep failing before the pane offers Reconnect.
let failureGrace: Duration

init(failureGrace: Duration) {
precondition(failureGrace >= .zero)
self.failureGrace = failureGrace
}

/// Production bound: a few rounds of the provider's attachment backoff.
static let standard = Self(failureGrace: .seconds(8))

/// No grace: a disconnected attachment offers Reconnect at once. For tests
/// that assert card content rather than timing.
static let immediate = Self(failureGrace: .zero)

/// How long the current unusable episode has lasted, as the session tracks it.
enum Stage: Equatable, Sendable {
/// Shorter than `failureGrace`: show nothing.
case silent
/// Past `failureGrace`: a disconnected attachment offers Reconnect.
case failure
}

enum Outcome: Equatable, Sendable {
case none
case failure
}

struct Input: Equatable, Sendable {
var phase: CloudTuiManualMirrorPhase
var replayReceived: Bool
/// Whether the session or its provider will retry on its own.
var automaticRecovery: Bool
var stage: Stage
}

/// Whether `input` describes an attachment the user can type into.
static func isUsable(_ input: Input) -> Bool {
input.phase == .attached && input.replayReceived
}

/// Whether `input` is an episode the stage timer should be measuring.
static func isUnusableEpisode(_ input: Input) -> Bool {
switch input.phase {
case .idle, .stopped: return false
case .connecting, .disconnected: return true
case .attached: return !input.replayReceived
}
}

static func outcome(for input: Input) -> Outcome {
switch input.phase {
case .idle, .stopped, .connecting, .attached:
// Never started, cancelled by the user, or still being worked on:
// nothing to report; the pane fills in when the stream is usable.
return .none
case .disconnected:
guard input.automaticRecovery else { return .failure }
return input.stage == .failure ? .failure : .none
}
}
}
67 changes: 67 additions & 0 deletions Sources/Cloud/CloudTerminalCreationRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Foundation

/// Retains one UI intent's daemon identity across explicit retries.
///
/// The first create uses the existing idempotency-key contract. Only a user
/// retry reads the durable receipt, once; no polling or automatic recreation
/// runs behind a pending pane.
@MainActor
final class CloudTerminalCreationRequest {
let id: UUID
let correlationKey: String
private(set) var attemptKey: String
private var submitted = false

init(id: UUID = UUID()) {
self.id = id
let key = "cmux-cloud-create-\(id.uuidString.lowercased())"
correlationKey = key
attemptKey = key
}

/// First attempts omit the additive correlation flag for older daemons.
/// A new-key retry uses it only after the daemon explicitly authorizes one.
var correlationArgument: String? { attemptKey == correlationKey ? nil : correlationKey }

/// Returns an existing terminal, or authorizes exactly one mutation attempt.
func prepare(
using runner: any CloudTuiCommandRunning,
socketPath: String
) async throws -> CmuxTuiSnapshotParser.CreatedTerminalPath? {
try Task.checkCancellation()
guard submitted else {
submitted = true
return nil
}
let data: Data
do {
data = try await runner.runTuiCommand(
arguments: ["--socket", socketPath, "--json", "session", "current", "creation", correlationKey, "resolve"],
deadline: .seconds(30)
)
} catch {
if case .rejected(let reason) = CloudTuiDaemonAnswer(error: error),
reason.contains("unsupported") || reason.contains("unknown command") {
throw CloudDiagnosticFailure.unsupported
}
throw error
}
try Task.checkCancellation()
guard let resolution = CloudTerminalCreationRetryResolution(
data: data, correlationKey: correlationKey, attemptKey: attemptKey
) else { throw CloudDiagnosticFailure.response }
switch resolution {
case .created(let terminal):
return terminal
case .sameAttempt:
return nil
case .newAttempt:
attemptKey = "cmux-cloud-create-\(UUID().uuidString.lowercased())"
return nil
case .pending:
throw CloudDiagnosticFailure.timeout
case .indeterminate:
throw CloudDiagnosticFailure.response
}
}
}
47 changes: 47 additions & 0 deletions Sources/Cloud/CloudTerminalCreationRetryResolution.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

/// The daemon's authoritative permission for one explicit terminal-creation retry.
enum CloudTerminalCreationRetryResolution: Equatable, Sendable {
case created(CmuxTuiSnapshotParser.CreatedTerminalPath)
case sameAttempt
case newAttempt
case pending
case indeterminate

init?(data: Data, correlationKey: String, attemptKey: String) {
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let envelope = (object["result"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? object
let value = (envelope["value"] as? [String: Any]) ?? envelope
guard value["correlation_key"] as? String == correlationKey,
let state = value["state"] as? String,
let recovery = value["recovery"] as? String else { return nil }
let returnedKey = value["idempotency_key"] as? String
// An absent record can authorize a new attempt without an old key.
// Every existing receipt must name this request's current attempt.
guard returnedKey == attemptKey ||
(returnedKey == nil && state == "not_applied" && recovery == "retry_new_idempotency_key") else { return nil }
switch (state, recovery) {
case ("created", "none"):
guard let path = value["created_path"] as? [String: Any],
path["kind"] as? String == "terminal",
let generation = value["generation"] as? String, !generation.isEmpty,
let revision = CloudWireNumber.unsigned(value["revision"]),
let created = CmuxTuiSnapshotParser.createdTerminal(fromRunResult: [
"value": path, "generation": generation, "revision": String(revision)
]),
created.workspaceID != nil, created.screenID != nil,
created.paneID != nil, created.tabID != nil else { return nil }
self = .created(created)
case ("not_applied", "retry_same_idempotency_key"):
self = .sameAttempt
case ("not_applied", "retry_new_idempotency_key"):
self = .newAttempt
case ("pending", "wait"):
self = .pending
case ("indeterminate", "do_not_retry"):
self = .indeterminate
default:
return nil
}
}
}
22 changes: 18 additions & 4 deletions Sources/Cloud/CloudTreeNodeActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ struct CloudTreeNodeActions {
}
},
newTerminal: { machine, remoteWorkspaceID in
// A cloud machine gets its pane at once; the sidebar shares the
// shortcut routes' optimistic path. The local machine and a missing
// workspace keep the awaited create below.
if !machine.isLocal, let workspaceID = selectedWorkspaceID(),
let workspace = Workspace.liveWorkspace(id: workspaceID),
workspace.openCloudTerminalOptimistically(on: machine, remoteWorkspaceID: remoteWorkspaceID) {
return
}
run(startingLabel(machine)) { catalog in
guard let provider = catalog.provider(for: machine) else { throw SurfaceCatalogError.noProvider(machine) }
let token = catalog.cloudWorkspaceProjectionCoordinator.beginLocalMutation(on: machine)
Expand All @@ -213,6 +221,11 @@ struct CloudTreeNodeActions {
},
openGroup: { machine, group, placement, remoteWorkspaceID in
if group.isEmpty {
if !machine.isLocal, let workspaceID = selectedWorkspaceID(),
let workspace = Workspace.liveWorkspace(id: workspaceID),
workspace.openCloudTerminalOptimistically(on: machine, remoteWorkspaceID: remoteWorkspaceID) {
return
}
run(startingLabel(machine)) { catalog in
guard let provider = catalog.provider(for: machine) else { throw SurfaceCatalogError.noProvider(machine) }
let resource = try await provider.createTerminal(command: nil, cwd: nil, name: nil, remoteWorkspaceID: remoteWorkspaceID)
Expand All @@ -231,7 +244,8 @@ struct CloudTreeNodeActions {
_ = try await catalog.projectGroup(
routedGroup,
into: try destination(placement),
focus: true
focus: true,
optimistic: .app
)
}
}
Expand All @@ -253,7 +267,7 @@ struct CloudTreeNodeActions {
),
title: Self.localWorkspaceTitle(hostName: machineName(machine), group: group),
focus: true,
host: .app
host: .appOptimistic
)
catalog.bindCloudWorkspace(
localWorkspaceID: opened.workspaceID, machine: machine,
Expand All @@ -273,7 +287,7 @@ struct CloudTreeNodeActions {
routedGroup,
title: Self.localWorkspaceTitle(hostName: machineName(machine), group: group),
focus: true,
host: .app,
host: .appOptimistic,
layout: layout
)
catalog.bindCloudWorkspace(
Expand Down Expand Up @@ -442,7 +456,7 @@ struct CloudTreeNodeActions {
group,
title: localWorkspaceTitle(hostName: resolvedMachineName(machine, snapshot: catalog.snapshot), group: group),
focus: focus,
host: .app
host: .appOptimistic
)
catalog.bindCloudWorkspace(
localWorkspaceID: opened.workspaceID,
Expand Down
2 changes: 1 addition & 1 deletion Sources/Cloud/CloudTreeTerminalNavigationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ final class CloudTreeTerminalNavigationCoordinator {
group: group
),
focus: true,
host: .app,
host: .appOptimistic,
layout: layout
)
guard !Task.isCancelled else {
Expand Down
4 changes: 3 additions & 1 deletion Sources/Cloud/CloudTuiCommandLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ struct CloudTuiCommandLine: Sendable {
/// `workspace <ws_id> run -- <argv…>`: a new terminal in that cmux-tui workspace
/// running the exact argv. Result: `MutationResult<CreatedTerminalPath>`
/// (`spec/resource-operations-v2.json` → `workspace.run`).
static func runArguments(socketPath: String, workspaceID: String, command: [String], onExit: String? = nil) -> [String] {
static func runArguments(socketPath: String, workspaceID: String, command: [String], onExit: String? = nil, idempotencyKey: String? = nil, correlationKey: String? = nil) -> [String] {
var arguments = ["--socket", socketPath, "--json", "workspace", workspaceID, "run"]
if let idempotencyKey { arguments += ["--idempotency-key", idempotencyKey] }
if let correlationKey { arguments += ["--correlation-key", correlationKey] }
// `--on-exit keep` retains the tab and the final screen after the process exits
// (spec `workspace.run`): what a sender needs when the process's last lines ARE
// the result (`CloudEnvDelivery`). The default (`close`) detaches every view.
Expand Down
Loading
Loading