Fix cross-process CLI token refresh races - #675
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses cross-process races during CLI OAuth token refresh by introducing an advisory, cross-process lock to serialize refresh-token rotation and by adopting tokens refreshed by another concurrent CLI process.
Changes:
- Add a cross-process “refresh” sidecar lock (
*.refresh.lock) inAuthStore, acquired viaspawn_blockingto keep blocking waits off Tokio worker threads. - In the client refresh path, acquire the refresh lock before rotating tokens and re-read the auth store to adopt tokens refreshed by another process.
- Add a concurrency test ensuring two clients only perform a single refresh call for rotating refresh tokens.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
lib/foundation/fabro-client/src/client.rs |
Serializes refresh-token rotation across processes and adopts refreshed tokens from the auth store; adds a concurrent refresh test. |
lib/foundation/fabro-client/src/auth_store.rs |
Implements an advisory cross-process refresh lock using a sidecar lockfile and spawn_blocking. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The cross-process refresh lock added a third copy of the open-file, try-lock, then block-on-contention sequence. Collapse all three into one `open_locked_file` helper parameterized by `LockMode`, which removes `open_lock_file` and `lock_error`. Lock calls are now qualified as `FileExt` calls throughout, since `std::fs::File` has inherent locking methods with different return types that take precedence over trait methods. Also give `acquire_refresh_lock` one signature on all platforms by defining `RefreshLockGuard` for non-Unix targets too, instead of returning `Result<(), _>` there and `Result<RefreshLockGuard, _>` on Unix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Ran a simplification pass over this branch ( The cross-process refresh lock had introduced a third copy of the same open-file → try-lock → block-on-contention sequence already present in Two smaller things:
Behavior is unchanged. The try-then-block sequence is preserved inside the shared helper — it is deliberate (added in 6ac8bf6 for filesystem-lock error classification) and now documented, since only genuine contention reports Considered and rejected:
Verification: One caveat: the non-Unix branches are not compiled anywhere. CI is Linux and macOS only, and no Windows target is installed locally, so that change is unverified by compilation. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
lib/foundation/fabro-client/src/auth_store.rs:112
- The new
LockError::Taskmessage says “auth store lock”, but this error is emitted while waiting for the refresh sidecar lock (*.refresh.lock). This is confusing when debugging lock contention.
#[error("failed to wait for auth store lock at {path}: {source}")]
lib/foundation/fabro-client/src/auth_store.rs:231
- On non-Unix targets,
acquire_refresh_lock()returnsUnsupportedPlatform, butClient::refresh_access_token()now calls it unconditionally. This can break the dev-token “adopt updated token from auth store” path (and yields a misleading OAuth-login error) even though reading the auth store is still supported on non-Unix.
#[cfg(not(unix))]
pub(crate) async fn acquire_refresh_lock(&self) -> Result<RefreshLockGuard, AuthStoreError> {
Err(AuthStoreError::UnsupportedPlatform)
}
The staleness check treated "different from the token that failed" as "usable". A long-lived process could read an entry a sibling rotated an hour earlier, whose access token had since expired, install it, and return Ok. The caller retries once and does not refresh again, so that surfaced a 401. Require the stored token to be unexpired; an expired one now falls through and rotates with the refresh token just read. Also: - Give the non-Unix `acquire_refresh_lock` a no-op passthrough, matching the other lock helpers off Unix. Returning an error there broke re-installing a stored dev token, which needs no lock because it never writes. - Rename `Client::refresh_lock` to `local_refresh_lock`. Two different locks were sharing one word four lines apart. - Gate `LockError::Task` on Unix, where its only construction site is. - Give the concurrency test a no-proxy transport connector. Building clients without one goes through `connect_target_transport`, which does not disable proxy discovery, against localhost. - Assert the rotated refresh token reaches the store, which is the invariant behind single-use rotation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Follow-up ( The staleness check could install an already-expired token. Fixed by requiring the stored token to be unexpired; an expired one falls through and rotates with the refresh token just read. Worth stressing that this check is an optimization, not the safety property — the lock plus the fresh read is what makes single-use rotation safe. Without the check the loser would rotate with the new refresh token it just read and still succeed, just burning one extra rotation. So it should not cost correctness. Added Also in this commit:
Two things I deliberately did not change:
Verification: The non-Unix caveat from my previous comment still applies: those branches compile nowhere, so the passthrough change is unverified. |
LockError::Task is only constructed while waiting for the refresh sidecar lock, so "auth store lock" pointed at the wrong file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
lib/foundation/fabro-client/src/auth_store.rs:283
- Same issue as
with_shared_lock:with_exclusive_lockshould also ensure the parent directory exists before opening/creating the lock file. Otherwise first-time writes to a newFABRO_AUTH_FILElocation can fail earlier than intended when trying to open*.lock.
#[cfg(unix)]
fn with_exclusive_lock<T>(
&self,
f: impl FnOnce() -> Result<T, AuthStoreError>,
) -> Result<T, AuthStoreError> {
let _lock = open_locked_file(self.lock_path(), LockMode::Exclusive)?;
f()
}
lib/foundation/fabro-client/src/auth_store.rs:266
with_shared_lockopens/creates the lock file but does not ensure the auth store parent directory exists first. IfFABRO_AUTH_FILEpoints at a path in a not-yet-created directory, read-only commands likefabro auth status/logoutcan fail withENOENTwhen opening*.lock, even though a missing auth store should behave like “empty”.
This issue also appears on line 276 of the same file.
fn with_shared_lock<T>(
&self,
f: impl FnOnce() -> Result<T, AuthStoreError>,
) -> Result<T, AuthStoreError> {
let _lock = open_locked_file(self.lock_path(), LockMode::Shared)?;
f()
}
Summary
Tests