Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
8c0e056
Add iOS pairing timeout regression tests
lawrencecchen Jun 20, 2026
5686dec
Bound iOS pairing attempts
lawrencecchen Jun 20, 2026
d33c2ad
Add launch restore timeout regression test
lawrencecchen Jun 20, 2026
d5902c4
Bound launch session restore
lawrencecchen Jun 20, 2026
333157f
Merge remote-tracking branch 'origin/main' into feat-ios-pairing-timeout
lawrencecchen Jun 20, 2026
0b0b726
Restore cached sessions immediately
lawrencecchen Jun 20, 2026
e38879b
Prefer cached session over dev auto-login restore
lawrencecchen Jun 20, 2026
df6ad97
Tighten iOS pairing timeout races
lawrencecchen Jun 20, 2026
df9d816
Tear down timed-out mobile RPC connects
lawrencecchen Jun 20, 2026
c2ec2a1
Claim mobile RPC timeout before teardown
lawrencecchen Jun 20, 2026
e8a2711
Bound mobile RPC auth augmentation
lawrencecchen Jun 20, 2026
a2666ae
Cancel preinstalled mobile RPC connects
lawrencecchen Jun 20, 2026
d6c3a2b
Bound timed-out auth phase cleanup
lawrencecchen Jun 20, 2026
a674753
Refine mobile timeout cleanup
lawrencecchen Jun 20, 2026
c3b3453
Merge remote-tracking branch 'origin/main' into feat-ios-pairing-timeout
lawrencecchen Jun 20, 2026
dffd30f
Fold timeout race helpers into timeout files
lawrencecchen Jun 20, 2026
690aba5
Allow RPC connect retry after timeout
lawrencecchen Jun 20, 2026
3560472
Add CLI session restore debug command
lawrencecchen Jun 21, 2026
66cd6fe
Rename session restore diagnostics to sessions list
lawrencecchen Jun 21, 2026
306fa38
Merge remote-tracking branch 'origin/main' into feat-ios-pairing-timeout
lawrencecchen Jun 22, 2026
055e961
Guard late exchange cleanup during sign-out capture
lawrencecchen Jun 22, 2026
547a53d
Hold connect gate while abandoned close hangs
lawrencecchen Jun 22, 2026
bc3fdcb
Bound abandoned close cleanup gate release
lawrencecchen Jun 22, 2026
451a73a
Move CLI regression tests to Swift Testing
lawrencecchen Jun 22, 2026
e592ac7
Split sessions CLI diagnostics
lawrencecchen Jun 22, 2026
ad288e2
Localize sessions CLI diagnostics
lawrencecchen Jun 22, 2026
7ef841e
Fix sessions CLI tuple inference
lawrencecchen Jun 22, 2026
686b390
Stop canceled validation token probes
lawrencecchen Jun 22, 2026
c79ed0b
Fix Swift Testing assertion shims
lawrencecchen Jun 22, 2026
2d475b6
Scope CLI Swift Testing helpers
lawrencecchen Jun 22, 2026
5528388
Clear late exchange tokens after timeout
lawrencecchen Jun 22, 2026
624fdab
Bound host status token fallback
lawrencecchen Jun 22, 2026
99d0ec6
Deflake render grid event test
lawrencecchen Jun 22, 2026
ad0fa68
Speed up browser sign-out race test
lawrencecchen Jun 22, 2026
810f2f7
Deflake auth runtime package CI
lawrencecchen Jun 22, 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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,22 @@ jobs:
fi
echo "::group::swift test $pkgdir"
case "$pkg" in
CmuxAuthRuntime)
suite_list="$(
find "$pkgdir/Tests" -name '*.swift' -print0 \
| xargs -0 perl -ne 'print "$1\n" if /^\s*@Suite(?:\([^)]*\))?\s+struct\s+([A-Za-z0-9_]+)/' \
| sort -u
)"
if [ -z "$suite_list" ]; then
echo "::error::no Swift Testing suites found for $pkg"
exit 1
fi
while IFS= read -r suite; do
[ -n "$suite" ] || continue
echo "swift test $pkgdir --filter $suite"
swift test --package-path "$pkgdir" --filter "$suite"
done <<< "$suite_list"
;;
CmuxTerminal|CmuxTerminalCore)
test_status=0
output="$(swift test --package-path "$pkgdir" 2>&1)" || test_status=$?
Expand Down
400 changes: 400 additions & 0 deletions CLI/CMUXCLI+SessionsList.swift

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3163,18 +3163,17 @@ struct CMUXCLI {
return
}

