Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
250e82a
feat: harden concurrency and failure handling
ndycode Mar 4, 2026
93d0df5
chore: retrigger CodeRabbit review\n\nCo-authored-by: Codex <noreply@…
ndycode Mar 4, 2026
a47e597
fix: stabilize conflict-save merge behavior
ndycode Mar 4, 2026
56cf4bb
test: expand config save conflict and contention coverage
ndycode Mar 4, 2026
12218aa
fix: surface quota cache persistence failures in json cli
ndycode Mar 4, 2026
8754da9
fix: harden auth timeout and stream parse behavior
ndycode Mar 4, 2026
214cc09
fix: tighten storage revision tracking and lease timeout fallback
ndycode Mar 4, 2026
9ca464e
test: cover timeout fallbacks for prompt caches
ndycode Mar 4, 2026
a449dc5
fix: finalize coderabbit remediation follow-ups
ndycode Mar 4, 2026
a33495a
fix: harden lock ownership and transient read retries
ndycode Mar 4, 2026
1fadcbc
fix: resolve remaining PR41 concurrency and recovery feedback
ndycode Mar 5, 2026
8bb8a71
fix: preserve fresh credentials and recover malformed unified settings
ndycode Mar 5, 2026
15ea07b
fix: handle sync ENOENT races in unified settings
ndycode Mar 5, 2026
a1b9be5
fix: harden settings snapshots and lock release path
ndycode Mar 5, 2026
569ea65
fix: sync known revision to disk after recovery fallback
ndycode Mar 5, 2026
9835542
fix: avoid false config save failures on lock release errors
ndycode Mar 5, 2026
fe98882
fix: resolve PR41 follow-up review threads
ndycode Mar 5, 2026
83dedbb
fix: resolve PR41 stale-lock and exit-code follow-ups
ndycode Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,7 @@ while (attempted.size < Math.max(1, accountCount)) {
{
model,
promptCacheKey: effectivePromptCacheKey,
idempotencyKey: requestCorrelationId,
},
);
const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`;
Expand Down Expand Up @@ -2164,6 +2165,7 @@ while (attempted.size < Math.max(1, accountCount)) {
{
model,
promptCacheKey: effectivePromptCacheKey,
idempotencyKey: requestCorrelationId,
},
);

Expand Down
197 changes: 194 additions & 3 deletions lib/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
type AccountWithMetrics,
type HybridSelectionOptions,
} from "./rotation.js";
import { nowMs } from "./utils.js";
import { isRecord, nowMs, sleep } from "./utils.js";
import {
loadCodexCliState,
type CodexCliTokenCacheEntry,
Expand Down Expand Up @@ -72,6 +72,8 @@ import {
} from "./accounts/rate-limits.js";

const log = createLogger("accounts");
type StoredAccount = AccountStorageV3["accounts"][number];
const DISK_PREFERRED_MERGE_KEYS = new Set(["refreshToken", "accessToken", "expiresAt"]);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
function initFamilyState(defaultValue: number): Record<ModelFamily, number> {
return Object.fromEntries(
Expand Down Expand Up @@ -724,7 +726,7 @@ export class AccountManager {
return account;
}

async saveToDisk(): Promise<void> {
private buildStorageSnapshot(): AccountStorageV3 {
const activeIndexByFamily: Partial<Record<ModelFamily, number>> = {};
for (const family of MODEL_FAMILIES) {
const raw = this.currentAccountIndexByFamily[family];
Expand Down Expand Up @@ -755,8 +757,197 @@ export class AccountManager {
activeIndex,
activeIndexByFamily,
};
return storage;
}

private isStorageConflictError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "ECONFLICT";
}

private mergeIntoLatestStorage(
latest: AccountStorageV3 | null,
local: AccountStorageV3,
): AccountStorageV3 {
if (!latest) {
return local;
}

const mergedAccounts = latest.accounts.map((account) => ({ ...account }));
const claimIndex = (candidate: StoredAccount): number => {
const token = candidate.refreshToken.trim();
const accountId = candidate.accountId?.trim();
const email = sanitizeEmail(candidate.email);

const byToken = mergedAccounts.findIndex(
(account) => account.refreshToken.trim() === token,
);
if (byToken >= 0) return byToken;

if (accountId) {
const byAccountId = mergedAccounts.findIndex(
(account) => (account.accountId?.trim() ?? "") === accountId,
);
if (byAccountId >= 0) return byAccountId;
}

if (email) {
const byEmail = mergedAccounts.findIndex(
(account) => sanitizeEmail(account.email) === email,
);
if (byEmail >= 0) return byEmail;
}

return -1;
};

for (const account of local.accounts) {
const idx = claimIndex(account);
if (idx >= 0) {
const current = mergedAccounts[idx];
if (current) {
mergedAccounts[idx] = this.mergeStoredAccountRecords(current, account);
}
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
mergedAccounts.push({ ...account });
}
}

const localActiveTokensByFamily = Object.fromEntries(
MODEL_FAMILIES.map((family) => {
const localIndex = local.activeIndexByFamily?.[family];
const token =
typeof localIndex === "number" && localIndex >= 0
? local.accounts[localIndex]?.refreshToken
: undefined;
return [family, token];
}),
) as Partial<Record<ModelFamily, string | undefined>>;

const mergedActiveIndexByFamily: Partial<Record<ModelFamily, number>> = {};
for (const family of MODEL_FAMILIES) {
const token = localActiveTokensByFamily[family];
if (token) {
const index = mergedAccounts.findIndex(
(account) => account.refreshToken === token,
);
if (index >= 0) {
mergedActiveIndexByFamily[family] = index;
continue;
}
}
mergedActiveIndexByFamily[family] = clampNonNegativeInt(
latest.activeIndexByFamily?.[family],
0,
);
}

return {
version: 3,
accounts: mergedAccounts,
activeIndex: clampNonNegativeInt(mergedActiveIndexByFamily.codex, 0),
activeIndexByFamily: mergedActiveIndexByFamily,
};
}

private mergeStoredAccountRecords(current: StoredAccount, incoming: StoredAccount): StoredAccount {
const next: StoredAccount = { ...current };
const nextRecord = next as unknown as Record<string, unknown>;
for (const [rawKey, rawValue] of Object.entries(incoming)) {
const value = rawValue as unknown;
if (value === undefined) {
continue;
}
const currentValue = nextRecord[rawKey];
if (DISK_PREFERRED_MERGE_KEYS.has(rawKey) && currentValue !== undefined) {
continue;
}
if (
(rawKey === "lastUsed" || rawKey === "addedAt" || rawKey === "coolingDownUntil") &&
typeof currentValue === "number" &&
typeof value === "number"
) {
nextRecord[rawKey] = Math.max(currentValue, value);
continue;
}
if (rawKey === "rateLimitResetTimes" && isRecord(currentValue) && isRecord(value)) {
const mergedRateLimits: Record<string, unknown> = { ...currentValue };
for (const [resetKey, resetValue] of Object.entries(value)) {
if (resetValue === undefined) {
continue;
}
const existingResetValue = mergedRateLimits[resetKey];
if (typeof existingResetValue === "number" && typeof resetValue === "number") {
mergedRateLimits[resetKey] = Math.max(existingResetValue, resetValue);
continue;
}
mergedRateLimits[resetKey] = resetValue;
}
nextRecord[rawKey] = mergedRateLimits;
continue;
}
if (isRecord(currentValue) && isRecord(value)) {
nextRecord[rawKey] = {
...currentValue,
...value,
};
continue;
}
nextRecord[rawKey] = value;
}
return next;
}
Comment thread
ndycode marked this conversation as resolved.
Comment thread
ndycode marked this conversation as resolved.

private applyPersistedStorageSnapshot(storage: AccountStorageV3): void {
const previousByRefreshToken = new Map(
this.accounts.map((account) => [account.refreshToken, account] as const),
);
const rehydrated = new AccountManager(undefined, storage);
this.accounts = rehydrated.accounts.map((account) => {
const previous = previousByRefreshToken.get(account.refreshToken);
if (!previous) {
return account;
}
return {
...account,
lastRateLimitReason: previous.lastRateLimitReason,
consecutiveAuthFailures: previous.consecutiveAuthFailures,
};
});
this.cursorByFamily = { ...rehydrated.cursorByFamily };
this.currentAccountIndexByFamily = {
...rehydrated.currentAccountIndexByFamily,
};
}

private async persistStorageWithConflictRecovery(storage?: AccountStorageV3): Promise<void> {
const maxAttempts = 3;
const baseStorage = storage ?? this.buildStorageSnapshot();
let mergedCandidate = baseStorage;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
await saveAccounts(mergedCandidate);
if (attempt > 0) {
this.applyPersistedStorageSnapshot(mergedCandidate);
}
return;
} catch (error) {
if (!this.isStorageConflictError(error) || attempt + 1 >= maxAttempts) {
throw error;
}
log.warn("Account save conflict detected; retrying with merged disk snapshot", {
attempt: attempt + 1,
maxAttempts,
});
const latest = await loadAccounts();
mergedCandidate = this.mergeIntoLatestStorage(latest, baseStorage);
await sleep(20 * 2 ** attempt);
}
}
}

await saveAccounts(storage);
async saveToDisk(): Promise<void> {
await this.persistStorageWithConflictRecovery(this.buildStorageSnapshot());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

saveToDiskDebounced(delayMs = 500): void {
Expand Down
44 changes: 28 additions & 16 deletions lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
import type { PKCEPair, AuthorizationFlow, TokenResult, ParsedAuthInput, JWTPayload } from "../types.js";
import { logError } from "../logger.js";
import { safeParseOAuthTokenResponse } from "../schemas.js";
import { isAbortError } from "../utils.js";
import { fetchWithTimeout, isAbortError } from "../utils.js";

// OAuth constants (from openai/codex)
export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
Expand All @@ -18,6 +18,8 @@ const OAUTH_SENSITIVE_QUERY_PARAMS = [
"code_challenge",
"code_verifier",
] as const;
const OAUTH_TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
const OAUTH_REFRESH_TIMEOUT_MS = 30_000;

function getOAuthResponseLogMetadata(rawResponse: unknown): Record<string, unknown> {
if (Array.isArray(rawResponse)) {
Expand Down Expand Up @@ -116,17 +118,26 @@ export async function exchangeAuthorizationCode(
verifier: string,
redirectUri: string = REDIRECT_URI,
): Promise<TokenResult> {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}),
});
let res: Response;
try {
res = await fetchWithTimeout(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}),
}, OAUTH_TOKEN_EXCHANGE_TIMEOUT_MS);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
if (isAbortError(err) || /timeout/i.test(err.message)) {
return { type: "failed", reason: "unknown", message: err.message };
}
return { type: "failed", reason: "network_error", message: err.message };
}
if (!res.ok) {
const text = await res.text().catch(() => "");
logError(`code->token failed: ${res.status} ${text}`);
Expand Down Expand Up @@ -186,14 +197,15 @@ export function decodeJWT(token: string): JWTPayload | null {
*/
type RefreshAccessTokenOptions = {
signal?: AbortSignal;
timeoutMs?: number;
};

export async function refreshAccessToken(
refreshToken: string,
options: RefreshAccessTokenOptions = {},
): Promise<TokenResult> {
try {
const response = await fetch(TOKEN_URL, {
const response = await fetchWithTimeout(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
signal: options?.signal,
Expand All @@ -202,7 +214,7 @@ export async function refreshAccessToken(
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
}, options.timeoutMs ?? OAUTH_REFRESH_TIMEOUT_MS);

if (!response.ok) {
const text = await response.text().catch(() => "");
Expand Down Expand Up @@ -233,8 +245,8 @@ export async function refreshAccessToken(
multiAccount: true,
};
} catch (error) {
const err = error as Error;
if (isAbortError(err)) {
const err = error instanceof Error ? error : new Error(String(error));
if (isAbortError(err) || /timeout/i.test(err.message)) {
return { type: "failed", reason: "unknown", message: err?.message ?? "Request aborted" };
}
logError("Token refresh error", err);
Expand Down
Loading