fix(startup): ignore stale opencode probe results
This commit is contained in:
parent
a8ac52b6f3
commit
4640e1eea4
2 changed files with 103 additions and 19 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue