Skip to content

fix(auth): a session survives the network, and boot does not wait on it - #123

Merged
anilcancakir merged 1 commit into
masterfrom
fix/auth-restore-offline
Aug 19, 2026
Merged

fix(auth): a session survives the network, and boot does not wait on it#123
anilcancakir merged 1 commit into
masterfrom
fix/auth-restore-offline

Conversation

@anilcancakir

Copy link
Copy Markdown
Contributor

What

Two defects on the same BaseGuard.restore() path, found by running a Flutter
app on an iPhone 17 simulator against a backend that accepts the TCP connection
and then never answers, which is what a captive portal, a dead mobile link or a
hung server actually looks like to a phone.

1. Boot waited for a call the cache had already answered.
AuthServiceProvider.boot() awaits restore(), which holds Magic.init(),
which holds runApp. restore() awaited _syncUserFromApi() even after
loadCachedUser() had produced a user and setUser had put it in place, so the
whole client timeout was time spent looking at a blank window. On an app
configured for a 120s timeout that measured as roughly two minutes of white
screen, with the console stopping dead on Auth: Cached user restored and the
consumer's own theme logging not appearing until it let go.

The class docblock has described the intent since it was written:

/// On restore:
/// 1. Load user from cache (instant)
/// 2. Sync from API in background (fresh data)

It is background now when there is a cached user, and still awaited when there
is not, because then there is nothing to render and no honest way to route.

2. Losing the network signed the user out. _syncUserFromApi() read any
non-2xx as a rejected token and called logout(). DioNetworkDriver._handleError
reports a transport failure as statusCode: 0, because a timeout or a DNS miss
has no response to carry. So a phone passing through a tunnel during the restore
call cleared the token and the cached user and dropped the app on sign-in, while
the log said Auth: Token invalid about a server that never spoke. Only 401
and 403 end a session now; anything else keeps the cached one and logs the
status it actually saw.

The two are related: the first made the second easy to hit, because the app sat
in the timeout on every cold start.

Why it had no coverage

Every existing restore() test runs against a MockGuard that OVERRIDES
restore() with three lines, so the real implementation was never driven. The
three new cases use a guard that keeps BaseGuard.restore() and swap the network
driver instead.

Testing

flutter test 1297 passing, flutter analyze --no-fatal-infos clean,
dart format --set-exit-if-changed . clean.

The reproducers report the mechanism rather than just a red bar:

  • the cache-first case uses a driver gated on a Completer and is deliberately
    written with no timeout wrapper, so without the fix it fails as a 30s hang,
    which is the defect's own shape
  • the transport case returns statusCode: 0 and asserts both check() and the
    token still in the vault
  • the 401 case asserts the opposite, so the fix cannot be read as "never log
    out"

Verified on the device, against the blackholed backend, in both directions:

  • before: two minutes of white, then the next launch logged
    Auth: No token found in storage because the timeout had logged the user out
  • after: the shell and its skeleton render immediately with the session intact
    (Auth: Token loaded from storage, Auth: Cached user restored), and about
    two minutes later the background sync reports
    Auth: user sync failed (status 0); keeping the cached session

Note for consumers

With a cached session, AuthRestored now fires after runApp rather than
during boot. Listeners are unaffected by construction; magic_starter's
_ReloadOnAuthRestored already exists to rebuild when it lands, which is the
behaviour this makes reachable rather than something it breaks.

Two defects on the same restore path, both found by running a Flutter app on an
iPhone simulator against a backend that accepts connections and never answers.

`restore()` awaited `_syncUserFromApi()` even after the cache had already
produced a user. `AuthServiceProvider.boot()` awaits `restore()`, which holds
`Magic.init()`, which holds `runApp`, so that await was the user staring at a
blank window for the whole client timeout: about two minutes on an app
configured for 120s, with the console stopping dead on "Auth: Cached user
restored". The class docblock has said "2. Sync from API in background" from the
start. It is background now when there is a cached user to render, and still
awaited when there is not, because then nothing can be drawn and no route can be
chosen honestly.

Then `_syncUserFromApi()` read any non-2xx as a rejected token and logged out.
`DioNetworkDriver._handleError` reports a transport failure as statusCode 0,
since a timeout or a dead link has no response to carry, so a phone passing
through a tunnel cleared the token and the cached user and landed on sign-in,
while the log claimed "Token invalid" about a server that never spoke. Only 401
and 403 end a session now; anything else keeps it and logs the status it saw.

The three new cases drive the real `BaseGuard.restore()` rather than a mock that
replaces it, which is why this path had no coverage: a gated driver proves the
first (the case hangs and times out without the fix, which is the defect's own
shape), and a driver returning 0 and one returning 401 pin both halves of the
second.
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@anilcancakir
anilcancakir merged commit 0f3125b into master Aug 19, 2026
4 checks passed
@anilcancakir
anilcancakir deleted the fix/auth-restore-offline branch August 19, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant