From 26c0c4809b9daee3f844f1349a1ce8815d9e6f79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 29 May 2026 14:11:53 +0000 Subject: [PATCH 1/3] Fix cross-process Linear OAuth refresh wiping valid connections 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 --- .../src/headlessLinearServices.test.ts | 32 +++++++ apps/ade-cli/src/headlessLinearServices.ts | 69 ++++++++++---- .../src/main/services/cto/linearAuth.test.ts | 62 +++++++++++++ .../services/cto/linearCredentialService.ts | 91 +++++++++++++------ .../services/cto/linearOAuthRefreshLock.ts | 71 +++++++++++++++ .../services/cto/linearTokenRefresh.test.ts | 39 ++++++++ .../main/services/cto/linearTokenRefresh.ts | 33 +++++++ 7 files changed, 352 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 68bd8f97b..367350ee4 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -603,6 +603,38 @@ describe("headlessLinearServices", () => { } }); + it("clears headless OAuth credentials when forced refresh gets invalid_grant without rotation", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-linear-forced-refresh-")); + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant" }), + })) as unknown as typeof fetch; + globalThis.fetch = fetchImpl; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-linear-forced-project-")); + const adeDir = path.join(projectRoot, ".ade"); + const services = createHeadlessLinearServices(createDeps({ projectRoot, adeDir })); + try { + services.linearCredentialService.setOAuthToken({ + accessToken: "at_rejected", + refreshToken: "rt_dead", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + + await services.linearCredentialService.ensureFreshToken({ force: true }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(services.linearCredentialService.getStatus().tokenStored).toBe(false); + } finally { + services.dispose(); + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + } + }); + it("assigns CTO default title for cto identityKey", async () => { const services = createHeadlessLinearServices(createDeps()); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 705787f97..4c521648f 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -58,9 +58,11 @@ import { createPrService as createPrServiceImpl } from "../../desktop/src/main/s import { createAutomationSecretService as createAutomationSecretServiceImpl } from "../../desktop/src/main/services/automations/automationSecretService"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import { + linearInvalidGrantLikelyStaleRotation, linearTokenNeedsRefresh, refreshLinearOAuthAccessToken, } from "../../desktop/src/main/services/cto/linearTokenRefresh"; +import { withLinearOAuthRefreshLock } from "../../desktop/src/main/services/cto/linearOAuthRefreshLock"; // Keep headless runtimes aligned with the desktop credential service so packaged // alpha builds can offer the same PKCE-based Linear sign-in flow. @@ -1144,8 +1146,9 @@ export function createHeadlessGitHubService( function createHeadlessLinearCredentialService(args: { adeDir: string; }): HeadlessLinearCredentialService { + const secretsDir = path.join(args.adeDir, "secrets"); const credentialStore = new EncryptedFileCredentialStore({ - secretsDir: path.join(args.adeDir, "secrets"), + secretsDir, }); const tokenKey = "linear.token.v1"; const authModeKey = "linear.authMode.v1"; @@ -1250,24 +1253,54 @@ function createHeadlessLinearCredentialService(args: { const client = readOAuthClientCredentials(); if (!client) return; refreshInFlight = (async () => { - const result = await refreshLinearOAuthAccessToken({ - refreshToken, - clientId: client.clientId, - clientSecret: client.clientSecret, + const performRefresh = async (tokenToRefresh: string): Promise => { + const result = await refreshLinearOAuthAccessToken({ + refreshToken: tokenToRefresh, + clientId: client.clientId, + clientSecret: client.clientSecret, + }); + if (result.ok) { + tokenOverride = result.accessToken; + writeCredential(tokenKey, result.accessToken); + writeCredential(authModeKey, "oauth"); + writeCredential(refreshTokenKey, result.refreshToken ?? tokenToRefresh); + writeCredential(tokenExpiresAtKey, result.expiresAt); + return; + } + if (result.invalidGrant) { + const rereadRefresh = readCredential(refreshTokenKey); + const rereadExpires = readCredential(tokenExpiresAtKey); + if ( + linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: tokenToRefresh, + rereadRefreshToken: rereadRefresh, + rereadExpiresAt: rereadExpires, + trustFreshExpiresAt: !opts?.force, + }) + ) { + tokenOverride = readCredential(tokenKey); + return; + } + tokenOverride = ""; + writeCredential(tokenKey, null); + writeCredential(authModeKey, null); + writeCredential(refreshTokenKey, null); + writeCredential(tokenExpiresAtKey, null); + return; + } + }; + + await withLinearOAuthRefreshLock(secretsDir, async () => { + const latestRefresh = readCredential(refreshTokenKey); + if (!latestRefresh) return; + if ( + !opts?.force + && !linearTokenNeedsRefresh(readCredential(tokenExpiresAtKey), Date.now()) + ) { + return; + } + await performRefresh(latestRefresh); }); - if (result.ok) { - tokenOverride = result.accessToken; - writeCredential(tokenKey, result.accessToken); - writeCredential(authModeKey, "oauth"); - writeCredential(refreshTokenKey, result.refreshToken ?? refreshToken); - writeCredential(tokenExpiresAtKey, result.expiresAt); - } else if (result.invalidGrant) { - tokenOverride = ""; - writeCredential(tokenKey, null); - writeCredential(authModeKey, null); - writeCredential(refreshTokenKey, null); - writeCredential(tokenExpiresAtKey, null); - } })().finally(() => { refreshInFlight = null; }); diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 40fb594e4..1305620e1 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -1272,6 +1272,68 @@ describe("linearCredentialService OAuth token refresh", () => { expect(service.getStatus().tokenStored).toBe(false); }); + it("keeps the connection when invalid_grant follows a concurrent refresh rotation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-refresh-race-")); + const store = new MemoryCredentialStore(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = String(init?.body ?? ""); + if (body.includes("refresh_token=rt_old")) { + store.setSync("linear.token.v1", "at_from_peer"); + store.setSync("linear.authMode.v1", "oauth"); + store.setSync("linear.refreshToken.v1", "rt_new"); + store.setSync( + "linear.tokenExpiresAt.v1", + new Date(Date.now() + 60 * 60 * 1000).toISOString(), + ); + return { + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant" }), + }; + } + return okResponse({ access_token: "at", refresh_token: "rt", expires_in: 86399 }); + }); + const service = createLinearCredentialService({ + adeDir: path.join(root, ".ade"), + logger: createLogger(), + credentialStore: store, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + service.setOAuthToken({ + accessToken: "at_old", + refreshToken: "rt_old", + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + + await service.ensureFreshToken(); + + expect(service.getToken()).toBe("at_from_peer"); + expect(service.getStatus()).toMatchObject({ + tokenStored: true, + authMode: "oauth", + refreshTokenStored: true, + }); + }); + + it("does not keep a forced-refresh invalid_grant just because the recorded expiry is fresh", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant" }), + })); + const service = makeService(fetchImpl); + service.setOAuthToken({ + accessToken: "at_rejected", + refreshToken: "rt_dead", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + + await service.ensureFreshToken({ force: true }); + + expect(service.getToken()).toBeNull(); + expect(service.getStatus().tokenStored).toBe(false); + }); + it("keeps the existing token on a transient refresh failure", async () => { const fetchImpl = vi.fn(async () => ({ ok: false, diff --git a/apps/desktop/src/main/services/cto/linearCredentialService.ts b/apps/desktop/src/main/services/cto/linearCredentialService.ts index 5e7624db0..a8cc96cc9 100644 --- a/apps/desktop/src/main/services/cto/linearCredentialService.ts +++ b/apps/desktop/src/main/services/cto/linearCredentialService.ts @@ -6,9 +6,11 @@ import type { Logger } from "../logging/logger"; import { isRecord, getErrorMessage, isEnoentError } from "../shared/utils"; import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/credentials/credentialStore"; import { + linearInvalidGrantLikelyStaleRotation, linearTokenNeedsRefresh, refreshLinearOAuthAccessToken, } from "./linearTokenRefresh"; +import { withLinearOAuthRefreshLock } from "./linearOAuthRefreshLock"; // Bundled OAuth client ID — ships with ADE so users get "Sign in with Linear" // out of the box without configuring their own OAuth app. @@ -530,39 +532,74 @@ export function createLinearCredentialService(args: LinearCredentialServiceArgs) if (!client) return; const refreshToken = stored.refreshToken; refreshInFlight = (async () => { - const result = await refreshLinearOAuthAccessToken({ - refreshToken, - clientId: client.clientId, - clientSecret: client.clientSecret, - fetchImpl: args.fetchImpl, - }); - if (result.ok) { - persistToken({ - token: result.accessToken, - authMode: "oauth", - refreshToken: result.refreshToken ?? refreshToken, - expiresAt: result.expiresAt, - }); - invalidateCache(); - args.logger?.info("linear_sync.oauth_token_refreshed", { - expiresAt: result.expiresAt, - }); - } else if (result.invalidGrant) { - // Refresh token revoked/expired — clear the connection so the UI prompts - // the user to reconnect rather than retrying a dead token forever. - persistToken(null); - invalidateCache(); - args.logger?.warn("linear_sync.oauth_refresh_invalid_grant", { - status: result.status, - message: result.message, + const performRefresh = async (tokenToRefresh: string): Promise => { + const result = await refreshLinearOAuthAccessToken({ + refreshToken: tokenToRefresh, + clientId: client.clientId, + clientSecret: client.clientSecret, + fetchImpl: args.fetchImpl, }); - } else { - // Transient (network / 5xx): keep the token and let the caller retry. + if (result.ok) { + persistToken({ + token: result.accessToken, + authMode: "oauth", + refreshToken: result.refreshToken ?? tokenToRefresh, + expiresAt: result.expiresAt, + }); + invalidateCache(); + args.logger?.info("linear_sync.oauth_token_refreshed", { + expiresAt: result.expiresAt, + }); + return; + } + if (result.invalidGrant) { + invalidateCache(); + const reread = getStoredToken(); + if ( + linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: tokenToRefresh, + rereadRefreshToken: reread?.refreshToken, + rereadExpiresAt: reread?.expiresAt, + trustFreshExpiresAt: !opts?.force, + }) + ) { + args.logger?.info("linear_sync.oauth_refresh_rotated_elsewhere", { + status: result.status, + message: result.message, + }); + return; + } + persistToken(null); + invalidateCache(); + args.logger?.warn("linear_sync.oauth_refresh_invalid_grant", { + status: result.status, + message: result.message, + }); + return; + } args.logger?.warn("linear_sync.oauth_refresh_failed", { status: result.status, message: result.message, }); + }; + + if (credentialStore) { + await withLinearOAuthRefreshLock(secretsDir, async () => { + invalidateCache(); + const latest = getStoredToken(); + if ( + !latest + || latest.authMode !== "oauth" + || !latest.refreshToken + || (!opts?.force && !linearTokenNeedsRefresh(latest.expiresAt, Date.now())) + ) { + return; + } + await performRefresh(latest.refreshToken); + }); + return; } + await performRefresh(refreshToken); })().finally(() => { refreshInFlight = null; }); diff --git a/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts b/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts new file mode 100644 index 000000000..bc8bc32db --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import path from "node:path"; + +const LOCK_FILE = "linear-oauth-refresh.lock"; +const LOCK_TIMEOUT_MS = 15_000; +const LOCK_STALE_MS = 60_000; +const LOCK_RETRY_MS = 25; + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function removeStaleLock(lockPath: string): void { + try { + const stat = fs.statSync(lockPath); + if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + fs.unlinkSync(lockPath); + } + } catch { + // ignore + } +} + +/** + * Serialize Linear OAuth refresh across ADE runtimes (desktop + ade serve) that + * share the same encrypted credential store. Linear rotates refresh tokens on + * each exchange, so concurrent refreshes can cause the loser to get + * invalid_grant and wipe a still-valid connection. + */ +export async function withLinearOAuthRefreshLock( + secretsDir: string, + fn: () => Promise, +): Promise { + fs.mkdirSync(secretsDir, { recursive: true }); + const lockPath = path.join(secretsDir, LOCK_FILE); + const deadline = Date.now() + LOCK_TIMEOUT_MS; + let fd: number | null = null; + + while (fd === null) { + try { + fd = fs.openSync(lockPath, "wx"); + fs.writeFileSync( + fd, + JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + removeStaleLock(lockPath); + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for Linear OAuth refresh lock."); + } + sleepSync(LOCK_RETRY_MS); + } + } + + try { + return await fn(); + } finally { + try { + fs.closeSync(fd); + } catch { + // ignore + } + try { + fs.unlinkSync(lockPath); + } catch { + // ignore + } + } +} diff --git a/apps/desktop/src/main/services/cto/linearTokenRefresh.test.ts b/apps/desktop/src/main/services/cto/linearTokenRefresh.test.ts index 08855ba69..ac05720e9 100644 --- a/apps/desktop/src/main/services/cto/linearTokenRefresh.test.ts +++ b/apps/desktop/src/main/services/cto/linearTokenRefresh.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { LINEAR_OAUTH_TOKEN_URL, + linearInvalidGrantLikelyStaleRotation, linearTokenNeedsRefresh, refreshLinearOAuthAccessToken, } from "./linearTokenRefresh"; @@ -33,6 +34,44 @@ describe("linearTokenNeedsRefresh", () => { }); }); +describe("linearInvalidGrantLikelyStaleRotation", () => { + it("detects a rotated refresh token in the shared store", () => { + expect(linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: "rt_old", + rereadRefreshToken: "rt_new", + rereadExpiresAt: null, + })).toBe(true); + }); + + it("detects a freshly renewed access token in the shared store", () => { + expect(linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: "rt_same", + rereadRefreshToken: "rt_same", + rereadExpiresAt: new Date(NOW + 60 * 60 * 1000).toISOString(), + nowMs: NOW, + })).toBe(true); + }); + + it("does not trust a fresh expiry when the caller forced refresh after auth failed", () => { + expect(linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: "rt_same", + rereadRefreshToken: "rt_same", + rereadExpiresAt: new Date(NOW + 60 * 60 * 1000).toISOString(), + trustFreshExpiresAt: false, + nowMs: NOW, + })).toBe(false); + }); + + it("returns false when the store still reflects the dead refresh attempt", () => { + expect(linearInvalidGrantLikelyStaleRotation({ + attemptedRefreshToken: "rt_dead", + rereadRefreshToken: "rt_dead", + rereadExpiresAt: new Date(NOW - 1000).toISOString(), + nowMs: NOW, + })).toBe(false); + }); +}); + describe("refreshLinearOAuthAccessToken", () => { const base = { refreshToken: "rt_old", clientId: "client-123" }; diff --git a/apps/desktop/src/main/services/cto/linearTokenRefresh.ts b/apps/desktop/src/main/services/cto/linearTokenRefresh.ts index 54006f1a3..e421f84d4 100644 --- a/apps/desktop/src/main/services/cto/linearTokenRefresh.ts +++ b/apps/desktop/src/main/services/cto/linearTokenRefresh.ts @@ -29,6 +29,39 @@ export function linearTokenNeedsRefresh( return nowMs >= expMs - bufferMs; } +/** + * True when an invalid_grant response is likely because another ADE runtime + * already rotated the refresh token in the shared credential store — not + * because the user's connection is actually dead. + * + * A fresh stored expiry only counts as evidence for proactive refreshes. During + * a forced refresh after a 401, the access token was already rejected, so expiry + * alone should not keep a dead refresh token around. + */ +export function linearInvalidGrantLikelyStaleRotation(args: { + attemptedRefreshToken: string; + rereadRefreshToken: string | null | undefined; + rereadExpiresAt: string | null | undefined; + trustFreshExpiresAt?: boolean; + nowMs?: number; +}): boolean { + const nowMs = args.nowMs ?? Date.now(); + if ( + args.rereadRefreshToken + && args.rereadRefreshToken !== args.attemptedRefreshToken + ) { + return true; + } + if ( + args.trustFreshExpiresAt !== false + && args.rereadExpiresAt + && !linearTokenNeedsRefresh(args.rereadExpiresAt, nowMs) + ) { + return true; + } + return false; +} + export type LinearTokenRefreshResult = | { ok: true; From 7d86afdf89b4fd92097739e21256d259dca174c6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 29 May 2026 15:48:10 -0400 Subject: [PATCH 2/3] fix: address Linear OAuth refresh review feedback --- apps/ade-cli/src/headlessLinearServices.ts | 40 +++++++++++++------ .../services/cto/linearCredentialService.ts | 38 +++++++++++------- .../services/cto/linearOAuthRefreshLock.ts | 15 +++++-- .../localRuntimeConnectionPool.test.ts | 2 +- 4 files changed, 63 insertions(+), 32 deletions(-) diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 4c521648f..d95be2a7a 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -62,7 +62,10 @@ import { linearTokenNeedsRefresh, refreshLinearOAuthAccessToken, } from "../../desktop/src/main/services/cto/linearTokenRefresh"; -import { withLinearOAuthRefreshLock } from "../../desktop/src/main/services/cto/linearOAuthRefreshLock"; +import { + LinearOAuthRefreshLockTimeoutError, + withLinearOAuthRefreshLock, +} from "../../desktop/src/main/services/cto/linearOAuthRefreshLock"; // Keep headless runtimes aligned with the desktop credential service so packaged // alpha builds can offer the same PKCE-based Linear sign-in flow. @@ -1145,6 +1148,7 @@ export function createHeadlessGitHubService( function createHeadlessLinearCredentialService(args: { adeDir: string; + logger?: Logger; }): HeadlessLinearCredentialService { const secretsDir = path.join(args.adeDir, "secrets"); const credentialStore = new EncryptedFileCredentialStore({ @@ -1290,17 +1294,24 @@ function createHeadlessLinearCredentialService(args: { } }; - await withLinearOAuthRefreshLock(secretsDir, async () => { - const latestRefresh = readCredential(refreshTokenKey); - if (!latestRefresh) return; - if ( - !opts?.force - && !linearTokenNeedsRefresh(readCredential(tokenExpiresAtKey), Date.now()) - ) { - return; - } - await performRefresh(latestRefresh); - }); + try { + await withLinearOAuthRefreshLock(secretsDir, async () => { + const latestRefresh = readCredential(refreshTokenKey); + if (!latestRefresh) return; + if ( + !opts?.force + && !linearTokenNeedsRefresh(readCredential(tokenExpiresAtKey), Date.now()) + ) { + return; + } + await performRefresh(latestRefresh); + }); + } catch (error: unknown) { + if (!(error instanceof LinearOAuthRefreshLockTimeoutError)) throw error; + args.logger?.warn("linear_sync.oauth_refresh_lock_timeout", { + message: error.message, + }); + } })().finally(() => { refreshInFlight = null; }); @@ -1722,7 +1733,10 @@ export function createHeadlessLinearServices( logger: args.logger, }); const linearCredentialService = - createHeadlessLinearCredentialService({ adeDir: args.adeDir }) as any; + createHeadlessLinearCredentialService({ + adeDir: args.adeDir, + logger: args.logger, + }) as any; const githubService = createHeadlessGitHubService( args.projectRoot, args.logger, diff --git a/apps/desktop/src/main/services/cto/linearCredentialService.ts b/apps/desktop/src/main/services/cto/linearCredentialService.ts index a8cc96cc9..bb83ebc2f 100644 --- a/apps/desktop/src/main/services/cto/linearCredentialService.ts +++ b/apps/desktop/src/main/services/cto/linearCredentialService.ts @@ -10,7 +10,10 @@ import { linearTokenNeedsRefresh, refreshLinearOAuthAccessToken, } from "./linearTokenRefresh"; -import { withLinearOAuthRefreshLock } from "./linearOAuthRefreshLock"; +import { + LinearOAuthRefreshLockTimeoutError, + withLinearOAuthRefreshLock, +} from "./linearOAuthRefreshLock"; // Bundled OAuth client ID — ships with ADE so users get "Sign in with Linear" // out of the box without configuring their own OAuth app. @@ -584,19 +587,26 @@ export function createLinearCredentialService(args: LinearCredentialServiceArgs) }; if (credentialStore) { - await withLinearOAuthRefreshLock(secretsDir, async () => { - invalidateCache(); - const latest = getStoredToken(); - if ( - !latest - || latest.authMode !== "oauth" - || !latest.refreshToken - || (!opts?.force && !linearTokenNeedsRefresh(latest.expiresAt, Date.now())) - ) { - return; - } - await performRefresh(latest.refreshToken); - }); + try { + await withLinearOAuthRefreshLock(secretsDir, async () => { + invalidateCache(); + const latest = getStoredToken(); + if ( + !latest + || latest.authMode !== "oauth" + || !latest.refreshToken + || (!opts?.force && !linearTokenNeedsRefresh(latest.expiresAt, Date.now())) + ) { + return; + } + await performRefresh(latest.refreshToken); + }); + } catch (error: unknown) { + if (!(error instanceof LinearOAuthRefreshLockTimeoutError)) throw error; + args.logger?.warn("linear_sync.oauth_refresh_lock_timeout", { + message: error.message, + }); + } return; } await performRefresh(refreshToken); diff --git a/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts b/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts index bc8bc32db..aa02d1727 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts @@ -6,8 +6,15 @@ const LOCK_TIMEOUT_MS = 15_000; const LOCK_STALE_MS = 60_000; const LOCK_RETRY_MS = 25; -function sleepSync(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +export class LinearOAuthRefreshLockTimeoutError extends Error { + constructor() { + super("Timed out waiting for Linear OAuth refresh lock."); + this.name = "LinearOAuthRefreshLockTimeoutError"; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } function removeStaleLock(lockPath: string): void { @@ -48,9 +55,9 @@ export async function withLinearOAuthRefreshLock( if (code !== "EEXIST") throw error; removeStaleLock(lockPath); if (Date.now() >= deadline) { - throw new Error("Timed out waiting for Linear OAuth refresh lock."); + throw new LinearOAuthRefreshLockTimeoutError(); } - sleepSync(LOCK_RETRY_MS); + await sleep(LOCK_RETRY_MS); } } diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 98246169a..e7b824827 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -1432,7 +1432,7 @@ describe("local runtime connection pool", () => { gitOriginUrl: null, }); (pool as unknown as { connection: Promise }).connection = Promise.resolve({ - client: { call }, + client: { call, isClosed: () => false }, child: null, socketPath: "/tmp/ade.sock", }); From 1ab066cf01ad2fa3b208aad30bfc9ee185693d45 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 29 May 2026 17:02:36 -0400 Subject: [PATCH 3/3] fix: make Linear OAuth refresh stale lock recoverable --- apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts b/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts index aa02d1727..3ba2ea253 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts @@ -3,7 +3,7 @@ import path from "node:path"; const LOCK_FILE = "linear-oauth-refresh.lock"; const LOCK_TIMEOUT_MS = 15_000; -const LOCK_STALE_MS = 60_000; +const LOCK_STALE_MS = 10_000; const LOCK_RETRY_MS = 25; export class LinearOAuthRefreshLockTimeoutError extends Error {