Skip to content

refactor(auth): replace SessionManager facade with SessionStateMachine actor#931

Open
grdsdev wants to merge 3 commits intomainfrom
refactor/auth-session-state-machine
Open

refactor(auth): replace SessionManager facade with SessionStateMachine actor#931
grdsdev wants to merge 3 commits intomainfrom
refactor/auth-session-state-machine

Conversation

@grdsdev
Copy link
Copy Markdown
Contributor

@grdsdev grdsdev commented Mar 23, 2026

Summary

  • Replaces the two-layer SessionManager struct + LiveSessionManager private actor with a single SessionStateMachine actor that owns all session state transitions explicitly
  • Introduces a SessionState enum with four cases: .uninitialized, .unauthenticated, .authenticated(Session), and .refreshing(session:task:)
  • Refresh coalescing is now an explicit property of the .refreshing state rather than an implicit nullable Task? field (inFlightRefreshTask)
  • remove() cancels any in-flight refresh task via the associated value in .refreshing
  • .tokenRefreshed event emission is consolidated inside refresh(token:) instead of being scattered across call sites
  • Removes the SessionManager struct facade; AuthClient, APIClient, and AuthMFA hold a SessionStateMachine reference directly via Dependencies

State machine

.uninitialized  ──(first access)──▶  .unauthenticated
                                  └▶  .authenticated(session)

.authenticated  ──(session expired)──▶  .refreshing(session, task)
.refreshing     ──(refresh success)──▶  .authenticated(newSession)
.refreshing     ──(refresh failure)──▶  .unauthenticated
any             ──(remove)──────────▶  .unauthenticated

State starts as .uninitialized and transitions to .authenticated or .unauthenticated on first access to storage, avoiding a circular dependency with Dependencies registration during AuthClient.init.

Test plan

  • swift build passes
  • swift test --filter AuthTests passes (167 tests, 0 failures)
  • No SessionManager / sessionManager references remain in Sources/

🤖 Generated with Claude Code

…e actor

Replaces the two-layer `SessionManager` struct + `LiveSessionManager` actor
with a single `SessionStateMachine` actor that owns all session state
transitions explicitly.

- Introduces `SessionState` enum with `.uninitialized`, `.unauthenticated`,
  `.authenticated`, and `.refreshing` cases
- State starts as `.uninitialized` and loads from storage on first access,
  avoiding a dependency on `Dependencies` being registered before init
- Refresh coalescing is now an explicit property of the `.refreshing` state
  rather than an implicit nullable `Task?` field
- `remove()` cancels any in-flight refresh task via the `.refreshing` state
- `.tokenRefreshed` event emission is consolidated inside `refresh(token:)`
- Removes the `SessionManager` struct facade; consumers hold a
  `SessionStateMachine` reference directly via `Dependencies`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 23, 2026 19:10
@github-actions github-actions Bot added the auth label Mar 23, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Refactors the Auth session layer by replacing the SessionManager facade + LiveSessionManager actor with a single SessionStateMachine actor that owns session lifecycle/state transitions, and updates all call sites/tests to use the new API.

Changes:

  • Introduces SessionStateMachine + SessionState and removes the old SessionManager implementation.
  • Updates Dependencies and all internal consumers (AuthClient, APIClient, AuthMFA) to use sessionMachine.validSession() / refresh(token:) / update / remove.
  • Updates test dependency setup and session tests to use SessionStateMachine.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
Tests/AuthTests/StoredSessionTests.swift Updates test dependency wiring from sessionManager to sessionMachine.
Tests/AuthTests/SessionManagerTests.swift Migrates tests to SessionStateMachine.validSession() and updated dependency wiring.
Tests/AuthTests/MockHelpers.swift Updates default Dependencies.live to provide a SessionStateMachine.
Tests/AuthTests/AuthClientTests.swift Removes unused SessionManager test property cleanup.
Sources/Auth/Internal/SessionStateMachine.swift Adds new actor-based session state machine implementation (refresh coalescing, auto-refresh, storage load).
Sources/Auth/Internal/SessionManager.swift Removes the previous SessionManager/LiveSessionManager implementation.
Sources/Auth/Internal/Dependencies.swift Replaces stored dependency from sessionManager to sessionMachine.
Sources/Auth/Internal/APIClient.swift Switches authorized requests and cleanup paths to use sessionMachine.
Sources/Auth/AuthMFA.swift Uses sessionMachine for session reads/updates in MFA flows.
Sources/Auth/AuthClient.swift Uses sessionMachine for session access, refresh, update/remove, and auto-refresh start/stop.

