Conversation
GIDGoogleUser tokens
mdmathias
left a comment
There was a problem hiding this comment.
There are a lot of commits here, including a merge from main and two separate "Changelog: serialize…" commits. The changelog commits' wording ("serialize under the object lock") no longer matches the final design. Please squash, or otherwise clean up to make sure things are easy to follow and match.
|
|
||
| // Guards `_tokens` and `_profile`. It is only ever held for a single read or write of those | ||
| // ivars, never while calling out to other code. | ||
| os_unfair_lock _tokenLock; |
There was a problem hiding this comment.
The os_unfair_lock use looks correct, but I don't think we need it (since it seems like it rebuilds what atomic properties do under the hood for free). Every critical section is a single pointer read or write, which is exactly what a synthesized atomic property provides. The runtime does the same retain-and-return under its own lock, so a concurrent setter still can't free the snapshot. Could we:
- Drop the custom tokens accessors and let
@property(atomic, strong) GIDGoogleUserTokens *tokenssynthesize? - Move
profileintoGIDGoogleUserTokens, or a privateatomicbacking property, so-profiledoesn't need a lock either?
That removes _tokenLock, the lock-ordering rule, and os_unfair_lock_assert_not_owner, which crashes in release builds if violated. It also makes profile and the tokens change together: right now updateWithTokenResponse:… updates them in separate critical sections, so readers can see a new profile with old tokens even though they share a lock. It also fixes the old snapshot being released while the lock is held (L188). I don't think speed is a concern either way at this call frequency.
|
|
||
| @interface GIDGoogleUser () | ||
|
|
||
| - (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken |
There was a problem hiding this comment.
The method is declared in a GIDGoogleUser (Internal) category in the .m, then redeclared here in the test. Could it move to GIDGoogleUser_Private.h? Alternatively, expose the GIDGoogleUserTokens snapshot internally and have callers read one object instead of three out-params. That's simpler, and scopes and configuration come along for free.
|
|
||
| // The token properties are derived from `tokens`, so KVO observers of each one are notified | ||
| // whenever `tokens` is replaced. | ||
| + (NSSet<NSString *> *)keyPathsForValuesAffectingAccessToken { |
There was a problem hiding this comment.
Every token property now depends on tokens. So when only the refresh token or the granted scopes change, observers of accessToken (and idToken) are still notified. Before, KVO fired only for the property that actually changed. This is visible to apps (for example, anyone who re-authorizes whenever accessToken fires). Could we call it out in the CHANGELOG? Or override automaticallyNotifiesObserversForKey: and notify each key only when it actually changes?
| NSMutableDictionary *additionalParameters = [@{} mutableCopy]; | ||
| // Read the auth state under `_authStateLock` so building the request cannot interleave with | ||
| // -updateWithTokenResponse:authorizationResponse:profileData:. | ||
| [self lockAuthState]; |
There was a problem hiding this comment.
The lock also covers +[GIDEMMSupport updatedEMMParametersWithParameters:] and +[GIDSignInPreferences loggingParameters]. Consider reading only the authState fields under the lock and building additionalParameters after unlocking.
| originalAuthorizationResponse:authorizationResponse | ||
| callback:^(OIDTokenResponse *_Nullable tokenResponse, | ||
| NSError *_Nullable error) { | ||
| // Update the auth state under `_authStateLock` so this refresh cannot interleave with |
There was a problem hiding this comment.
The description says this fixes "Overlapping token updates could apply out of order", but the lock only makes each update atomic; it doesn't order them. The refresh request is built under the lock, but the lock is released while the request is in flight. If addScopes calls updateWithTokenResponse:authorizationResponse:… in the meantime, this callback then applies a response based on the pre-addScopes grant on top of it. As far as I can tell, AppAuth doesn't check which authorization a token response belongs to. The result is that grantedScopes and accessToken can revert. This is existing behavior, not a regression. Could we either drop the response when self.authState.lastAuthorizationResponse != authorizationResponse (the value you already capture at L307), or remove that line from the description?
| @property(nonatomic, readwrite, nullable) GIDToken *idToken; | ||
|
|
||
| /// A representation of the state of the OAuth session for this instance. | ||
| // TODO: Reads through this property bypass the lock GIDGoogleUser takes around its own auth |
There was a problem hiding this comment.
Thanks for the TODO. Since GIDSignIn and fetcherAuthorizer read authState without the lock, locking encodeWithCoder: and emmSupport protects against GSI's own updates but not AppAuth/GTMAppAuth refreshes that go through fetcherAuthorizer. Could we file an issue for that and link it from the TODO?
Also, it would be nice to connect TODOs with an issue on the repo for tracking purposes.
| // The consistency guarantee comes from the snapshot accessor's single lock acquisition. | ||
| // Reading the three properties individually would NOT be atomic even with the per-accessor | ||
| // locking, by design. | ||
| if ([accessToken.tokenString isEqualToString:accessTokenA]) { |
There was a problem hiding this comment.
If accessToken is neither A nor B, the read checks nothing. Consider asserting that idToken matches the initial token in that case too, so a mixed initial/A snapshot is also caught.
| // The consistency assertion above is what gives it meaning without TSan. | ||
| } | ||
|
|
||
| - (void)testRefreshTokensIfNeeded_readsConsistentSnapshot { |
There was a problem hiding this comment.
This looks similar to testRefreshTokensIfNeededWithCompletion_refresh_givenAccessTokenExpired. Suggest deleting it, or renaming it and asserting something new.
| // KVO observers of the token properties run while the user holds its auth state lock. This checks | ||
| // that an observer can call back into the user on the same thread, including starting another | ||
| // token update, without deadlocking or aborting. | ||
| - (void)testTokenObserver_reentersUserDuringUpdate { |
There was a problem hiding this comment.
Nice test. Consider adding a second observer to check that nested updates don't deliver notifications out of order (it would see B's notification before A's finishes).
| xcodebuild \ | ||
| -scheme GoogleSignIn-Package \ | ||
| -sdk iphonesimulator \ | ||
| -destination "platform=iOS Simulator,name=iPhone 16,OS=18.6" \ |
There was a problem hiding this comment.
Xcode 16.4, iPhone 16 and iOS 18.6 are hardcoded, while the other jobs use a matrix. Could we reuse the matrix values so it doesn't drift from the others? Consider a macOS destination as well.
Fixes a data race on
GIDGoogleUsertokens. This addresses a number of potential issues. (Yes, I used Markdown to write this, an LLM didn't write it :P)What changed
@synchronized(self))What could have gone wrong
Previously, accessing these properties on different threads could have resulted in a crash. AppAuth & GSI mostly deliver everything on the main queue, but it seems likely that
user.accessToken.tokenStringwould be read by networking code on the background queue. A refresh on the main queue at the same time could result inEXC_BAD_ACCESS. When you multiply that (small) likelihood by the number of times this code runs...it's probably happening.Additionally:
updateTokensWithAuthStatewas updating each token individually, and the synchronized self only applied inupdateWithTokenResponse; calls to the token update from elsewhere had no lock.synchronized(googleUser)would share the library's lock, potentially leading to deadlocks.What might still go wrong
authStatecan still be read directly, byGIDSignInand others viafetcherAuthorizer. There's a TODO on the property, but the fix would be a public API change.performActionWithFreshTokensin AppAuth still readsOIDAuthStatewithout a lock if GTMAppAuth allows that off of the main thread. This is existing behaviour, and would require a fix in AppAuth.