Fix WSL Codex auth timeout state

This commit is contained in:
Jinjing
2026-07-10 18:42:08 -07:00
parent 7b6eb3cc06
commit 735a1111d7
7 changed files with 116 additions and 43 deletions
@@ -14,9 +14,13 @@ vi.mock('node:os', () => ({
homedir: homedirMock
}))
import { codexAuthExists } from './codex-auth-presence'
import { probeCodexAuthPresence } from './codex-auth-presence'
describe('codexAuthExists', () => {
function fsError(code: string): NodeJS.ErrnoException {
return Object.assign(new Error(code), { code })
}
describe('probeCodexAuthPresence', () => {
const originalCodexHome = process.env.CODEX_HOME
beforeEach(() => {
@@ -36,7 +40,7 @@ describe('codexAuthExists', () => {
it('checks an explicit managed-account home first', async () => {
accessMock.mockResolvedValue(undefined)
await expect(codexAuthExists('/managed/home')).resolves.toBe(true)
await expect(probeCodexAuthPresence('/managed/home')).resolves.toBe('present')
expect(accessMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json'))
})
@@ -44,21 +48,39 @@ describe('codexAuthExists', () => {
process.env.CODEX_HOME = '/custom/codex'
accessMock.mockResolvedValue(undefined)
await expect(codexAuthExists()).resolves.toBe(true)
await expect(probeCodexAuthPresence()).resolves.toBe('present')
expect(accessMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json'))
})
it('falls back to ~/.codex when neither home nor CODEX_HOME is set', async () => {
accessMock.mockRejectedValue(new Error('ENOENT'))
accessMock.mockRejectedValue(fsError('ENOENT'))
await expect(codexAuthExists()).resolves.toBe(false)
await expect(probeCodexAuthPresence()).resolves.toBe('absent')
expect(accessMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json'))
})
it('returns false instead of throwing when the fs check fails', async () => {
accessMock.mockRejectedValue(new Error('EACCES'))
it('keeps an inaccessible auth path distinct from confirmed absence', async () => {
accessMock.mockRejectedValue(fsError('EACCES'))
await expect(codexAuthExists('/managed/home')).resolves.toBe(false)
await expect(probeCodexAuthPresence('/managed/home')).resolves.toBe('unavailable')
})
it('confirms WSL auth absence only when the Codex home is reachable', async () => {
accessMock.mockRejectedValueOnce(fsError('ENOENT')).mockResolvedValueOnce(undefined)
await expect(
probeCodexAuthPresence('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex')
).resolves.toBe('absent')
expect(accessMock).toHaveBeenCalledTimes(2)
})
it('reports unavailable when WSL cannot resolve either the auth file or its home', async () => {
accessMock.mockRejectedValue(fsError('ENOENT'))
await expect(
probeCodexAuthPresence('\\\\wsl.localhost\\Missing\\home\\alice\\.codex')
).resolves.toBe('unavailable')
expect(accessMock).toHaveBeenCalledTimes(2)
})
it('stops waiting for a stalled filesystem check when the caller aborts', async () => {
@@ -70,12 +92,12 @@ describe('codexAuthExists', () => {
)
const controller = new AbortController()
const result = codexAuthExists('/managed/home', { signal: controller.signal })
const result = probeCodexAuthPresence('/managed/home', { signal: controller.signal })
await Promise.resolve()
await Promise.resolve()
controller.abort()
await expect(result).resolves.toBe(false)
await expect(result).resolves.toBe('unavailable')
resolveAccess()
await Promise.resolve()
})
@@ -90,13 +112,13 @@ describe('codexAuthExists', () => {
const timeoutController = new AbortController()
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutController.signal)
const result = codexAuthExists('/managed/home')
const result = probeCodexAuthPresence('/managed/home')
expect(timeout).toHaveBeenCalledWith(5_000)
await Promise.resolve()
await Promise.resolve()
timeoutController.abort()
await expect(result).resolves.toBe(false)
await expect(result).resolves.toBe('timeout')
resolveAccess()
await Promise.resolve()
timeout.mockRestore()
@@ -112,10 +134,10 @@ describe('codexAuthExists', () => {
const firstController = new AbortController()
const secondController = new AbortController()
const first = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', {
const first = probeCodexAuthPresence('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', {
signal: firstController.signal
})
const second = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', {
const second = probeCodexAuthPresence('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', {
signal: secondController.signal
})
@@ -124,8 +146,8 @@ describe('codexAuthExists', () => {
expect(accessMock).toHaveBeenCalledTimes(1)
firstController.abort()
secondController.abort()
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
await expect(first).resolves.toBe('unavailable')
await expect(second).resolves.toBe('unavailable')
resolveAccess()
await Promise.resolve()
+30 -12
View File
@@ -1,20 +1,28 @@
import { access } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
createAuthFilesystemOperation,
type SharedAuthFilesystemOperation
} from './auth-filesystem-operation'
const AUTH_PRESENCE_TIMEOUT_MS = 5_000
const authPresenceProbeByPath = new Map<string, SharedAuthFilesystemOperation<boolean>>()
const authPresenceProbeByPath = new Map<string, SharedAuthFilesystemOperation<CodexAuthPresence>>()
export type CodexAuthPresence = 'present' | 'absent' | 'timeout' | 'unavailable'
type CodexAuthPresenceOptions = {
signal?: AbortSignal
timeoutMs?: number
}
function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation<boolean> {
function isMissingPathError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code
return code === 'ENOENT' || code === 'ENOTDIR'
}
function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation<CodexAuthPresence> {
const existing = authPresenceProbeByPath.get(authPath)
if (existing) {
return existing
@@ -22,12 +30,14 @@ function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation<b
// Why: aborting a Node fs promise does not necessarily cancel an already
// issued UNC operation. Share the raw probe until it really settles so a
// disconnected WSL home cannot accumulate native requests across polls.
const probe = createAuthFilesystemOperation(authPath, () =>
access(authPath).then(
() => true,
() => false
)
)
const probe = createAuthFilesystemOperation(authPath, async () => {
try {
await access(authPath)
return 'present'
} catch (error) {
return isMissingPathError(error) ? 'absent' : 'unavailable'
}
})
authPresenceProbeByPath.set(authPath, probe)
const clearProbe = (): void => {
if (authPresenceProbeByPath.get(authPath) === probe) {
@@ -44,10 +54,10 @@ function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation<b
// the background. A signed-in Codex always writes an auth.json under its
// CODEX_HOME, so gating the fetch on that file keeps the poller silent until
// the user actually uses Codex.
export async function codexAuthExists(
export async function probeCodexAuthPresence(
codexHomePath?: string | null,
options: CodexAuthPresenceOptions = {}
): Promise<boolean> {
): Promise<CodexAuthPresence> {
// Mirror Codex's own home resolution: an explicit managed-account home wins,
// then CODEX_HOME, then the default ~/.codex.
const home = codexHomePath ?? process.env.CODEX_HOME ?? join(homedir(), '.codex')
@@ -58,8 +68,16 @@ export async function codexAuthExists(
// Why: managed WSL homes are UNC paths. A synchronous stat can park
// Electron main while Windows wakes or reconnects the distro; the race
// also keeps a disconnected distro from serializing all later refreshes.
return await getAuthPresenceProbe(authPath).wait(signal)
const authPresence = await getAuthPresenceProbe(authPath).wait(signal)
if (authPresence !== 'absent' || !parseWslUncPath(home)) {
return authPresence
}
// Why: ENOENT on a WSL UNC auth path can mean either a missing auth file or
// an unavailable distro. Only an accessible Codex home proves signed-out.
const homePresence = await getAuthPresenceProbe(home).wait(signal)
return homePresence === 'present' ? 'absent' : 'unavailable'
} catch {
return false
return timeoutSignal.aborted && !options.signal?.aborted ? 'timeout' : 'unavailable'
}
}
@@ -21,7 +21,7 @@ vi.mock('node-pty', () => ({
// Auth gate is covered separately; these tests assume a signed-in Codex.
vi.mock('./codex-auth-presence', () => ({
codexAuthExists: vi.fn(() => true)
probeCodexAuthPresence: vi.fn(() => 'present')
}))
import { fetchCodexRateLimits } from './codex-fetcher'
@@ -10,7 +10,9 @@ const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({
vi.mock('node:child_process', () => ({ spawn: childSpawnMock }))
vi.mock('node:fs/promises', () => ({ readFile: readFileMock }))
vi.mock('node-pty', () => ({ spawn: ptySpawnMock }))
vi.mock('./codex-auth-presence', () => ({ codexAuthExists: vi.fn(async () => true) }))
vi.mock('./codex-auth-presence', () => ({
probeCodexAuthPresence: vi.fn(async () => 'present')
}))
import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher'
@@ -20,7 +20,7 @@ vi.mock('node-pty', () => ({
// Auth gate is covered separately; these tests assume a signed-in Codex.
vi.mock('./codex-auth-presence', () => ({
codexAuthExists: vi.fn(() => true)
probeCodexAuthPresence: vi.fn(() => 'present')
}))
import { fetchCodexRateLimits } from './codex-fetcher'
+26 -8
View File
@@ -28,11 +28,11 @@ vi.mock('node-pty', () => ({
// Default to signed-in so the spawn paths under test still run; the auth gate
// itself is covered by codex-auth-presence.test.ts and the no-auth case below.
vi.mock('./codex-auth-presence', () => ({
codexAuthExists: vi.fn(() => true)
probeCodexAuthPresence: vi.fn(() => 'present')
}))
import { fetchCodexRateLimits } from './codex-fetcher'
import { codexAuthExists } from './codex-auth-presence'
import { probeCodexAuthPresence } from './codex-auth-presence'
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
function makeDisposable() {
@@ -77,7 +77,7 @@ describe('fetchCodexRateLimits', () => {
vi.useFakeTimers()
vi.clearAllMocks()
resolveCodexCommandMock.mockReturnValue('codex')
vi.mocked(codexAuthExists).mockResolvedValue(true)
vi.mocked(probeCodexAuthPresence).mockResolvedValue('present')
readFileMock.mockRejectedValue(new Error('no auth fixture'))
vi.stubGlobal('fetch', vi.fn())
})
@@ -87,7 +87,7 @@ describe('fetchCodexRateLimits', () => {
})
it('does not spawn Codex when the user is not signed in', async () => {
vi.mocked(codexAuthExists).mockResolvedValue(false)
vi.mocked(probeCodexAuthPresence).mockResolvedValue('absent')
await expect(fetchCodexRateLimits()).resolves.toMatchObject({
provider: 'codex',
@@ -102,9 +102,9 @@ describe('fetchCodexRateLimits', () => {
})
it('preserves the aborted result when cancellation lands during the auth check', async () => {
let resolveAuth!: (exists: boolean) => void
vi.mocked(codexAuthExists).mockReturnValueOnce(
new Promise<boolean>((resolve) => {
let resolveAuth!: (presence: 'absent') => void
vi.mocked(probeCodexAuthPresence).mockReturnValueOnce(
new Promise<'absent'>((resolve) => {
resolveAuth = resolve
})
)
@@ -112,7 +112,7 @@ describe('fetchCodexRateLimits', () => {
const result = fetchCodexRateLimits({ signal: controller.signal })
controller.abort()
resolveAuth(false)
resolveAuth('absent')
await expect(result).resolves.toMatchObject({
provider: 'codex',
@@ -123,6 +123,24 @@ describe('fetchCodexRateLimits', () => {
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it.each([
['timeout', 'Timed out while checking Codex sign-in status'],
['unavailable', 'Codex sign-in status is unavailable']
] as const)(
'does not report an indeterminate %s probe as signed out',
async (presence, error) => {
vi.mocked(probeCodexAuthPresence).mockResolvedValue(presence)
await expect(fetchCodexRateLimits()).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error
})
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
}
)
it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()
+16 -3
View File
@@ -10,7 +10,7 @@ import { spawn } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { codexAuthExists } from './codex-auth-presence'
import { probeCodexAuthPresence } from './codex-auth-presence'
import { resolveCodexCommand } from '../codex-cli/command'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils'
@@ -1026,13 +1026,13 @@ export async function fetchCodexRateLimits(
// Why: never spawn the `codex` binary unless the user has signed in. Without
// auth the RPC/PTY paths can only error, and spawning them shows up as an
// unexpected background Codex process for users who don't use Codex.
const hasCodexAuth = await codexAuthExists(options?.codexHomePath, {
const authPresence = await probeCodexAuthPresence(options?.codexHomePath, {
signal: options?.signal
})
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
if (!hasCodexAuth) {
if (authPresence === 'absent') {
return {
provider: 'codex',
session: null,
@@ -1042,6 +1042,19 @@ export async function fetchCodexRateLimits(
status: 'unavailable'
}
}
if (authPresence !== 'present') {
return {
provider: 'codex',
session: null,
weekly: null,
updatedAt: Date.now(),
error:
authPresence === 'timeout'
? 'Timed out while checking Codex sign-in status'
: 'Codex sign-in status is unavailable',
status: 'error'
}
}
// Path A (WSL): use Codex's own backend usage contract. Host accounts retain
// app-server's token-refresh/custom-CA behavior; WSL avoids starting a login