if command == "help" { print(usage()); return }
if command == "remote-daemon-status" { try runRemoteDaemonStatus(commandArgs: commandArgs, jsonOutput: jsonOutput); return }
if command == "help" { print(usage()); return }; if command == "remote-daemon-status" { try runRemoteDaemonStatus(commandArgs: commandArgs, jsonOutput: jsonOutput); return }
if command == "vm-pty-connect" { try runVMPtyConnect(commandArgs: commandArgs); return }
if command == "docs" { try runDocsCommand(commandArgs: commandArgs, jsonOutput: jsonOutput); return }
if command == "welcome" { printWelcome(); return }
if command == "sessions" || command == "session-debug" { try runSessionsCommand(commandArgs: command == "session-debug" ? ["debug"] + commandArgs : commandArgs, jsonOutput: jsonOutput, processEnv: processEnv); return }
if command == "__sigpipe-probe" { try runSIGPIPEProbe(commandArgs: commandArgs); return }
if command == "__sigpipe-stdin-pipe-probe" { try runSIGPIPEStdinPipeProbe(); return }
if command == "__sigpipe-inspect" { try runSIGPIPEInspect(commandArgs: commandArgs); return }
if command == "diff-viewer-server" { try runDiffViewerServerCommand(commandArgs: commandArgs); return }
if command == "__diff-viewer-refs" { try runDiffViewerRefsCommand(commandArgs: commandArgs); return }
if command == "__diff-viewer-branch" { try runDiffViewerBranchRegenerateCommand(commandArgs: commandArgs); return }

if command == "settings",
settingsCommandDoesNotNeedSocket(commandArgs) {
try runSettings(
Expand Down Expand Up @@ -14223,6 +14222,7 @@ struct CMUXCLI {
If the app is already running, this restores the last saved session into the current app.
If the app is not running, this launches cmux and lets startup restore reopen the saved session.
"""
case "sessions", "session-debug": return sessionsUsage()
case "feedback":
return """
Usage: cmux feedback
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ public struct CMUXAuthState: Equatable, Sendable {

/// The launch-time priming state, decided from the launch inputs in
/// priority order: cleared auth, mock data, a UI-test fixture user,
/// pending auto-login, then the cached user (restoring iff tokens are
/// known to exist).
/// pending auto-login, then the cached user. A cached user with known
/// tokens is treated as signed in immediately while runtime validation
/// proceeds in the background.
/// - Parameters:
/// - clearAuthRequested: Whether the launch requested a cleared auth state.
/// - mockDataEnabled: Whether mock-data mode is active.
Expand Down Expand Up @@ -51,15 +52,15 @@ public struct CMUXAuthState: Equatable, Sendable {
return Self(isAuthenticated: true, currentUser: fixtureUser, isRestoringSession: false)
}

if autoLoginCredentials != nil {
if autoLoginCredentials != nil, !hasTokens {
return Self(isAuthenticated: false, currentUser: cachedUser, isRestoringSession: true)
}

return Self(
isAuthenticated: false,
currentUser: cachedUser,
isRestoringSession: hasTokens
)
if hasTokens, let cachedUser {
return Self(isAuthenticated: true, currentUser: cachedUser, isRestoringSession: false)
}

return Self(isAuthenticated: false, currentUser: cachedUser, isRestoringSession: hasTokens)
}

/// The fully signed-out state.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ struct CMUXAuthStateTests {
)
}

@Test("Primed state restores cached user while awaiting token validation")
func primedStateRestoresCachedUserWhileAwaitingTokenValidation() {
@Test("Primed state authenticates cached user while validating tokens")
func primedStateAuthenticatesCachedUserWhileValidatingTokens() {
let user = CMUXAuthUser(id: "user_123", primaryEmail: "user@example.com", displayName: "Test User")
let state = CMUXAuthState.primed(
clearAuthRequested: false,
Expand All @@ -133,8 +133,25 @@ struct CMUXAuthStateTests {
mockUser: CMUXAuthUser(id: "mock", primaryEmail: "mock@example.com", displayName: "Mock")
)

#expect(!state.isAuthenticated)
#expect(state.isAuthenticated)
#expect(state.currentUser == user)
#expect(!state.isRestoringSession)
}

@Test("Primed state restores when tokens exist without a cached user")
func primedStateRestoresWhenTokensExistWithoutCachedUser() {
let state = CMUXAuthState.primed(
clearAuthRequested: false,
mockDataEnabled: false,
fixtureUser: nil,
autoLoginCredentials: nil,
cachedUser: nil,
hasTokens: true,
mockUser: CMUXAuthUser(id: "mock", primaryEmail: "mock@example.com", displayName: "Mock")
)

#expect(!state.isAuthenticated)
#expect(state.currentUser == nil)
#expect(state.isRestoringSession)
}

Expand All @@ -156,6 +173,24 @@ struct CMUXAuthStateTests {
#expect(state.isRestoringSession)
}

@Test("Primed state ignores auto-login credentials when cached tokens exist")
func primedStateIgnoresAutoLoginCredentialsWhenCachedTokensExist() {
let user = CMUXAuthUser(id: "user_123", primaryEmail: "user@example.com", displayName: "Test User")
let state = CMUXAuthState.primed(
clearAuthRequested: false,
mockDataEnabled: false,
fixtureUser: nil,
autoLoginCredentials: CMUXAuthAutoLoginCredentials(email: "user@example.com", password: "password"),
cachedUser: user,
hasTokens: true,
mockUser: CMUXAuthUser(id: "mock", primaryEmail: "mock@example.com", displayName: "Mock")
)

#expect(state.isAuthenticated)
#expect(state.currentUser == user)
#expect(!state.isRestoringSession)
}

@Test("Primed state does not authenticate from cached user alone")
func primedStateDoesNotAuthenticateFromCachedUserAlone() {
let user = CMUXAuthUser(id: "user_123", primaryEmail: "user@example.com", displayName: "Test User")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation

@MainActor
extension AuthCoordinator {
/// Run a sign-in flow's credential exchange as a coordinator-owned child
/// task registered under the flow's attempt id, racing the phase deadline
/// like ``runPhase(_:timeout:_:)``.
///
/// The registration is what makes sign-out able to win against a parked
/// exchange: ``signOut(onSignedOut:teardownTimeout:)`` cancels every
/// registered exchange before clearing local state, and the SDK's write
/// chokepoint drops the token store write of a cancelled flow, so the
/// stale exchange can neither resurrect the signed-out session nor
/// clobber a newer sign-in's freshly written tokens. Caller cancellation
/// is forwarded to the child task.
func runExchange(
_ phase: AuthPhase,
flow: SignInFlowContext,
timeout: Duration,
_ operation: @escaping @Sendable () async throws -> Void
) async throws {
let exchangeID = UUID()
guard await phaseTimeoutRegistry.begin(phase, id: exchangeID) else {
log.log("auth.phase=\(phase.rawValue) previous timed-out exchange still active")
throw AuthError.timedOut
}
let registry = phaseTimeoutRegistry
let client = self.client
let exchange = Task {
try await operation()
let refreshToken = await client.refreshToken()
if Task.isCancelled {
if let refreshToken {
await waitForSignOutCredentialCapture()
await client.clearLocalSession(ifRefreshTokenMatches: refreshToken)
}
throw CancellationError()
}
return refreshToken
}
let completion = Task {
_ = await exchange.result
}
activeSignInExchanges[flow.attempt] = AuthTrackedSignInExchange(
id: exchangeID,
task: exchange,
completion: completion
)
Task { [weak self, registry, attempt = flow.attempt, exchangeID, phase] in
await completion.value
await registry.end(phase, id: exchangeID)
await MainActor.run {
guard self?.activeSignInExchanges[attempt]?.id == exchangeID else { return }
self?.activeSignInExchanges[attempt] = nil
}
}
try await withTaskCancellationHandler {
try await waitForExchange(exchange, id: exchangeID, attempt: flow.attempt, phase: phase, timeout: timeout)
} onCancel: {
Task { await registry.markTimedOut(phase, id: exchangeID) }
exchange.cancel()
}
}

private func waitForExchange(
_ exchange: Task<String?, any Error>,
id: UUID,
attempt: UInt64,
phase: AuthPhase,
timeout: Duration
) async throws {
try Task.checkCancellation()
let race = AuthPhaseTimeoutRace()
let stream = AsyncThrowingStream<Void, any Error> { continuation in
let exchangeWaiter = Task {
do {
let refreshToken = try await exchange.value
guard await race.winOperation() else {
if let refreshToken {
await waitForSignOutCredentialCapture()
await self.client.clearLocalSession(ifRefreshTokenMatches: refreshToken)
}
return
}
await phaseTimeoutRegistry.end(phase, id: id)
tokenStoreWriteHighWater = max(tokenStoreWriteHighWater, attempt)
latestSignInRefreshToken = refreshToken
Comment thread
cursor[bot] marked this conversation as resolved.
continuation.yield(())
continuation.finish()
} catch {
await phaseTimeoutRegistry.end(phase, id: id)
guard await race.winOperation() else { return }
continuation.finish(throwing: error)
}
}
let deadline = Task { [clock, log] in
do {
try await clock.sleep(for: timeout, tolerance: nil)
try Task.checkCancellation()
} catch {
return
}
guard await race.winTimeout() else { return }
log.log("auth.phase=\(phase.rawValue) timed out after \(timeout)")
await phaseTimeoutRegistry.markTimedOut(phase, id: id)
exchange.cancel()
continuation.finish(throwing: AuthError.timedOut)
}
continuation.onTermination = { _ in
exchangeWaiter.cancel()
deadline.cancel()
}
}
do {
for try await _ in stream {
return
}
} catch AuthError.timedOut {
throw AuthError.timedOut
} catch {
throw error
}
if Task.isCancelled {
throw CancellationError()
}
throw AuthError.timedOut
}
}
Loading
Loading