Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ccf8cec
test: reproduce Cloud command deadline and pipe teardown stalls
austinywang Sep 14, 2026
eb14189
fix: own Cloud command process groups through deadline and capture cl…
austinywang Sep 14, 2026
ebdae07
fix: open injected command clocks at the generic call boundary
austinywang Sep 14, 2026
8643561
test: verify command cleanup, resume ordering and durable timeout state
austinywang Sep 14, 2026
dc6d386
fix: reap an exited leader on the cleanup deadline path
austinywang Sep 14, 2026
df225e6
test: reproduce inherited cold identity initialization deadlock
austinywang Sep 14, 2026
2739cf5
test: make cold identity observer captures explicit
austinywang Sep 14, 2026
0384850
test: validate canonical identity fixture home boundaries
austinywang Sep 14, 2026
fae8cbf
fix: publish cold host identity before defaults notifications
austinywang Sep 14, 2026
ae6c75c
test: verify actual capture descriptor closure
austinywang Sep 14, 2026
4534661
Merge remote-tracking branch 'origin/main' into issue-11008-cloud-com…
austinywang Sep 15, 2026
1f0c466
Merge main into Cloud command deadlines branch
lawrencecchen Sep 17, 2026
bda560a
Merge current main into Cloud command deadlines branch
lawrencecchen Sep 17, 2026
fd03627
Merge remote-tracking branch 'origin/issue-11008-cloud-command-deadli…
austinywang Sep 19, 2026
fe24caa
Merge main and port deadline regressions to persistent Cloud requests
austinywang Sep 19, 2026
0ae1a1b
fix: enforce absolute deadlines on persistent Cloud replies
austinywang Sep 19, 2026
942f640
test: keep timed out Cloud operations visible
austinywang Sep 19, 2026
9538d27
ci: allow isolated deadline checks on either hosted macOS lane
austinywang Sep 19, 2026
8243c86
ci: track the notification-extension bundle identity in the App Store…
austinywang Sep 19, 2026
13e7df7
fix: compile the persistent Cloud transport in the isolated deadline …
austinywang Sep 19, 2026
2503185
Merge origin/main into Cloud command deadlines branch
austinywang Sep 19, 2026
d5f2fbf
Merge branch 'main' into issue-11008-cloud-command-deadlines
lawrencecchen Sep 19, 2026
3bb1b19
Merge origin/main into Cloud command deadlines branch
austinywang Sep 19, 2026
522eb6d
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Sep 19, 2026
9bc8ef8
Merge remote-tracking branch 'origin/main' into issue-11008-cloud-com…
austinywang Sep 20, 2026
bc5467c
fix: keep authoritative cloud names on accepted graph
austinywang Sep 20, 2026
7ce5b12
Merge branch 'main' into issue-11008-cloud-command-deadlines
lawrencecchen Sep 20, 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
51 changes: 51 additions & 0 deletions .github/workflows/cloud-command-deadlines.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Cloud command deadlines
on:
pull_request:
paths:
- Sources/Cloud/CloudTuiPersistent*.swift
- Sources/Cloud/CloudTuiManualIO*.swift
- Sources/Cloud/CloudTuiTerminalProjectionTarget.swift
- Sources/Cloud/CloudTuiRemoteColors.swift
- cmuxTests/CloudCommand*.swift
- cmuxTests/CloudTuiManualIOConnectionTests.swift
- tests/fixtures/cloud-command-deadlines/**
- tests/run_cloud_command_deadline_tests.sh
- Sources/Mobile/MobileHostIdentity.swift
- Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift
- Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicBooleanGate.swift
- Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/**
- tests/fixtures/mobile-host-identity-cold-start/**
- tests/run_mobile_host_identity_cold_start_tests.sh
- tests/test_mobile_host_identity_cold_start.py
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- .github/workflows/cloud-command-deadlines.yml
workflow_dispatch:
inputs:
source_ref:
description: Source commit to exercise (empty uses the dispatch ref)
default: ""
type: string
runner:
description: Hosted macOS runner
default: blacksmith-6vcpu-macos-15
type: choice
options:
- blacksmith-6vcpu-macos-15
- blacksmith-6vcpu-macos-26
permissions:
contents: read
concurrency:
group: cloud-command-deadlines-${{ github.ref }}-${{ inputs.source_ref }}-${{ inputs.runner }}
cancel-in-progress: true
jobs:
command-regressions:
runs-on: ${{ inputs.runner || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
ref: ${{ inputs.source_ref || github.ref }}
- name: Exercise the persistent Cloud command transport in isolation
run: tests/run_cloud_command_deadline_tests.sh "$RUNNER_TEMP/issue-11008-${{ github.run_id }}"
- name: Prove cold identity publication before defaults notification
run: tests/run_mobile_host_identity_cold_start_tests.sh "$RUNNER_TEMP/issue-11008-identity-${{ github.run_id }}"
33 changes: 28 additions & 5 deletions Sources/Cloud/CloudTuiPersistentResourceConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ actor CloudTuiPersistentResourceConnection {
let continuation: CheckedContinuation<Data, Error>
let request: CloudTuiRequest
let deadline: Task<Void, Never>
let isExpired: @Sendable () -> Bool
}
private struct Subscription {
let continuation: AsyncStream<Data>.Continuation
Expand Down Expand Up @@ -75,7 +76,16 @@ actor CloudTuiPersistentResourceConnection {
}

func request(_ request: CloudTuiRequest, timeout: Duration = .seconds(30)) async throws -> Data {
// Explicit `self.` is required: the compiler declines to open the
// existential when the argument is an implicit-self stored property.
try await performRequest(request, timeout: timeout, clock: self.clock)
}

private func performRequest<RequestClock: Clock>(
_ request: CloudTuiRequest, timeout: Duration, clock: RequestClock
) async throws -> Data where RequestClock.Duration == Duration {
try Task.checkCancellation()
guard timeout > .zero else { throw CloudMachineLink.LinkError.timedOut }
try await start()
try Task.checkCancellation()
guard !closed else { throw Self.protocolFailure }
Expand All @@ -85,20 +95,27 @@ actor CloudTuiPersistentResourceConnection {
let id = nextID()
let encoded = try request.envelope(id: id)
guard encoded.count <= 256 * 1024 - 1 else { throw CloudMachineLink.LinkError.inputTooLarge }
return try await withTaskCancellationHandler(operation: {
let expiresAt = clock.now.advanced(by: timeout)
let result: Data = try await withTaskCancellationHandler(operation: {
try Task.checkCancellation()
return try await withCheckedThrowingContinuation { continuation in
let clock = self.clock
// A genuine request deadline, owned and cancelled with its pending entry.
let deadline = Task { [weak self, clock] in
do { try await clock.sleep(for: timeout) } catch { return }
do { try await clock.sleep(until: expiresAt, tolerance: nil) } catch { return }
await self?.retire(id, error: CloudMachineLink.LinkError.timedOut)
}
pending[id] = Pending(continuation: continuation, request: request, deadline: deadline)
pending[id] = Pending(
continuation: continuation, request: request, deadline: deadline,
isExpired: { clock.now >= expiresAt }
)
connection.send(line: encoded + Data([0x0A]))
}
}, onCancel: { [weak self] in
Task { await self?.retire(id, error: CancellationError()) }
})
// The cancellation handler's actor hop can arrive after a successful response.
try Task.checkCancellation()
return result
}

private func retire(_ id: String, error: Error) {
Expand Down Expand Up @@ -159,12 +176,18 @@ actor CloudTuiPersistentResourceConnection {
}
guard let id = root["id"] as? String,
let ok = root["ok"] as? NSNumber, CFGetTypeID(ok) == CFBooleanGetTypeID() else { close(); return }
guard let entry = pending.removeValue(forKey: id) else {
guard let entry = pending[id] else {
// Responses to cancellation and retired requests are harmless.
guard id.hasPrefix("request-\(namespace)-"), let suffix = id.split(separator: "-").last,
let issued = UInt64(suffix), issued > 0, issued <= sequence else { close(); return }
return
}
// After suspend/resume, the socket reader may run before the deadline task.
guard !entry.isExpired() else {
retire(id, error: CloudMachineLink.LinkError.timedOut)
return
}
pending.removeValue(forKey: id)
entry.deadline.cancel()
if !entry.request.raw && (root["protocol"] as? String != "cmux.protocol/2" || root["type"] as? String != "response") {
entry.continuation.resume(throwing: Self.protocolFailure); close(); return
Expand Down
23 changes: 22 additions & 1 deletion Sources/Surfaces/SurfaceCatalog+Snapshot.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
import Foundation

extension SurfaceCatalog {
/// Reconciles machine-summary workspace names with the accepted daemon graph.
/// Resource rows may carry optimistic creation or rename overlays, but the
/// authoritative snapshot must expose the last accepted name for identities
/// already present in `cloudStates`.
private func authoritativeMachineInfo(_ info: SurfaceMachineInfo) -> SurfaceMachineInfo {
guard case .cloud = info.id,
let state = cloudStates[info.id],
let workspaces = info.remoteWorkspaces else { return info }
let accepted = Dictionary(uniqueKeysWithValues: state.workspaces.map { workspace in
(workspace.id, SurfaceRemoteWorkspace(
id: workspace.id,
name: workspace.name,
index: workspace.index,
focused: workspace.focused
))
})
var adjusted = info
adjusted.remoteWorkspaces = workspaces.map { accepted[$0.id] ?? $0 }
return adjusted
}

/// The provider rows without any deletion or rename intent applied, used to
/// enumerate destructive operations. Presentation still withholds a stale
/// graph's cwd, and stale machines are flagged, exactly as `snapshot` does.
var authoritativeSnapshot: SurfaceCatalogSnapshot {
SurfaceCatalogSnapshot(
machines: machines.values.sorted {
machines: machines.values.map(authoritativeMachineInfo).sorted {
if $0.id.isLocal != $1.id.isLocal { return $0.id.isLocal }
return $0.name.localizedStandardCompare($1.name) == .orderedAscending
},
Expand Down
12 changes: 12 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,9 @@
EA00C01A1F5287765D8117C5 /* CloudBrowserRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF04D7E65B3CF1EDF513AFE /* CloudBrowserRouting.swift */; };
D01100800000000000000006 /* CloudCatalogQueryTestProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01100800000000000000005 /* CloudCatalogQueryTestProvider.swift */; };
954865E90FA14C5790614136 /* CloudClosedPanelRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1889B99217E645E7A477621A /* CloudClosedPanelRestoreTests.swift */; };
E1724349548A4068A8377242 /* CloudCommandDeadlineClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54945EFADC4466F8B520CA0 /* CloudCommandDeadlineClock.swift */; };
2626F8CD8D00447CA73AF900 /* CloudCommandDeadlineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791992D7B1A443AF960CC85A /* CloudCommandDeadlineTests.swift */; };
EA2B013FB51E4424834575A5 /* CloudCommandOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AAC366FD05843F5BC94E64D /* CloudCommandOperationTests.swift */; };
9A1261600000000000000004 /* CloudDesktopAccessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1261600000000000000003 /* CloudDesktopAccessTests.swift */; };
9A126160000000000000000A /* CloudDesktopConnectionObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1261600000000000000009 /* CloudDesktopConnectionObserver.swift */; };
9A1261600000000000000006 /* CloudDesktopWebSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1261600000000000000005 /* CloudDesktopWebSocketTests.swift */; };
Expand Down Expand Up @@ -4757,6 +4760,9 @@
CEF04D7E65B3CF1EDF513AFE /* CloudBrowserRouting.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudBrowserRouting.swift; sourceTree = "<group>"; };
D01100800000000000000005 /* CloudCatalogQueryTestProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudCatalogQueryTestProvider.swift; sourceTree = "<group>"; };
1889B99217E645E7A477621A /* CloudClosedPanelRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudClosedPanelRestoreTests.swift; sourceTree = "<group>"; };
A54945EFADC4466F8B520CA0 /* CloudCommandDeadlineClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudCommandDeadlineClock.swift"; sourceTree = "<group>"; };
791992D7B1A443AF960CC85A /* CloudCommandDeadlineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudCommandDeadlineTests.swift"; sourceTree = "<group>"; };
3AAC366FD05843F5BC94E64D /* CloudCommandOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudCommandOperationTests.swift"; sourceTree = "<group>"; };
9A1261600000000000000003 /* CloudDesktopAccessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDesktopAccessTests.swift"; sourceTree = "<group>"; };
9A1261600000000000000009 /* CloudDesktopConnectionObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDesktopConnectionObserver.swift"; sourceTree = "<group>"; };
9A1261600000000000000005 /* CloudDesktopWebSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDesktopWebSocketTests.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -11841,6 +11847,9 @@
7A0CE100000000000000072D /* CloudTunnelStatusBlockerTests.swift */,
7A0CE100000000000000072F /* CloudTunnelBannerTests.swift */,
8C629AFC3F73DD0690975630 /* CmuxTuiSurfaceProviderTests+LinkCancellation.swift */,
791992D7B1A443AF960CC85A /* CloudCommandDeadlineTests.swift */,
3AAC366FD05843F5BC94E64D /* CloudCommandOperationTests.swift */,
A54945EFADC4466F8B520CA0 /* CloudCommandDeadlineClock.swift */,
C12093000000000000000001 /* CloudVPNExplicitActivationTests.swift */,
7A0CE1000000000000000731 /* CloudLoopbackPortForwardTests.swift */,
A6D3896BB28B4549B3D33A82 /* CloudPortForwardAddressReuseTests.swift */,
Expand Down Expand Up @@ -15611,6 +15620,9 @@
BD85C5CFA3F5C1D251113A3B /* CloudBrowserProxyIntegrationTests.swift in Sources */,
D01100800000000000000006 /* CloudCatalogQueryTestProvider.swift in Sources */,
954865E90FA14C5790614136 /* CloudClosedPanelRestoreTests.swift in Sources */,
E1724349548A4068A8377242 /* CloudCommandDeadlineClock.swift in Sources */,
2626F8CD8D00447CA73AF900 /* CloudCommandDeadlineTests.swift in Sources */,
EA2B013FB51E4424834575A5 /* CloudCommandOperationTests.swift in Sources */,
9A1261600000000000000004 /* CloudDesktopAccessTests.swift in Sources */,
9A1261600000000000000006 /* CloudDesktopWebSocketTests.swift in Sources */,
5910F6B0C9704B8DB8607318 /* CloudDirectoryLifecycleTests.swift in Sources */,
Expand Down
49 changes: 49 additions & 0 deletions cmuxTests/CloudCommandDeadlineClock.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation

