refactor(auth): replace SessionManager facade with SessionStateMachine actor#931
refactor(auth): replace SessionManager facade with SessionStateMachine actor#931
Conversation
…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>
There was a problem hiding this comment.
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+SessionStateand removes the oldSessionManagerimplementation. - Updates
Dependenciesand all internal consumers (AuthClient,APIClient,AuthMFA) to usesessionMachine.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. |
| 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 | ||
| } |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| state = .unauthenticated | |
| remove() |
| var sut: SessionStateMachine { | ||
| Dependencies[clientID].sessionMachine | ||
| } |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| /// 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 { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
…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>
| // 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. |
There was a problem hiding this comment.
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).
| // 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. |
| // 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() | ||
|
|
There was a problem hiding this comment.
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.
| /// 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() } | ||
| } |
There was a problem hiding this comment.
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.
Summary
SessionManagerstruct +LiveSessionManagerprivate actor with a singleSessionStateMachineactor that owns all session state transitions explicitlySessionStateenum with four cases:.uninitialized,.unauthenticated,.authenticated(Session), and.refreshing(session:task:).refreshingstate rather than an implicit nullableTask?field (inFlightRefreshTask)remove()cancels any in-flight refresh task via the associated value in.refreshing.tokenRefreshedevent emission is consolidated insiderefresh(token:)instead of being scattered across call sitesSessionManagerstruct facade;AuthClient,APIClient, andAuthMFAhold aSessionStateMachinereference directly viaDependenciesState machine
State starts as
.uninitializedand transitions to.authenticatedor.unauthenticatedon first access to storage, avoiding a circular dependency withDependenciesregistration duringAuthClient.init.Test plan
swift buildpassesswift test --filter AuthTestspasses (167 tests, 0 failures)SessionManager/sessionManagerreferences remain inSources/🤖 Generated with Claude Code