Fix cross-process Linear OAuth refresh wiping valid connections#400
Fix cross-process Linear OAuth refresh wiping valid connections#400cursor[bot] wants to merge 2 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
3c9fdca to
93a6ff3
Compare
|
@copilot review but do not make fixes |
There was a problem hiding this comment.
PR Review
Scope: 7 file(s), +352 / −45
Verdict: Looks good
This PR fixes a real cross-process race: when desktop and ade serve refresh Linear OAuth near expiry, the loser’s invalid_grant was clearing a still-valid shared connection. It adds a file lock under .ade/secrets, re-reads credentials inside the lock, and treats invalid_grant as benign when the store already shows rotation or a fresh expiry (except on forced refresh after auth failure). Tests cover the race, forced refresh, and headless parity.
Notes
- Tests: The concurrent-rotation and forced-refresh cases in
linearAuth.test.tsandheadlessLinearServices.test.tsmatch the failure mode described in the PR and give good regression coverage. - Lock helper:
linearOAuthRefreshLock.tsfollows the samewx+ stale-mtime pattern asapps/ade-cli/src/tuiClient/state.ts; lock contention should be rare (only when two runtimes refresh at once). - Callers:
linearClientalready swallowsensureFreshTokenerrors, so a 15s lock timeout surfaces as a best-effort skip rather than a user-facing crash. - Docs (non-blocking):
docs/features/cto/linear-integration.mdstill saysinvalid_grantalways clears the connection; behavior is now conditional on stale rotation detection.
Sent by Cursor Automation: BUGBOT in Versic
|
@copilot review but do not make fixes |
|
Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews. |
When desktop and ade serve both refresh near token expiry, Linear rotates the refresh token on the first exchange. The loser gets invalid_grant and was clearing the shared credential store, forcing a full reconnect. Serialize refresh with a cross-process lock under .ade/secrets and treat invalid_grant as stale when the store already has a rotated refresh token or a fresh access token. Co-authored-by: Arul Sharma <arul28@users.noreply.github.com>
e6da089 to
392df60
Compare
|
@copilot review but do not make fixes |


Fixes ADE-63
Bug and impact
When ADE desktop and
ade serve(headless runtime) are both active for the same project, a near-expiry Linear OAuth refresh can race. Linear rotates the refresh token on the first successful exchange; the second runtime retries with the now-invalid refresh token, getsinvalid_grant, and clears the shared credential store — forcing the user to reconnect Linear even though the connection was still valid.This was introduced by the automatic OAuth token refresh added in #395.
Root cause
ensureFreshToken()deduped refreshes in-process only (refreshInFlight).EncryptedFileCredentialStoreunder.ade/secrets.invalid_grant, both services unconditionally cleared all Linear credentials.Fix
linear-oauth-refresh.lock) so only one runtime refreshes at a time.invalid_grant; if the refresh token was rotated or the access token is now fresh, treat the failure as a stale race and do not clear the connection.linearCredentialService) and headless (headlessLinearServices).Validation
npm run test -- --run src/main/services/cto/linearTokenRefresh.test.ts src/main/services/cto/linearAuth.test.ts(45 tests passed)npm --prefix apps/ade-cli run test -- --run src/headlessLinearServices.test.ts(22 tests passed)npm --prefix apps/desktop run typecheckGreptile Summary
This PR fixes a cross-process race where ADE Desktop and
ade servecould both attempt a Linear OAuth refresh concurrently, causing the slower process to receiveinvalid_grant(Linear rotates the refresh token on exchange) and unconditionally wipe the shared credential store.linear-oauth-refresh.lock) serialises refresh attempts across runtimes; the lock callback re-reads the credential store after acquisition so a proactive refresh that is no longer needed is skipped.linearInvalidGrantLikelyStaleRotation) detects thatinvalid_grantwas caused by a peer rotation (different refresh token or fresh expiry already in the store) rather than a genuinely dead token, preventing unnecessary credential clearing.localRuntimeConnectionPool.test.tsaddsisClosed: () => falseto mock client objects to satisfy a new interface requirement unrelated to OAuth.Confidence Score: 5/5
Safe to merge; the cross-process lock and stale-rotation recovery logic are well-reasoned and well-tested, with no regressions on the existing token-refresh paths.
The core race-condition fix is correct: the lock serialises cross-process refreshes and the re-read inside the lock prevents redundant work. The invalidGrant recovery logic is sound — it only treats the failure as stale when the store already holds a rotated token or a fresh expiry, and forced-refresh correctly opts out of the expiry heuristic. The two style-level findings do not affect normal operation.
No files require special attention; linearOAuthRefreshLock.ts and headlessLinearServices.ts have minor inconsistencies noted in comments but nothing that blocks correctness under normal conditions.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Desktop as ADE Desktop participant Headless as ade serve participant Lock as lock file participant Linear as Linear OAuth API participant Store as CredentialStore Note over Desktop,Headless: Token near expiry — both runtimes call ensureFreshToken() Desktop->>Lock: openSync wx — acquires lock Headless->>Lock: openSync wx — EEXIST, retries every 25ms Desktop->>Store: re-read refreshToken inside lock Desktop->>Linear: POST /oauth/token rt_v1 Linear-->>Desktop: 200 OK rt_v2 + fresh expiresAt Desktop->>Store: write at_v2 / rt_v2 / expiresAt Desktop->>Lock: unlink — releases lock Headless->>Lock: openSync wx — acquires lock Headless->>Store: re-read refreshToken — sees rt_v2 Note over Headless: expiresAt fresh, linearTokenNeedsRefresh=false — skip Headless->>Lock: unlink — releases lock Note over Desktop,Headless: Single refresh performed, connection preserved Note over Desktop,Headless: Fallback — invalid_grant recovery path Headless->>Linear: POST /oauth/token rt_v1 Linear-->>Headless: 400 invalid_grant Headless->>Store: re-read store — sees rt_v2 or fresh expiresAt Note over Headless: linearInvalidGrantLikelyStaleRotation=true — skip clearPrompt To Fix All With AI
Reviews (2): Last reviewed commit: "fix: address Linear OAuth refresh review..." | Re-trigger Greptile