From cfde30da4426bcd737a3e3df03f57667bfda2cb1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:46:42 -0700 Subject: [PATCH] fix(rate-limits): bound and offload the Kimi credentials read for WSL homes Adopted from @cengiz-io's #12372: read credentials through createAuthFilesystemOperation (async, per-path dedup, AbortSignal bound) so a stopped distro degrades to an error instead of parking Electron main on a UNC read. ENOTDIR joins ENOENT as "not signed in" to keep existsSync parity, and the WSL runtime target is now probed with the async wsl.exe helpers. Co-authored-by: Orca --- src/main/kimi/kimi-runtime-home.test.ts | 48 +++++----- src/main/kimi/kimi-runtime-home.ts | 21 +++-- .../rate-limits/kimi-fetcher-wsl-home.test.ts | 37 +++++++- src/main/rate-limits/kimi-fetcher.test.ts | 55 ++++++++++-- src/main/rate-limits/kimi-fetcher.ts | 87 +++++++++++++++---- src/main/rate-limits/service.test.ts | 2 +- src/main/rate-limits/service.ts | 16 +++- 7 files changed, 202 insertions(+), 64 deletions(-) diff --git a/src/main/kimi/kimi-runtime-home.test.ts b/src/main/kimi/kimi-runtime-home.test.ts index fd05c22a303..d56abb658d1 100644 --- a/src/main/kimi/kimi-runtime-home.test.ts +++ b/src/main/kimi/kimi-runtime-home.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const wslMocks = vi.hoisted(() => ({ - getDefaultWslDistro: vi.fn<() => string | null>(), - getWslHome: vi.fn<(distro: string) => string | null>() + listWslDistrosAsync: vi.fn<() => Promise>(), + getWslHomeAsync: vi.fn<(distro: string) => Promise>() })) vi.mock('../wsl', () => wslMocks) @@ -52,8 +52,8 @@ describe('resolveKimiHome', () => { beforeEach(() => { delete process.env.KIMI_CODE_HOME - wslMocks.getDefaultWslDistro.mockReset().mockReturnValue('Ubuntu') - wslMocks.getWslHome.mockReset().mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\neil') + wslMocks.listWslDistrosAsync.mockReset().mockResolvedValue(['Ubuntu']) + wslMocks.getWslHomeAsync.mockReset().mockResolvedValue('\\\\wsl.localhost\\Ubuntu\\home\\neil') }) afterEach(() => { @@ -64,62 +64,62 @@ describe('resolveKimiHome', () => { } }) - it('resolves the host home for a host target', () => { - expect(resolveKimiHome({ runtime: 'host', wslDistro: null }, 'win32')).toEqual({ + it('resolves the host home for a host target', async () => { + expect(await resolveKimiHome({ runtime: 'host', wslDistro: null }, 'win32')).toEqual({ runtime: 'host', wslDistro: null, path: getHostKimiHome() }) - expect(wslMocks.getWslHome).not.toHaveBeenCalled() + expect(wslMocks.getWslHomeAsync).not.toHaveBeenCalled() }) - it('resolves the WSL distro home for a WSL target', () => { - expect(resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({ + it('resolves the WSL distro home for a WSL target', async () => { + expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu', path: '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code' }) }) - it('ignores the host KIMI_CODE_HOME when reading a WSL home', () => { + it('ignores the host KIMI_CODE_HOME when reading a WSL home', async () => { process.env.KIMI_CODE_HOME = 'D:\\kimi-home' - expect(resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32').path).toBe( + expect((await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).path).toBe( '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code' ) }) - it('falls back to the default distro when none is configured', () => { - expect(resolveKimiHome({ runtime: 'wsl', wslDistro: null }, 'win32')).toMatchObject({ + it('falls back to the default distro when none is configured', async () => { + expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: null }, 'win32')).toMatchObject({ wslDistro: 'Ubuntu' }) - expect(wslMocks.getWslHome).toHaveBeenCalledWith('Ubuntu') + expect(wslMocks.getWslHomeAsync).toHaveBeenCalledWith('Ubuntu') }) - it('reports no path when the distro home cannot be probed', () => { - wslMocks.getWslHome.mockReturnValue(null) - expect(resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({ + it('reports no path when the distro home cannot be probed', async () => { + wslMocks.getWslHomeAsync.mockResolvedValue(null) + expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu', path: null }) }) - it('reports no path when no distro exists at all', () => { - wslMocks.getDefaultWslDistro.mockReturnValue(null) - expect(resolveKimiHome({ runtime: 'wsl', wslDistro: null }, 'win32')).toEqual({ + it('reports no path when no distro exists at all', async () => { + wslMocks.listWslDistrosAsync.mockResolvedValue([]) + expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: null }, 'win32')).toEqual({ runtime: 'wsl', wslDistro: null, path: null }) }) - it('never probes WSL off Windows', () => { - expect(resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'darwin')).toEqual({ + it('never probes WSL off Windows', async () => { + expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'darwin')).toEqual({ runtime: 'host', wslDistro: null, path: getHostKimiHome() }) - expect(wslMocks.getDefaultWslDistro).not.toHaveBeenCalled() - expect(wslMocks.getWslHome).not.toHaveBeenCalled() + expect(wslMocks.listWslDistrosAsync).not.toHaveBeenCalled() + expect(wslMocks.getWslHomeAsync).not.toHaveBeenCalled() }) }) diff --git a/src/main/kimi/kimi-runtime-home.ts b/src/main/kimi/kimi-runtime-home.ts index 183cd0ed921..9b0cf799638 100644 --- a/src/main/kimi/kimi-runtime-home.ts +++ b/src/main/kimi/kimi-runtime-home.ts @@ -6,7 +6,7 @@ import { type LocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import type { GlobalSettings } from '../../shared/types' -import { getDefaultWslDistro, getWslHome } from '../wsl' +import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' export type KimiHomeResolution = LocalAccountRuntimeTarget & { /** null when the WSL distro's home could not be probed (distro missing/stopped). */ @@ -34,23 +34,32 @@ export function getKimiRuntimeTarget( return resolveLocalAccountRuntimeTarget(settings, platform) } -/** Resolve a runtime target to the Kimi home Orca should read (UNC path for WSL). */ -export function resolveKimiHome( +/** + * Resolve a runtime target to the Kimi home Orca should read (UNC path for WSL). + * Async because it runs on every quota poll: the sync `wsl.exe` probes park + * Electron's main process for up to 5s each cycle while a distro is stopped. + */ +export async function resolveKimiHome( target: LocalAccountRuntimeTarget, platform: NodeJS.Platform = process.platform -): KimiHomeResolution { +): Promise { if (target.runtime !== 'wsl' || platform !== 'win32') { return { runtime: 'host', wslDistro: null, path: getHostKimiHome() } } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() + const distro = target.wslDistro?.trim() || (await defaultWslDistro()) if (!distro) { return { runtime: 'wsl', wslDistro: null, path: null } } - const home = getWslHome(distro) + const home = await getWslHomeAsync(distro) // KIMI_CODE_HOME describes the Windows host process, never the distro's home. return { runtime: 'wsl', wslDistro: distro, path: home ? joinKimiHome(home) : null } } +/** Async twin of `getDefaultWslDistro`, which shells out to wsl.exe synchronously. */ +async function defaultWslDistro(): Promise { + return (await listWslDistrosAsync())[0] ?? null +} + function joinKimiHome(home: string): string { return parseWslUncPath(home) ? pathWin32.join(home, '.kimi-code') : join(home, '.kimi-code') } diff --git a/src/main/rate-limits/kimi-fetcher-wsl-home.test.ts b/src/main/rate-limits/kimi-fetcher-wsl-home.test.ts index ab9ba795688..be448003657 100644 --- a/src/main/rate-limits/kimi-fetcher-wsl-home.test.ts +++ b/src/main/rate-limits/kimi-fetcher-wsl-home.test.ts @@ -2,14 +2,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const netFetchMock = vi.hoisted(() => vi.fn()) const files = vi.hoisted(() => new Map()) +const STALLED = vi.hoisted(() => '__stalled_unc_read__') vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) -vi.mock('node:fs', () => ({ - existsSync: (path: string) => files.has(path), - readFileSync: (path: string) => { +vi.mock('node:fs/promises', () => ({ + readFile: async (path: string) => { const contents = files.get(path) if (contents === undefined) { - throw new Error(`ENOENT: ${path}`) + const error = new Error(`ENOENT: ${path}`) as NodeJS.ErrnoException + error.code = 'ENOENT' + throw error + } + if (contents === STALLED) { + // A distro that is down parks the UNC read instead of failing. + return await new Promise(() => {}) } return contents } @@ -108,4 +114,27 @@ describe('fetchKimiRateLimits with a WSL credentials home', () => { expect(result.status).toBe('unavailable') }) + + // Last: an unsettled UNC read stays shared for its path by design, so the stalled + // distro gets its own home to avoid poisoning the other cases. + it('bounds a stalled UNC read instead of parking the poll cycle', async () => { + const stalledHome = '\\\\wsl.localhost\\Stopped\\home\\neil\\.kimi-code' + files.set(`${stalledHome}\\credentials\\kimi-code.json`, STALLED) + const timeoutController = new AbortController() + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutController.signal) + + const pending = fetchKimiRateLimits({ + home: { runtime: 'wsl', wslDistro: 'Stopped', path: stalledHome } + }) + expect(timeout).toHaveBeenCalledWith(5_000) + await Promise.resolve() + await Promise.resolve() + timeoutController.abort() + + const result = await pending + expect(result.status).toBe('error') + expect(result.error).toContain('(WSL Stopped)') + expect(netFetchMock).not.toHaveBeenCalled() + timeout.mockRestore() + }) }) diff --git a/src/main/rate-limits/kimi-fetcher.test.ts b/src/main/rate-limits/kimi-fetcher.test.ts index 58f11dfdb03..01a7c10bd09 100644 --- a/src/main/rate-limits/kimi-fetcher.test.ts +++ b/src/main/rate-limits/kimi-fetcher.test.ts @@ -1,28 +1,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const netFetchMock = vi.hoisted(() => vi.fn()) -const fsState = vi.hoisted<{ credentials: string | null; readError: Error | null }>(() => ({ +const fsState = vi.hoisted<{ + credentials: string | null + readError: Error | null + readPaths: string[] +}>(() => ({ credentials: null, - readError: null + readError: null, + readPaths: [] })) vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) -vi.mock('node:fs', () => ({ - existsSync: () => fsState.credentials !== null, - readFileSync: () => { +vi.mock('node:fs/promises', () => ({ + readFile: async (path: string) => { + fsState.readPaths.push(String(path)) if (fsState.readError) { throw fsState.readError } if (fsState.credentials === null) { - throw new Error('ENOENT') + const error = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException + error.code = 'ENOENT' + throw error } return fsState.credentials - }, - writeFileSync: () => {}, - renameSync: () => {} + } })) vi.mock('node:os', () => ({ homedir: () => '/home/test' })) @@ -60,6 +65,7 @@ describe('fetchKimiRateLimits', () => { netFetchMock.mockReset() fsState.credentials = null fsState.readError = null + fsState.readPaths = [] }) afterEach(() => { @@ -147,4 +153,35 @@ describe('fetchKimiRateLimits', () => { }) expect(netFetchMock).not.toHaveBeenCalled() }) + + it('reads the host ~/.kimi-code credentials by default', async () => { + fsState.credentials = freshCredentials() + netFetchMock.mockResolvedValueOnce(jsonResponse(USAGE_RESPONSE)) + + const result = await fetchKimiRateLimits() + + expect(result.status).toBe('ok') + expect(fsState.readPaths).toEqual(['/home/test/.kimi-code/credentials/kimi-code.json']) + }) + + it('honors KIMI_CODE_HOME for the host home', async () => { + vi.stubEnv('KIMI_CODE_HOME', '/custom/kimi-home') + fsState.credentials = freshCredentials() + netFetchMock.mockResolvedValueOnce(jsonResponse(USAGE_RESPONSE)) + + const result = await fetchKimiRateLimits() + + expect(result.status).toBe('ok') + expect(fsState.readPaths).toEqual(['/custom/kimi-home/credentials/kimi-code.json']) + }) + + it('ignores a blank KIMI_CODE_HOME instead of reading from the process cwd', async () => { + vi.stubEnv('KIMI_CODE_HOME', ' ') + fsState.credentials = freshCredentials() + netFetchMock.mockResolvedValueOnce(jsonResponse(USAGE_RESPONSE)) + + await fetchKimiRateLimits() + + expect(fsState.readPaths).toEqual(['/home/test/.kimi-code/credentials/kimi-code.json']) + }) }) diff --git a/src/main/rate-limits/kimi-fetcher.ts b/src/main/rate-limits/kimi-fetcher.ts index 2172568b110..92a62fdf5fe 100644 --- a/src/main/rate-limits/kimi-fetcher.ts +++ b/src/main/rate-limits/kimi-fetcher.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' import { join, win32 as pathWin32 } from 'node:path' import { net } from 'electron' import type { @@ -8,6 +8,10 @@ import type { } from '../../shared/rate-limit-types' import { parseWslUncPath } from '../../shared/wsl-paths' import { getHostKimiHome, type KimiHomeResolution } from '../kimi/kimi-runtime-home' +import { + createAuthFilesystemOperation, + type SharedAuthFilesystemOperation +} from './auth-filesystem-operation' // Why: Kimi Code's managed coding plan exposes subscription usage at // `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle). @@ -15,6 +19,7 @@ import { getHostKimiHome, type KimiHomeResolution } from '../kimi/kimi-runtime-h // stays aligned with a user's self-hosted/staging config. const KIMI_BASE_URL = process.env.KIMI_CODE_BASE_URL ?? 'https://api.kimi.com/coding/v1' const API_TIMEOUT_MS = 10_000 +const CREDENTIALS_READ_TIMEOUT_MS = 5_000 const SESSION_WINDOW_MINUTES = 300 // 5h const WEEKLY_WINDOW_MINUTES = 10080 // 7d @@ -50,22 +55,65 @@ function parseCredentials(value: unknown): KimiCredentials | null { return credentials } -function readCredentials(kimiHome: string): CredentialsReadResult { - const path = getCredentialsPath(kimiHome) - if (!existsSync(path)) { - return { status: 'missing' } +const credentialsReadByPath = new Map< + string, + SharedAuthFilesystemOperation +>() + +function isMissingPathError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} + +function readErrorMessage(err: unknown): string { + // Why: AbortSignal timeouts reject with a DOMException, not an Error. + const message = (err as { message?: unknown } | null)?.message + return typeof message === 'string' ? message : 'Unable to read Kimi credentials' +} + +function getCredentialsRead(path: string): SharedAuthFilesystemOperation { + const existing = credentialsReadByPath.get(path) + if (existing) { + return existing } - try { - const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) - const credentials = parseCredentials(parsed) - return credentials - ? { status: 'ok', credentials } - : { status: 'error', error: 'Kimi credentials file is invalid' } - } catch (err) { - return { - status: 'error', - error: err instanceof Error ? err.message : 'Unable to read Kimi credentials' + // Why: aborting an fs promise does not cancel an already issued UNC request, so + // share one raw read per path until it settles (mirrors codex-fetcher's auth read). + const read = createAuthFilesystemOperation(path, async (): Promise => { + let raw: string + try { + raw = await readFile(path, 'utf-8') + } catch (err) { + return isMissingPathError(err) + ? { status: 'missing' } + : { status: 'error', error: readErrorMessage(err) } } + try { + const credentials = parseCredentials(JSON.parse(raw)) + return credentials + ? { status: 'ok', credentials } + : { status: 'error', error: 'Kimi credentials file is invalid' } + } catch (err) { + return { status: 'error', error: readErrorMessage(err) } + } + }) + credentialsReadByPath.set(path, read) + const clearRead = (): void => { + if (credentialsReadByPath.get(path) === read) { + credentialsReadByPath.delete(path) + } + } + void read.result.then(clearRead, clearRead) + return read +} + +async function readCredentials(kimiHome: string): Promise { + const path = getCredentialsPath(kimiHome) + try { + // Why: a stopped distro parks a UNC read for minutes; bound it so a WSL home + // degrades to an error instead of stalling the poll cycle. + return await getCredentialsRead(path).wait(AbortSignal.timeout(CREDENTIALS_READ_TIMEOUT_MS)) + } catch (err) { + return { status: 'error', error: readErrorMessage(err) } } } @@ -210,6 +258,11 @@ function mapUsageResponse(data: KimiUsageResponse): ProviderRateLimits { } } +// A bare "operation was aborted due to timeout" hides which machine stalled. +function readErrorContext(home: KimiHomeResolution, error: string): string { + return home.runtime === 'wsl' ? `${error} (WSL ${home.wslDistro ?? 'default distro'})` : error +} + function expiredSessionMessage(home: KimiHomeResolution): string { const where = home.runtime === 'wsl' @@ -260,12 +313,12 @@ export async function fetchKimiRateLimits(options?: { if (home.path === null) { return result('error', `WSL Kimi home unavailable for ${home.wslDistro ?? 'default distro'}`) } - const readResult = readCredentials(home.path) + const readResult = await readCredentials(home.path) if (readResult.status === 'missing') { return result('unavailable', 'Not signed in to Kimi Code') } if (readResult.status === 'error') { - return result('error', readResult.error) + return result('error', readErrorContext(home, readResult.error)) } const creds = readResult.credentials if (typeof creds.access_token !== 'string' || creds.access_token.length === 0) { diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index ab66aa0404e..4a6895af5e1 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -1439,7 +1439,7 @@ describe('RateLimitService', () => { wslDistro: 'Ubuntu', path: '\\\\wsl.localhost\\Ubuntu\\home\\neil\\.kimi-code' } - const resolver = vi.fn(() => home) + const resolver = vi.fn(async () => home) service.setKimiHomeResolver(resolver) mockFreshBackgroundProviderFetches() vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10)) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index d149ef9f082..a8db8ec6db6 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -39,6 +39,7 @@ export type InactiveCodexAccountInfo = { } type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => string | null +type KimiHomeResolver = () => Promise type ClaudeAuthPreparationResolver = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -224,7 +225,7 @@ export class RateLimitService { wslDistro: null } // Why: resolved per cycle — the local-account runtime policy can flip between fetches. - private kimiHomeResolver: (() => KimiHomeResolution) | null = null + private kimiHomeResolver: KimiHomeResolver | null = null private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { runtime: 'host', @@ -263,10 +264,19 @@ export class RateLimitService { this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) } - setKimiHomeResolver(resolver: () => KimiHomeResolution): void { + setKimiHomeResolver(resolver: KimiHomeResolver): void { this.kimiHomeResolver = resolver } + // Why: resolving a WSL home probes wsl.exe, so it must not run before the other + // providers' fetches are started; chaining keeps the no-resolver path immediate. + private fetchKimiWithResolvedHome(): Promise { + const pendingHome = this.kimiHomeResolver?.() + return pendingHome + ? pendingHome.then((home) => fetchKimiRateLimits({ home })) + : fetchKimiRateLimits({ home: undefined }) + } + setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { this.claudeAuthPreparationResolver = resolver } @@ -1668,7 +1678,7 @@ export class RateLimitService { workspaceIdOverride || undefined, this.networkProxySettingsResolver?.() ), - fetchKimiRateLimits({ home: this.kimiHomeResolver?.() }), + this.fetchKimiWithResolvedHome(), miniMaxConfigResult.error ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) : fetchMiniMaxRateLimits({