Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
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
Expand Up @@ -81,11 +81,24 @@ extension AuthCoordinator {

let cachedUser = loadCachedUser()
// accessToken() may refresh over the network; a sign-out can land
// while these reads are parked, so re-check the generation after.
let hasAccessToken = await client.accessToken() != nil
let hasRefreshToken = await client.refreshToken() != nil
// while these reads are parked, so bound the probe and re-check the
// generation after. A timeout preserves the cached identity instead of
// leaving launch behind "Restoring session".
let hasStoredTokens: Bool
do {
let client = self.client
hasStoredTokens = try await runPhase(.validateSession, timeout: timeouts.sessionRestore) {
let hasAccessToken = await client.accessToken() != nil
let hasRefreshToken = await client.refreshToken() != nil
return hasAccessToken || hasRefreshToken
}
Comment on lines +90 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Carry one remaining restore budget instead of restarting sessionRestore for each phase.

Each runPhase(... timeout: timeouts.sessionRestore) gets a fresh deadline, so a slow token probe can consume nearly the whole launch budget and validateCachedSession can then spend another full budget on currentUser/refresh-token checks. That weakens the PR’s hard launch-restore deadline and can keep “Restoring session” visible longer than configured. Capture the restore start once in checkExistingSession and pass remaining time into the nested probes/validation. Based on PR context: “Adds a hard deadline for session restoration at app launch.”

Also applies to: 185-201, 232-234

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

In
`@Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator`+SessionPriming.swift
around lines 90 - 94, The session restoration process is giving each runPhase
call a fresh full timeout instead of sharing a single budget across all phases.
Capture the restore start time once at the beginning of the checkExistingSession
method, then modify all runPhase calls (including those in the validateSession
phase around lines 90-94 and the validateCachedSession phase around lines
185-201 and 232-234) to pass the remaining time calculated from the initial
start time instead of using the fixed timeouts.sessionRestore timeout. This
ensures that slow token probes or validation checks consume from a single shared
budget rather than each phase getting its own full allocation.

} catch {
guard generation == sessionGeneration else { return }
authLog.error("Session token probe failed: \(error.localizedDescription, privacy: .private)")
preserveCachedSessionAfterValidationFailure()
return
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
guard generation == sessionGeneration else { return }
let hasStoredTokens = hasAccessToken || hasRefreshToken

#if DEBUG
if launch.mockDataEnabled { return }
Expand Down Expand Up @@ -169,7 +182,7 @@ extension AuthCoordinator {
func validateCachedSession(generation: UInt64, storeWriteHighWater: UInt64) async {
do {
let client = self.client
let user = try await runPhase(.validateSession, timeout: timeouts.network) {
let user = try await runPhase(.validateSession, timeout: timeouts.sessionRestore) {
try await client.currentUser(throwOnMissing: true)
}
// A sign-out landed while the fetch was in flight: the user's
Expand All @@ -183,7 +196,9 @@ extension AuthCoordinator {
authLog.info("Cached session validation returned no current user")
// Snapshot the dead session's refresh token right before the
// clear so the clear can be compare-and-clear at the token store.
let expectedRefreshToken = await client.refreshToken()
let expectedRefreshToken = try await runPhase(.validateSession, timeout: timeouts.sessionRestore) {
await client.refreshToken()
}
guard generation == sessionGeneration else { return }
await clearStaleSessionState(
generation: generation,
Expand All @@ -206,7 +221,22 @@ extension AuthCoordinator {
// time with a confusing host-side message. The live token store is the
// ground truth: if no refresh token survives, the session is genuinely
// gone and the user must see the sign-in page.
let survivingRefreshToken = await client.refreshToken()
if (error as? AuthError) == .timedOut {
authLog.error("Session validation timed out; preserving cached session")
preserveCachedSessionAfterValidationFailure()
return
}
let survivingRefreshToken: String?
do {
let client = self.client
survivingRefreshToken = try await runPhase(.validateSession, timeout: timeouts.sessionRestore) {
await client.refreshToken()
}
} catch {
authLog.error("Session refresh-token check failed: \(error.localizedDescription, privacy: .private)")
preserveCachedSessionAfterValidationFailure()
return
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
guard generation == sessionGeneration else { return }
if survivingRefreshToken == nil {
authLog.error(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,95 @@
import Foundation
internal import os

/// Internal marker distinguishing "the deadline child fired" from errors the
/// operation itself threw (including `CancellationError` when the caller's
/// task is cancelled, which must surface as a cancellation, not a timeout).
private struct AuthPhaseDeadlineExceeded: Error {}
private final class AuthPhaseTimeoutState<T: Sendable>: @unchecked Sendable {
private enum Completion: Sendable {
case success(T)
case failure(any Error)
}

private struct State {
var continuation: CheckedContinuation<T, any Error>?
var cancelHandler: (@Sendable () -> Void)?
var completion: Completion?
}

private let state = OSAllocatedUnfairLock(initialState: State())

func install(_ continuation: CheckedContinuation<T, any Error>) {
let completion = state.withLock { state -> Completion? in
guard let completion = state.completion else {
state.continuation = continuation
return nil
}
state.continuation = nil
return completion
}
if let completion {
resume(continuation, with: completion)
}
}

func setCancelHandler(_ handler: @escaping @Sendable () -> Void) {
let shouldCancel = state.withLock { state in
guard state.completion == nil else { return true }
state.cancelHandler = handler
return false
}
if shouldCancel {
handler()
}
}

func resume(returning value: T) {
finish(.success(value))
}

func resume(throwing error: any Error) {
finish(.failure(error))
}

func cancel() {
finish(.failure(CancellationError()))
}

private func finish(_ completion: Completion) {
let installed = state.withLock { state -> (
continuation: CheckedContinuation<T, any Error>?,
cancelHandler: (@Sendable () -> Void)?
)? in
guard state.completion == nil else { return nil }
state.completion = completion
let continuation = state.continuation
let cancelHandler = state.cancelHandler
state.continuation = nil
state.cancelHandler = nil
return (continuation, cancelHandler)
}

guard let installed else { return }
installed.cancelHandler?()
if let continuation = installed.continuation {
resume(continuation, with: completion)
}
}

private func resume(_ continuation: CheckedContinuation<T, any Error>, with completion: Completion) {
switch completion {
case let .success(value):
continuation.resume(returning: value)
case let .failure(error):
continuation.resume(throwing: error)
}
}
}

/// Race `operation` against a `duration` deadline on `clock`.
///
/// Structured: whichever side finishes first cancels the other, and the task
/// group joins both children before returning, so neither can outlive the
/// call. The operation must be cancellation-responsive for the bound to be
/// real (URLSession calls are; the interactive OAuth waits are made so by the
/// cancellation gates in the vendored Stack SDK).
/// Whichever side finishes first cancels the other and resumes the caller
/// immediately. The losing task is not joined, because some Stack SDK calls can
/// ignore cancellation while parked in network/token refresh code; joining
/// those calls would keep user-visible restore/sign-in spinners alive after
/// the deadline had already fired.
///
/// - Parameters:
/// - phase: The phase label for timeout diagnostics.
Expand All @@ -29,24 +107,33 @@ func withAuthPhaseTimeout<T: Sendable>(
log: AuthDebugLog,
operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await clock.sleep(for: duration, tolerance: nil)
throw AuthPhaseDeadlineExceeded()
}
// Cancel the loser on every exit path; the group then joins it.
defer { group.cancelAll() }
do {
guard let first = try await group.next() else {
throw AuthError.timedOut
try Task.checkCancellation()
let state = AuthPhaseTimeoutState<T>()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
state.install(continuation)
let operationTask = Task {
do {
state.resume(returning: try await operation())
} catch {
state.resume(throwing: error)
}
}
let deadlineTask = Task {
do {
try await clock.sleep(for: duration, tolerance: nil)
} catch {
return
}
log.log("auth.phase=\(phase.rawValue) timed out after \(duration)")
state.resume(throwing: AuthError.timedOut)
}
state.setCancelHandler {
operationTask.cancel()
deadlineTask.cancel()
}
return first
} catch is AuthPhaseDeadlineExceeded {
log.log("auth.phase=\(phase.rawValue) timed out after \(duration)")
throw AuthError.timedOut
}
} onCancel: {
state.cancel()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,26 @@ public struct AuthTimeouts: Sendable, Equatable {
/// post-sign-in hook). Sized above the Stack SDK's 30s per-request timeout
/// so it only fires when retries stack up or a call wedges.
public var network: Duration
/// Shorter deadline for launch session restore. This state blocks the
/// first screen behind "Restoring session", so it should fail open to the
/// cached session instead of waiting for the full backend-call budget.
public var sessionRestore: Duration

/// The production defaults: 300s interactive, 60s network.
/// The production defaults: 300s interactive, 60s network, 8s launch restore.
public static let `default` = AuthTimeouts(
interactiveFlow: .seconds(300),
network: .seconds(60)
network: .seconds(60),
sessionRestore: .seconds(8)
)

/// Creates a timeout table.
/// - Parameters:
/// - interactiveFlow: Deadline for flows with interactive system UI.
/// - network: Deadline for non-interactive backend calls.
public init(interactiveFlow: Duration, network: Duration) {
/// - sessionRestore: Deadline for launch cached-session restore.
public init(interactiveFlow: Duration, network: Duration, sessionRestore: Duration? = nil) {
self.interactiveFlow = interactiveFlow
self.network = network
self.sessionRestore = sessionRestore ?? network
}
}
Loading
Loading