From 4640e1eea4f81df63cab368d8802b875243041af Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 26 May 2026 10:39:50 +0300 Subject: [PATCH] fix(startup): ignore stale opencode probe results --- .../OpenCodeRuntimeInstallerService.ts | 62 +++++++++++++------ .../OpenCodeRuntimeInstallerService.test.ts | 60 ++++++++++++++++++ 2 files changed, 103 insertions(+), 19 deletions(-) diff --git a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts index 7d2bbf49..811e4356 100644 --- a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts +++ b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts @@ -194,6 +194,7 @@ const pathProbeCache = new Map(); const pathProbeInFlight = new Map>(); const runtimeBinaryResolveCache = new Map(); const runtimeBinaryResolveInFlight = new Map>(); +let runtimeResolverCacheGeneration = 0; async function probeOpenCodeBinaryVersion(binaryPath: string): Promise { try { @@ -230,13 +231,16 @@ async function probeOpenCodeBinaryVersionCached( return inFlight; } + const cacheGeneration = runtimeResolverCacheGeneration; const request = probeOpenCodeBinaryVersion(binaryPath) .then((result) => { - versionProbeCache.set(cacheKey, { - result, - cachedAt: Date.now(), - ttlMs: getVersionProbeTtlMs(result), - }); + if (cacheGeneration === runtimeResolverCacheGeneration) { + versionProbeCache.set(cacheKey, { + result, + cachedAt: Date.now(), + ttlMs: getVersionProbeTtlMs(result), + }); + } return result; }) .finally(() => { @@ -356,13 +360,16 @@ async function probeFirstWorkingPathOpenCodeBinaryCached( return inFlight; } + const cacheGeneration = runtimeResolverCacheGeneration; const request = probeFirstWorkingPathOpenCodeBinary(options) .then((result) => { - pathProbeCache.set(cacheKey, { - result, - cachedAt: Date.now(), - ttlMs: getPathProbeTtlMs(result), - }); + if (cacheGeneration === runtimeResolverCacheGeneration) { + pathProbeCache.set(cacheKey, { + result, + cachedAt: Date.now(), + ttlMs: getPathProbeTtlMs(result), + }); + } return result; }) .finally(() => { @@ -382,6 +389,7 @@ async function resolveVerifiedPathOpenCodeBinaryPath( } export function clearOpenCodeRuntimeBinaryResolverCache(): void { + runtimeResolverCacheGeneration += 1; versionProbeCache.clear(); versionProbeInFlight.clear(); pathProbeCache.clear(); @@ -404,15 +412,18 @@ export async function resolveVerifiedOpenCodeRuntimeBinaryPath( return inFlight; } + const cacheGeneration = runtimeResolverCacheGeneration; const request = (async () => (await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ?? (await resolveVerifiedPathOpenCodeBinaryPath(options)))() .then((binaryPath) => { - runtimeBinaryResolveCache.set(cacheKey, { - binaryPath, - cachedAt: Date.now(), - ttlMs: getRuntimeBinaryResolveTtlMs(binaryPath), - }); + if (cacheGeneration === runtimeResolverCacheGeneration) { + runtimeBinaryResolveCache.set(cacheKey, { + binaryPath, + cachedAt: Date.now(), + ttlMs: getRuntimeBinaryResolveTtlMs(binaryPath), + }); + } return binaryPath; }) .finally(() => { @@ -653,12 +664,14 @@ export class OpenCodeRuntimeInstallerService { private latestStatus: OpenCodeRuntimeStatus | null = null; private latestStatusAt = 0; private statusPromise: Promise | null = null; + private statusCacheGeneration = 0; setMainWindow(win: BrowserWindow | null): void { this.mainWindow = win; } invalidateStatusCache(): void { + this.statusCacheGeneration += 1; this.latestStatus = null; this.latestStatusAt = 0; this.statusPromise = null; @@ -681,7 +694,8 @@ export class OpenCodeRuntimeInstallerService { return this.statusPromise; } - const request = this.resolveStatus().finally(() => { + const statusCacheGeneration = this.statusCacheGeneration; + const request = this.resolveStatus(statusCacheGeneration).finally(() => { if (this.statusPromise === request) { this.statusPromise = null; } @@ -690,10 +704,10 @@ export class OpenCodeRuntimeInstallerService { return request; } - private async resolveStatus(): Promise { + private async resolveStatus(statusCacheGeneration: number): Promise { const appManagedStatus = await this.getAppManagedStatus(); if (appManagedStatus.installed) { - this.rememberStatus(appManagedStatus); + this.rememberStatusIfCurrent(appManagedStatus, statusCacheGeneration); return appManagedStatus; } @@ -704,7 +718,7 @@ export class OpenCodeRuntimeInstallerService { appManagedStatus.state !== 'failed' ? pathStatus : appManagedStatus; - this.rememberStatus(status); + this.rememberStatusIfCurrent(status, statusCacheGeneration); return status; } @@ -719,10 +733,20 @@ export class OpenCodeRuntimeInstallerService { } private publish(status: OpenCodeRuntimeStatus): void { + this.statusCacheGeneration += 1; this.rememberStatus(status); safeSendToRenderer(this.mainWindow, CHANNEL, status); } + private rememberStatusIfCurrent( + status: OpenCodeRuntimeStatus, + statusCacheGeneration: number + ): void { + if (statusCacheGeneration === this.statusCacheGeneration) { + this.rememberStatus(status); + } + } + private rememberStatus(status: OpenCodeRuntimeStatus): void { this.latestStatus = status; this.latestStatusAt = Date.now(); diff --git a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts index 4a93dbba..114790c6 100644 --- a/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts +++ b/test/main/services/infrastructure/OpenCodeRuntimeInstallerService.test.ts @@ -306,6 +306,30 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1); }); + it('does not warm verified OpenCode PATH caches from a stale in-flight probe', 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.mockReturnValueOnce(versionProbe.promise); + + const staleResolve = resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 }); + await vi.waitFor(() => expect(execCliMock).toHaveBeenCalledTimes(1)); + + clearOpenCodeRuntimeBinaryResolverCache(); + versionProbe.resolve({ stdout: 'opencode 1.0.0\n', stderr: '' }); + await expect(staleResolve).resolves.toBe(binaryPath); + + await expect(resolveVerifiedOpenCodeRuntimeBinaryPath({ shellEnvTimeoutMs: 0 })).resolves.toBe( + binaryPath + ); + expect(execCliMock).toHaveBeenCalledTimes(2); + }); + 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 }); @@ -336,6 +360,42 @@ describe('OpenCodeRuntimeInstallerService resolver', () => { expect(execCliMock).toHaveBeenCalledTimes(1); }); + it('does not remember OpenCode runtime status from a stale in-flight check', 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.mockReturnValueOnce(versionProbe.promise).mockResolvedValue({ + stdout: 'opencode 2.0.0\n', + stderr: '', + }); + const service = new OpenCodeRuntimeInstallerService(); + + const staleStatus = service.getStatus(); + await vi.waitFor(() => expect(execCliMock).toHaveBeenCalledTimes(1)); + + service.invalidateStatusCache(); + versionProbe.resolve({ stdout: 'opencode 1.0.0\n', stderr: '' }); + await expect(staleStatus).resolves.toMatchObject({ + installed: true, + source: 'path', + binaryPath, + version: 'opencode 1.0.0', + }); + + await expect(service.getStatus()).resolves.toMatchObject({ + installed: true, + source: 'path', + binaryPath, + version: 'opencode 2.0.0', + }); + expect(execCliMock).toHaveBeenCalledTimes(2); + }); + 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 });