fix(auth): a session survives the network, and boot does not wait on it - #123
Merged
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two defects on the same
BaseGuard.restore()path, found by running a Flutterapp 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()awaitsrestore(), which holdsMagic.init(),which holds
runApp.restore()awaited_syncUserFromApi()even afterloadCachedUser()had produced a user andsetUserhad put it in place, so thewhole 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 restoredand theconsumer's own theme logging not appearing until it let go.
The class docblock has described the intent since it was written:
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 anynon-2xx as a rejected token and called
logout().DioNetworkDriver._handleErrorreports a transport failure as
statusCode: 0, because a timeout or a DNS misshas 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 invalidabout a server that never spoke. Only401and
403end a session now; anything else keeps the cached one and logs thestatus 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 aMockGuardthat OVERRIDESrestore()with three lines, so the real implementation was never driven. Thethree new cases use a guard that keeps
BaseGuard.restore()and swap the networkdriver instead.
Testing
flutter test1297 passing,flutter analyze --no-fatal-infosclean,dart format --set-exit-if-changed .clean.The reproducers report the mechanism rather than just a red bar:
Completerand is deliberatelywritten with no timeout wrapper, so without the fix it fails as a 30s hang,
which is the defect's own shape
statusCode: 0and asserts bothcheck()and thetoken still in the vault
401case asserts the opposite, so the fix cannot be read as "never logout"
Verified on the device, against the blackholed backend, in both directions:
Auth: No token found in storagebecause the timeout had logged the user out(
Auth: Token loaded from storage,Auth: Cached user restored), and abouttwo minutes later the background sync reports
Auth: user sync failed (status 0); keeping the cached sessionNote for consumers
With a cached session,
AuthRestorednow fires afterrunApprather thanduring boot. Listeners are unaffected by construction;
magic_starter's_ReloadOnAuthRestoredalready exists to rebuild when it lands, which is thebehaviour this makes reachable rather than something it breaks.