diff --git a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts index fef3a0cb..7d2bbf49 100644 --- a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts +++ b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts @@ -27,6 +27,10 @@ const MAX_TARBALL_BYTES = 250 * 1024 * 1024; const MAX_BINARY_BYTES = 350 * 1024 * 1024; const FETCH_TIMEOUT_MS = 60_000; const VERSION_TIMEOUT_MS = 10_000; +const VERSION_PROBE_SUCCESS_CACHE_TTL_MS = 30_000; +const VERSION_PROBE_FAILURE_CACHE_TTL_MS = 5_000; +const RUNTIME_STATUS_SUCCESS_CACHE_TTL_MS = 30_000; +const RUNTIME_STATUS_FAILURE_CACHE_TTL_MS = 5_000; interface NpmPackageMetadata { name?: string; @@ -97,15 +101,8 @@ export async function resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(): Prom if (!binaryPath) { return null; } - try { - await execCli(binaryPath, ['--version'], { - timeout: VERSION_TIMEOUT_MS, - windowsHide: true, - }); - return binaryPath; - } catch { - return null; - } + const version = await probeOpenCodeBinaryVersionCached(binaryPath); + return version.ok ? binaryPath : null; } function getExecutableName(): string { @@ -173,6 +170,31 @@ type VerifiedOpenCodeBinaryProbe = | { ok: true; binaryPath: string; version: string | null } | { ok: false; firstFailure: { binaryPath: string; error: string } | null }; +interface CachedVersionProbe { + result: OpenCodeBinaryVersionProbe; + cachedAt: number; + ttlMs: number; +} + +interface CachedPathProbe { + result: VerifiedOpenCodeBinaryProbe; + cachedAt: number; + ttlMs: number; +} + +interface CachedRuntimeBinaryResolve { + binaryPath: string | null; + cachedAt: number; + ttlMs: number; +} + +const versionProbeCache = new Map(); +const versionProbeInFlight = new Map>(); +const pathProbeCache = new Map(); +const pathProbeInFlight = new Map>(); +const runtimeBinaryResolveCache = new Map(); +const runtimeBinaryResolveInFlight = new Map>(); + async function probeOpenCodeBinaryVersion(binaryPath: string): Promise { try { const { stdout } = await execCli(binaryPath, ['--version'], { @@ -190,6 +212,42 @@ function normalizeBinaryCandidateForCompare(binaryPath: string): string { return process.platform === 'win32' ? normalized.toLowerCase() : normalized; } +function getVersionProbeTtlMs(result: OpenCodeBinaryVersionProbe): number { + return result.ok ? VERSION_PROBE_SUCCESS_CACHE_TTL_MS : VERSION_PROBE_FAILURE_CACHE_TTL_MS; +} + +async function probeOpenCodeBinaryVersionCached( + binaryPath: string +): Promise { + const cacheKey = normalizeBinaryCandidateForCompare(binaryPath); + const cached = versionProbeCache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < cached.ttlMs) { + return cached.result; + } + + const inFlight = versionProbeInFlight.get(cacheKey); + if (inFlight) { + return inFlight; + } + + const request = probeOpenCodeBinaryVersion(binaryPath) + .then((result) => { + versionProbeCache.set(cacheKey, { + result, + cachedAt: Date.now(), + ttlMs: getVersionProbeTtlMs(result), + }); + return result; + }) + .finally(() => { + if (versionProbeInFlight.get(cacheKey) === request) { + versionProbeInFlight.delete(cacheKey); + } + }); + versionProbeInFlight.set(cacheKey, request); + return request; +} + async function probeFirstWorkingOpenCodeBinaryCandidate( candidates: string[], seen: Set, @@ -202,7 +260,7 @@ async function probeFirstWorkingOpenCodeBinaryCandidate( continue; } seen.add(normalized); - const version = await probeOpenCodeBinaryVersion(binaryPath); + const version = await probeOpenCodeBinaryVersionCached(binaryPath); if (version.ok) { return { ok: true, binaryPath, version: version.version }; } @@ -217,6 +275,22 @@ interface OpenCodeRuntimeBinaryResolveOptions { includeShellEnv?: boolean; } +function getPathProbeCacheKey(options: OpenCodeRuntimeBinaryResolveOptions = {}): string { + if (options.includeShellEnv === false) { + return 'no-shell-env'; + } + + return `shell-env:${options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS}`; +} + +function getPathProbeTtlMs(result: VerifiedOpenCodeBinaryProbe): number { + return result.ok ? VERSION_PROBE_SUCCESS_CACHE_TTL_MS : VERSION_PROBE_FAILURE_CACHE_TTL_MS; +} + +function getRuntimeBinaryResolveTtlMs(binaryPath: string | null): number { + return binaryPath ? VERSION_PROBE_SUCCESS_CACHE_TTL_MS : VERSION_PROBE_FAILURE_CACHE_TTL_MS; +} + async function probeFirstWorkingPathOpenCodeBinary( options: OpenCodeRuntimeBinaryResolveOptions = {} ): Promise { @@ -268,20 +342,86 @@ async function probeFirstWorkingPathOpenCodeBinary( ); } +async function probeFirstWorkingPathOpenCodeBinaryCached( + options: OpenCodeRuntimeBinaryResolveOptions = {} +): Promise { + const cacheKey = getPathProbeCacheKey(options); + const cached = pathProbeCache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < cached.ttlMs) { + return cached.result; + } + + const inFlight = pathProbeInFlight.get(cacheKey); + if (inFlight) { + return inFlight; + } + + const request = probeFirstWorkingPathOpenCodeBinary(options) + .then((result) => { + pathProbeCache.set(cacheKey, { + result, + cachedAt: Date.now(), + ttlMs: getPathProbeTtlMs(result), + }); + return result; + }) + .finally(() => { + if (pathProbeInFlight.get(cacheKey) === request) { + pathProbeInFlight.delete(cacheKey); + } + }); + pathProbeInFlight.set(cacheKey, request); + return request; +} + async function resolveVerifiedPathOpenCodeBinaryPath( options: OpenCodeRuntimeBinaryResolveOptions = {} ): Promise { - const result = await probeFirstWorkingPathOpenCodeBinary(options); + const result = await probeFirstWorkingPathOpenCodeBinaryCached(options); return result.ok ? result.binaryPath : null; } +export function clearOpenCodeRuntimeBinaryResolverCache(): void { + versionProbeCache.clear(); + versionProbeInFlight.clear(); + pathProbeCache.clear(); + pathProbeInFlight.clear(); + runtimeBinaryResolveCache.clear(); + runtimeBinaryResolveInFlight.clear(); +} + export async function resolveVerifiedOpenCodeRuntimeBinaryPath( options: OpenCodeRuntimeBinaryResolveOptions = {} ): Promise { - return ( + const cacheKey = getPathProbeCacheKey(options); + const cached = runtimeBinaryResolveCache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < cached.ttlMs) { + return cached.binaryPath; + } + + const inFlight = runtimeBinaryResolveInFlight.get(cacheKey); + if (inFlight) { + return inFlight; + } + + const request = (async () => (await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ?? - (await resolveVerifiedPathOpenCodeBinaryPath(options)) - ); + (await resolveVerifiedPathOpenCodeBinaryPath(options)))() + .then((binaryPath) => { + runtimeBinaryResolveCache.set(cacheKey, { + binaryPath, + cachedAt: Date.now(), + ttlMs: getRuntimeBinaryResolveTtlMs(binaryPath), + }); + return binaryPath; + }) + .finally(() => { + if (runtimeBinaryResolveInFlight.get(cacheKey) === request) { + runtimeBinaryResolveInFlight.delete(cacheKey); + } + }); + runtimeBinaryResolveInFlight.set(cacheKey, request); + return request; } function isLinuxMuslRuntime(): boolean { @@ -511,6 +651,8 @@ export class OpenCodeRuntimeInstallerService { private mainWindow: BrowserWindow | null = null; private installPromise: Promise | null = null; private latestStatus: OpenCodeRuntimeStatus | null = null; + private latestStatusAt = 0; + private statusPromise: Promise | null = null; setMainWindow(win: BrowserWindow | null): void { this.mainWindow = win; @@ -518,16 +660,40 @@ export class OpenCodeRuntimeInstallerService { invalidateStatusCache(): void { this.latestStatus = null; + this.latestStatusAt = 0; + this.statusPromise = null; + clearOpenCodeRuntimeBinaryResolverCache(); } async getStatus(): Promise { if (this.installPromise && this.latestStatus) { return this.latestStatus; } + if (this.installPromise) { + return this.installPromise; + } + if (this.latestStatus && Date.now() - this.latestStatusAt < this.getStatusCacheTtlMs()) { + return this.latestStatus; + } + + if (this.statusPromise) { + return this.statusPromise; + } + + const request = this.resolveStatus().finally(() => { + if (this.statusPromise === request) { + this.statusPromise = null; + } + }); + this.statusPromise = request; + return request; + } + + private async resolveStatus(): Promise { const appManagedStatus = await this.getAppManagedStatus(); if (appManagedStatus.installed) { - this.latestStatus = appManagedStatus; + this.rememberStatus(appManagedStatus); return appManagedStatus; } @@ -538,7 +704,7 @@ export class OpenCodeRuntimeInstallerService { appManagedStatus.state !== 'failed' ? pathStatus : appManagedStatus; - this.latestStatus = status; + this.rememberStatus(status); return status; } @@ -553,10 +719,21 @@ export class OpenCodeRuntimeInstallerService { } private publish(status: OpenCodeRuntimeStatus): void { - this.latestStatus = status; + this.rememberStatus(status); safeSendToRenderer(this.mainWindow, CHANNEL, status); } + private rememberStatus(status: OpenCodeRuntimeStatus): void { + this.latestStatus = status; + this.latestStatusAt = Date.now(); + } + + private getStatusCacheTtlMs(): number { + return this.latestStatus?.installed === true + ? RUNTIME_STATUS_SUCCESS_CACHE_TTL_MS + : RUNTIME_STATUS_FAILURE_CACHE_TTL_MS; + } + private publishProgress(progress: OpenCodeRuntimeInstallProgress): void { this.publish({ installed: false, @@ -571,32 +748,28 @@ export class OpenCodeRuntimeInstallerService { if (!isAbsoluteExistingFile(manifest?.binaryPath)) { return { installed: false, source: 'missing', state: 'idle' }; } - try { - const { stdout } = await execCli(manifest.binaryPath, ['--version'], { - timeout: VERSION_TIMEOUT_MS, - windowsHide: true, - }); + const version = await probeOpenCodeBinaryVersionCached(manifest.binaryPath); + if (version.ok) { return { installed: true, binaryPath: manifest.binaryPath, - version: stdout.trim() || manifest.version, + version: version.version ?? manifest.version, source: 'app-managed', state: 'ready', }; - } catch (error) { - return { - installed: false, - binaryPath: manifest.binaryPath, - version: manifest.version, - source: 'app-managed', - state: 'failed', - error: getErrorMessage(error), - }; } + return { + installed: false, + binaryPath: manifest.binaryPath, + version: manifest.version, + source: 'app-managed', + state: 'failed', + error: version.error, + }; } private async getPathStatus(): Promise { - const result = await probeFirstWorkingPathOpenCodeBinary(); + const result = await probeFirstWorkingPathOpenCodeBinaryCached(); if (result.ok) { return { installed: true, @@ -687,6 +860,7 @@ export class OpenCodeRuntimeInstallerService { `${JSON.stringify(manifest, null, 2)}\n`, 'utf8' ); + clearOpenCodeRuntimeBinaryResolverCache(); const status: OpenCodeRuntimeStatus = { installed: true, diff --git a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts index 563d6e49..4a93dbba 100644 --- a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts +++ b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts @@ -28,6 +28,7 @@ vi.mock('@main/utils/shellEnv', () => ({ })); import { + clearOpenCodeRuntimeBinaryResolverCache, extractOpenCodeRuntimeBinaryFromTarball, getOpenCodeRuntimePlatformCandidates, OpenCodeRuntimeInstallerService, @@ -41,6 +42,20 @@ import { setAppDataBasePath } from '@main/utils/pathDecoder'; let tempRoot: string | null = null; let originalPath: string | undefined; +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + function writeOctal(header: Buffer, offset: number, length: number, value: number): void { const encoded = value .toString(8) @@ -84,6 +99,7 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { setAppDataBasePath(tempRoot); originalPath = process.env.PATH; process.env.PATH = ''; + clearOpenCodeRuntimeBinaryResolverCache(); execCliMock.mockReset(); execCliMock.mockResolvedValue({ stdout: 'opencode 1.0.0\n', stderr: '' }); buildMergedCliPathMock.mockReset(); @@ -97,6 +113,7 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { }); afterEach(async () => { + clearOpenCodeRuntimeBinaryResolverCache(); setAppDataBasePath(null); if (originalPath === undefined) { delete process.env.PATH; @@ -194,11 +211,53 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { windowsHide: true, }); + clearOpenCodeRuntimeBinaryResolverCache(); execCliMock.mockRejectedValueOnce(new Error('broken binary')); await expect(resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()).resolves.toBeNull(); }); + it('coalesces concurrent app-managed OpenCode verification probes', async () => { + const binaryPath = path.join( + tempRoot!, + 'data', + 'runtimes', + 'opencode', + 'versions', + '1.0.0', + 'opencode-test', + 'opencode' + ); + const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json'); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + await writeFile( + manifestPath, + `${JSON.stringify({ + schemaVersion: 1, + version: '1.0.0', + platformPackage: 'opencode-test', + binaryPath, + integrity: 'sha512-test', + installedAt: '2026-05-12T00:00:00.000Z', + })}\n`, + 'utf8' + ); + const versionProbe = deferred<{ stdout: string; stderr: string }>(); + execCliMock.mockReturnValue(versionProbe.promise); + + const first = resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); + const second = resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); + await vi.waitFor(() => expect(execCliMock).toHaveBeenCalledTimes(1)); + + versionProbe.resolve({ stdout: 'opencode 1.0.0\n', stderr: '' }); + await expect(Promise.all([first, second])).resolves.toEqual([binaryPath, binaryPath]); + + await expect(resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()).resolves.toBe(binaryPath); + expect(execCliMock).toHaveBeenCalledTimes(1); + }); + it('returns a verified OpenCode binary from best-effort shell PATH when app-managed runtime is absent', async () => { const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode'); await mkdir(path.dirname(binaryPath), { recursive: true }); @@ -222,6 +281,61 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { }); }); + it('coalesces concurrent verified OpenCode PATH probes and reuses the warm result', async () => { + const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode'); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ + PATH: path.dirname(binaryPath), + }); + getShellPreferredHomeMock.mockReturnValue(tempRoot!); + const versionProbe = deferred<{ stdout: string; stderr: string }>(); + execCliMock.mockReturnValue(versionProbe.promise); + + const first = resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 }); + const second = resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 }); + await vi.waitFor(() => expect(execCliMock).toHaveBeenCalledTimes(1)); + + versionProbe.resolve({ stdout: 'opencode 1.0.0\n', stderr: '' }); + await expect(Promise.all([first, second])).resolves.toEqual([binaryPath, binaryPath]); + + await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe( + binaryPath + ); + expect(execCliMock).toHaveBeenCalledTimes(1); + expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1); + }); + + it('coalesces concurrent OpenCode runtime status checks and serves a short warm cache', async () => { + const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode'); + await mkdir(path.dirname(binaryPath), { recursive: true }); + await writeFile(binaryPath, 'binary', { mode: 0o755 }); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ + PATH: path.dirname(binaryPath), + }); + getShellPreferredHomeMock.mockReturnValue(tempRoot!); + const versionProbe = deferred<{ stdout: string; stderr: string }>(); + execCliMock.mockReturnValue(versionProbe.promise); + const service = new OpenCodeRuntimeInstallerService(); + + const first = service.getStatus(); + const second = service.getStatus(); + await vi.waitFor(() => expect(execCliMock).toHaveBeenCalledTimes(1)); + + versionProbe.resolve({ stdout: 'opencode 1.0.0\n', stderr: '' }); + await expect(Promise.all([first, second])).resolves.toMatchObject([ + { installed: true, source: 'path', binaryPath }, + { installed: true, source: 'path', binaryPath }, + ]); + + await expect(service.getStatus()).resolves.toMatchObject({ + installed: true, + source: 'path', + binaryPath, + }); + expect(execCliMock).toHaveBeenCalledTimes(1); + }); + it('returns a verified OpenCode binary from the merged CLI PATH after zero-wait shell fallback', async () => { const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode'); await mkdir(path.dirname(binaryPath), { recursive: true });