Comment on lines +101 to +111
do {
let newSession = try await refreshTask.value
update(newSession)
eventEmitter.emit(.tokenRefreshed, session: newSession)
return newSession
} catch {
if !(error is CancellationError) {
state = .unauthenticated
}
throw error
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

In refresh(token:), if refreshTask.value throws CancellationError, the catch block rethrows without clearing state from .refreshing(...). That can leave the state machine stuck in .refreshing with a canceled task, making subsequent validSession() calls immediately fail with CancellationError. Consider resetting state back to the previous .authenticated(session) (or .unauthenticated) on cancellation (or using a defer to always clear .refreshing when the task completes/cancels).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The refresh task is only cancelled by the remove() method, which clear the state already, so I don't think this is an issue.

return newSession
} catch {
if !(error is CancellationError) {
state = .unauthenticated
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

On refresh failure (non-cancellation), state is set to .unauthenticated but the previously stored (likely expired) session is not removed from sessionStorage. Because validSession() only calls loadFromStorage() when state == .uninitialized, the machine can get into a persistent mismatch where storage has a session but validSession() always throws sessionMissing (also breaking autoRefreshTokenTick() which reads storage directly). Either delete storage when transitioning to unauthenticated here, or restore state to .authenticated(previousSession)/force a reload so callers can retry refresh.

Suggested change
state = .unauthenticated
remove()

Copilot uses AI. Check for mistakes.
Comment on lines +22 to 24
var sut: SessionStateMachine {
Dependencies[clientID].sessionMachine
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

The test file/class is still named SessionManagerTests, but it now exercises SessionStateMachine (sut is a SessionStateMachine). Renaming the test type/file would reduce confusion and help future searchability (e.g. when grepping for SessionManager usages).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +66 to +70
/// Forces a token refresh with the given refresh token.
///
/// If a refresh is already in flight, this method coalesces with it and returns the same result.
/// Emits a `.tokenRefreshed` event on success.
func refresh(token: String) async throws -> Session {
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

SessionStateMachine adds new behavior around refresh failures/cancellation, but the test suite shown here only covers the happy-path and refresh coalescing. Please add tests for at least: (1) refresh cancellation restoring a usable state, and (2) refresh failure leaving storage/state consistent (and auto-refresh able to recover).

Copilot generated this review using guidance from repository custom instructions.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 25, 2026

@grdsdev I've opened a new pull request, #933, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 25, 2026

@grdsdev I've opened a new pull request, #934, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 2 commits March 25, 2026 15:57
…eTests (#933)

* Initial plan

* refactor(auth-tests): rename SessionManagerTests to SessionStateMachineTests

Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com>
Agent-Logs-Url: https://github.com/supabase/supabase-swift/sessions/15fc44fe-ec53-42d5-9c01-6f9d3283338e

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com>
…ation and failure (#934)

* Initial plan

* test(auth): add tests for refresh cancellation and failure in SessionStateMachine

Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com>
Agent-Logs-Url: https://github.com/supabase/supabase-swift/sessions/96bb2c8d-09ab-479b-89de-3bd718602e12

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com>
Co-authored-by: Guilherme Souza <guilherme@supabase.io>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Comment on lines +140 to +145
// State is unauthenticated after failure – validSession() throws sessionMissing.
do {
_ = try await sut.validSession()
XCTFail("Expected sessionMissing after refresh failure")
} catch AuthError.sessionMissing {
// Expected: storage and state are consistent.
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This test’s comment says “storage and state are consistent”, but it only asserts validSession() throws sessionMissing. With the current SessionStateMachine.refresh(token:) implementation, the expired session remains in sessionStorage, so storage/state are not actually consistent. Consider either asserting Dependencies[clientID].sessionStorage.get() is nil after the failure (if failure should sign out), or adjusting the expectation/comment to match the intended behavior (e.g. allowing retries without clearing storage).

Suggested change
// State is unauthenticated after failure – validSession() throws sessionMissing.
do {
_ = try await sut.validSession()
XCTFail("Expected sessionMissing after refresh failure")
} catch AuthError.sessionMissing {
// Expected: storage and state are consistent.
// After the failed refresh, state is unauthenticated – validSession() throws sessionMissing.
// The expired session may still be present in storage so that a refresh can be retried later.
do {
_ = try await sut.validSession()
XCTFail("Expected sessionMissing after refresh failure")
} catch AuthError.sessionMissing {
// Expected: the state machine reports no valid session.

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +98
// Block until the task is cancelled; Task.sleep throws CancellationError on cancellation.
try await Task.sleep(nanoseconds: NSEC_PER_SEC * 60)
return .stub(Session.validSession)
}
)

let sut = sut
let refreshTask = Task {
try await sut.validSession()
}

await Task.yield()

// Cancel the in-flight refresh by removing the session.
await sut.remove()

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

testRefreshCancellation_shouldRestoreUsableState relies on a single await Task.yield() before calling remove(), but remove() only cancels the refresh when the state is already .refreshing. If the refresh hasn’t transitioned yet, the HTTP stub will sleep for 60s and the test can hang/slow down significantly. Consider adding a deterministic synchronization point (e.g. a signal from the HTTP handler that the request started) and/or wrapping the awaited refreshTask.value in a short withTimeout to keep the test bounded.

Copilot uses AI. Check for mistakes.
Comment on lines 1393 to 1398
/// Starts an auto-refresh process in the background. The session is checked every few seconds. Close to the time of expiration a process is started to refresh the session. If refreshing fails it will be retried for as long as necessary.
///
/// If you set ``Configuration/autoRefreshToken`` you don't need to call this function, it will be called for you.
public func startAutoRefresh() {
Task { await sessionManager.startAutoRefresh() }
Task { await sessionMachine.startAutoRefresh() }
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The doc comment for startAutoRefresh() says “If refreshing fails it will be retried for as long as necessary.” With the new SessionStateMachine.refresh(token:) behavior of transitioning to .unauthenticated on non-cancellation errors, validSession() can start throwing sessionMissing and auto-refresh may stop attempting actual refreshes (depending on final failure-handling semantics). Please update this doc comment (or the refresh failure handling) so the public API documentation matches the real behavior.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants