Fix cross-process Linear OAuth refresh wiping valid connections#400
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. |
e6da089 to
392df60
Compare
|
@copilot review but do not make fixes |
|
@copilot review but do not make fixes |
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>
a6a721d to
1ab066c
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 condition where a near-expiry Linear OAuth refresh triggered by both the ADE desktop app and
ade servesimultaneously would cause the slower process to receiveinvalid_grant(Linear rotates the refresh token on each exchange) and incorrectly wipe the shared credential store.linear-oauth-refresh.lockin the secrets dir) serializes refresh attempts across runtimes; both desktop and headless services acquire the lock, re-read credentials inside it, and skip the refresh if a peer already completed it.linearInvalidGrantLikelyStaleRotation) re-reads the credential store oninvalid_grantand treats the failure as benign if the refresh token was already rotated by a peer or the access token expiry is freshly extended; this guard is intentionally bypassed on forced refreshes (force: true) to avoid masking genuinely dead tokens.LOCK_STALE_MS(10 s) is now less thanLOCK_TIMEOUT_MS(15 s), making stale-lock recovery actually reachable within the wait window, and the retry loop now uses asyncsetTimeout-based sleep so the event loop is not blocked.Confidence Score: 5/5
Safe to merge. The fix correctly serializes cross-process token refreshes and avoids wiping valid connections on a stale invalid_grant; the previous reviewer concerns about event-loop blocking, stale-constant ordering, and unhandled timeout rejections are all resolved.
The lock implementation, stale-rotation heuristic, and forced-refresh bypass are all well-reasoned and covered by 45+ tests. The one remaining finding (fd leak when writeFileSync fails after openSync) is a minor edge case that self-recovers via stale detection within 10 s and does not affect correctness in normal operation.
linearOAuthRefreshLock.ts — the lock acquisition block could be tightened to close and delete the lock file if writeFileSync fails before the finally block is reached.
Important Files Changed
Sequence Diagram
sequenceDiagram participant D as Desktop (ensureFreshToken) participant L as Lock File (.ade/secrets/linear-oauth-refresh.lock) participant S as ade serve (ensureFreshToken) participant Linear as Linear OAuth API participant Store as EncryptedFileCredentialStore D->>L: openSync("wx") — acquired S->>L: openSync("wx") — EEXIST, wait 25 ms loop D->>Store: re-read latest refreshToken D->>Linear: POST /oauth/token (rt_old) Linear-->>D: "{access_token, refresh_token: rt_new}" D->>Store: write at_new, rt_new, expiresAt D->>L: unlinkSync — released S->>L: openSync("wx") — acquired S->>Store: re-read latest refreshToken → rt_new Note over S: linearTokenNeedsRefresh(expiresAt) = false → skip refresh S->>L: unlinkSync — releasedPrompt To Fix All With AI
Reviews (5): Last reviewed commit: "fix: make Linear OAuth refresh stale loc..." | Re-trigger Greptile