/// A virtual command clock that can hold timer delivery while time advances.
/// Safety: Clock.now is synchronous; the lock protects only the test clock's instant and sleepers.
final class CloudCommandDeadlineClock: Clock, @unchecked Sendable {
typealias Instant = ContinuousClock.Instant
private let lock = NSLock()
private var instant = ContinuousClock.now
private var sleepers: [UUID: (Instant, AsyncStream<Void>.Continuation)] = [:]
private let registrations = AsyncStream<Void>.makeStream()

var now: Instant { lock.withLock { instant } }
var minimumResolution: Duration { .nanoseconds(1) }

func advance(by duration: Duration, wakingTimers: Bool = true) {
let ready = lock.withLock {
instant = instant.advanced(by: duration)
return wakingTimers ? sleepers.values.filter { $0.0 <= instant }.map { $0.1 } : []
}
for continuation in ready { continuation.finish() }
}

func waitUntilSleeping() async {
var iterator = registrations.stream.makeAsyncIterator()
_ = await iterator.next()
}

func sleep(until deadline: Instant, tolerance: Duration?) async throws {
let id = UUID()
let wakeup = AsyncStream<Void>.makeStream()
register(id, deadline: deadline, continuation: wakeup.continuation)
defer { remove(id) }
registrations.continuation.yield(())
// AsyncStream ends on cancellation, releasing even a deliberately held timer.
for await _ in wakeup.stream {}
try Task.checkCancellation()
}

private func register(_ id: UUID, deadline: Instant, continuation: AsyncStream<Void>.Continuation) {
lock.withLock {
if deadline <= instant { continuation.finish() }
else { sleepers[id] = (deadline, continuation) }
}
}

private func remove(_ id: UUID) {
_ = lock.withLock { sleepers.removeValue(forKey: id) }
}
}
72 changes: 72 additions & 0 deletions cmuxTests/CloudCommandDeadlineTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import Testing
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#elseif canImport(CloudCommandFixture)
@testable import CloudCommandFixture
#endif

