Follow-ups against 1.4.6 (a2e9773) in the token refresh coordination path. None of them blocks 1.4.6; they are grouped here so they can land together in one patch release.
1. Darwin observer is never re-registered after the last observer unregisters
Sources/Conduit/Core/DarwinNotificationCenter.swift:54-61, 85-105. unregister leaves the key in observerMap with an empty array; registerObserver early-returns whenever the key already exists, so CFNotificationCenterAddObserver runs only once per notification name per process. After the first contended refresh, didEndTokenFetchNotification is never delivered again and every waiter falls back to its timeout: the 0.5s poll on the coordinated path, the full lock timeout on the legacy path. Pre-dates 1.4.5.
Reproduces with: register → post → handler fires → unregister → register → post → handler never fires.
Fix: remove the key when its observer array empties.
2. Waiter wake-up runs on the main thread
DarwinNotificationCenter.swift:117-128. The CF callback fires on the main thread and handleDarwinNotification runs observer.queue.sync { handler } inline, so the waiter's prepareForTransport (a token-store read, which may decrypt) executes on main.
Fix: dispatch the handler async on observer.queue, and move the once-guard from OAuth2RequestPipelineMiddleware.waitForRefreshThenResumeOnce into OAuth2TokenRefreshCoordinator.waitForRefresh so the notification and the timeout can never both invoke a handler. Must ship together with (1): fixing (1) alone makes this fire on every contended refresh instead of once per process, and re-exposes the legacy path's double invocation.
3. Coordinated wait has no deadline
Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift:118-176. A waiter re-enters prepareForTransport every ≤0.5s with no attempt counter or overall bound. If the in-flight grant never completes (timeoutIntervalForResource defaults to 7 days), the request never calls its completion.
Fix: carry a deadline (on the order of tokenRefreshLockRelinquishInterval) through the coordinated re-entry and fail with the existing URLSessionClientError.requestTimeout once it passes. Coordinated path only.
4. Post-fetch hook fires for a superseded holder
OAuth2RequestPipelineMiddleware.swift:307-312, versus the owns(...) gates at :190 and :203. Store and unlock are ownership-gated, but notifyTokenPostFetchHooksWith is not, so a holder whose claim was taken over still runs consumer hooks after completion. A hook that tracks the in-progress refresh can then mishandle the owner's result.
Fix: notify post-fetch hooks only when the completing refresh still owns the claim. Legacy path unchanged.
5. Stale-steal point is independent of the grant's own timeout
tokenRefreshLockRelinquishInterval (default 30s) sets both the claim's staleAfter and the storage lock timeout, while the grant itself runs on Auth.sessionClient, whose timeoutIntervalForRequest defaults to 60s. A grant slower than the interval is taken over mid-flight and the same one-time refresh token is sent twice, which is the case the claim registry exists to prevent. Today a consumer can only avoid this by configuring every middleware instance in the process, including ones built by intermediate libraries, or by shortening the grant client's timeout.
Fix: derive the stale-steal point from the grant client's timeout (for example max(tokenRefreshLockRelinquishInterval, grantTimeout + margin)) so the invariant holds by construction for every instance.
6. Claim key carries no store namespace
OAuth2RequestPipelineMiddleware.swift:38-40 keys the claim on tokenIdentifierFor (client + level + type). Stores sandboxed by the consumer (for example per-user UserDefaults suites) share one claim, so refreshes for different sandboxes serialize against each other. False serialization only; needs a protocol change, so likely won't fix.
Follow-ups against 1.4.6 (
a2e9773) in the token refresh coordination path. None of them blocks 1.4.6; they are grouped here so they can land together in one patch release.1. Darwin observer is never re-registered after the last observer unregisters
Sources/Conduit/Core/DarwinNotificationCenter.swift:54-61, 85-105.unregisterleaves the key inobserverMapwith an empty array;registerObserverearly-returns whenever the key already exists, soCFNotificationCenterAddObserverruns only once per notification name per process. After the first contended refresh,didEndTokenFetchNotificationis never delivered again and every waiter falls back to its timeout: the 0.5s poll on the coordinated path, the full lock timeout on the legacy path. Pre-dates 1.4.5.Reproduces with: register → post → handler fires → unregister → register → post → handler never fires.
Fix: remove the key when its observer array empties.
2. Waiter wake-up runs on the main thread
DarwinNotificationCenter.swift:117-128. The CF callback fires on the main thread andhandleDarwinNotificationrunsobserver.queue.sync { handler }inline, so the waiter'sprepareForTransport(a token-store read, which may decrypt) executes on main.Fix: dispatch the handler async on
observer.queue, and move the once-guard fromOAuth2RequestPipelineMiddleware.waitForRefreshThenResumeOnceintoOAuth2TokenRefreshCoordinator.waitForRefreshso the notification and the timeout can never both invoke a handler. Must ship together with (1): fixing (1) alone makes this fire on every contended refresh instead of once per process, and re-exposes the legacy path's double invocation.3. Coordinated wait has no deadline
Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift:118-176. A waiter re-entersprepareForTransportevery ≤0.5s with no attempt counter or overall bound. If the in-flight grant never completes (timeoutIntervalForResourcedefaults to 7 days), the request never calls its completion.Fix: carry a deadline (on the order of
tokenRefreshLockRelinquishInterval) through the coordinated re-entry and fail with the existingURLSessionClientError.requestTimeoutonce it passes. Coordinated path only.4. Post-fetch hook fires for a superseded holder
OAuth2RequestPipelineMiddleware.swift:307-312, versus theowns(...)gates at:190and:203. Store and unlock are ownership-gated, butnotifyTokenPostFetchHooksWithis not, so a holder whose claim was taken over still runs consumer hooks aftercompletion. A hook that tracks the in-progress refresh can then mishandle the owner's result.Fix: notify post-fetch hooks only when the completing refresh still owns the claim. Legacy path unchanged.
5. Stale-steal point is independent of the grant's own timeout
tokenRefreshLockRelinquishInterval(default 30s) sets both the claim'sstaleAfterand the storage lock timeout, while the grant itself runs onAuth.sessionClient, whosetimeoutIntervalForRequestdefaults to 60s. A grant slower than the interval is taken over mid-flight and the same one-time refresh token is sent twice, which is the case the claim registry exists to prevent. Today a consumer can only avoid this by configuring every middleware instance in the process, including ones built by intermediate libraries, or by shortening the grant client's timeout.Fix: derive the stale-steal point from the grant client's timeout (for example
max(tokenRefreshLockRelinquishInterval, grantTimeout + margin)) so the invariant holds by construction for every instance.6. Claim key carries no store namespace
OAuth2RequestPipelineMiddleware.swift:38-40keys the claim ontokenIdentifierFor(client + level + type). Stores sandboxed by the consumer (for example per-userUserDefaultssuites) share one claim, so refreshes for different sandboxes serialize against each other. False serialization only; needs a protocol change, so likely won't fix.