fix(startup): ignore stale opencode probe results

This commit is contained in:
777genius 2026-05-26 10:39:50 +03:00
parent a8ac52b6f3
commit 4640e1eea4
2 changed files with 103 additions and 19 deletions

View file

@ -194,6 +194,7 @@ const pathProbeCache = new Map<string, CachedPathProbe>();
const pathProbeInFlight = new Map<string, Promise<VerifiedOpenCodeBinaryProbe>>();
const runtimeBinaryResolveCache = new Map<string, CachedRuntimeBinaryResolve>();
const runtimeBinaryResolveInFlight = new Map<string, Promise<string | null>>();
let runtimeResolverCacheGeneration = 0;
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
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<OpenCodeRuntimeStatus> | 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<OpenCodeRuntimeStatus> {
private async resolveStatus(statusCacheGeneration: number): Promise<OpenCodeRuntimeStatus> {
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();

View file

@ -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 });