@Suite(.timeLimit(.minutes(1))) struct CloudCommandDeadlineTests {
private typealias Socket = CloudTuiManualIOConnectionTests

@Test(arguments: [false, true])
func responseAfterDeadlineFailsEvenWhenTimerHasNotResumed(raw: Bool) async throws {
let clock = CloudCommandDeadlineClock()
try await Socket.withResourceConnection(clock: clock) { channel, peer in
let command = Task { try await channel.request(CloudTuiRequest("session.ping", raw: raw), timeout: .seconds(30)) }
defer { command.cancel() }
let bytes = try await Socket.blocking { try Socket.readLine(peer) }
let request = try Socket.object(bytes)
let requestID = try #require(request["id"] as? String)
await clock.waitUntilSleeping()
clock.advance(by: .seconds(600), wakingTimers: false)
try Socket.write(peer, Socket.response(request, result: ["expired": true], raw: raw))
do {
_ = try await command.value
Issue.record("a response delivered after resume cannot turn an expired request into success")
} catch CloudMachineLink.LinkError.timedOut {}

// An expired request never closes the machine-owned socket or replays work.
async let sibling = channel.request(CloudTuiRequest("session.snapshot"))
try await Socket.blocking {
var next = try Socket.object(Socket.readLine(peer))
if next["operation"] as? String == "request.cancel" {
#expect(!raw)
#expect((next["params"] as? [String: Any])?["request_id"] as? String == requestID)
next = try Socket.object(Socket.readLine(peer))
}
#expect(next["operation"] as? String == "session.snapshot")
try Socket.write(peer, Socket.response(next, result: ["alive": true]))
}
let siblingResult = try await sibling
#expect(try Socket.object(siblingResult)["alive"] as? Bool == true)
#expect(!(await channel.isClosed))
}
}

@Test(arguments: [false, true])
func responseBeforeDeadlinePreservesOutput(raw: Bool) async throws {
let clock = CloudCommandDeadlineClock()
try await Socket.withResourceConnection(clock: clock) { channel, peer in
async let command = channel.request(CloudTuiRequest("session.ping", raw: raw), timeout: .seconds(30))
let bytes = try await Socket.blocking { try Socket.readLine(peer) }
await clock.waitUntilSleeping()
clock.advance(by: .seconds(29), wakingTimers: false)
try Socket.write(peer, Socket.response(Socket.object(bytes), result: ["value": "unchanged"], raw: raw))
let commandResult = try await command
#expect(try Socket.object(commandResult)["value"] as? String == "unchanged")
}
}

@Test(arguments: [Duration.zero, .seconds(-1)])
func expiredBudgetIsRejectedBeforeOpeningSocket(timeout: Duration) async throws {
let channel = CloudTuiPersistentResourceConnection(socketPath: "/does/not/exist")
do {
_ = try await channel.request(CloudTuiRequest("session.ping"), timeout: timeout)
Issue.record("an expired request must be rejected")
} catch CloudMachineLink.LinkError.timedOut {}
await channel.close()
}
}
Loading
Loading