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 pathProbeInFlight = new Map<string, Promise<VerifiedOpenCodeBinaryProbe>>();
|
||||||
const runtimeBinaryResolveCache = new Map<string, CachedRuntimeBinaryResolve>();
|
const runtimeBinaryResolveCache = new Map<string, CachedRuntimeBinaryResolve>();
|
||||||
const runtimeBinaryResolveInFlight = new Map<string, Promise<string | null>>();
|
const runtimeBinaryResolveInFlight = new Map<string, Promise<string | null>>();
|
||||||
|
let runtimeResolverCacheGeneration = 0;
|
||||||
|
|
||||||
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
|
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -230,13 +231,16 @@ async function probeOpenCodeBinaryVersionCached(
|
||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheGeneration = runtimeResolverCacheGeneration;
|
||||||
const request = probeOpenCodeBinaryVersion(binaryPath)
|
const request = probeOpenCodeBinaryVersion(binaryPath)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
versionProbeCache.set(cacheKey, {
|
if (cacheGeneration === runtimeResolverCacheGeneration) {
|
||||||
result,
|
versionProbeCache.set(cacheKey, {
|
||||||
cachedAt: Date.now(),
|
result,
|
||||||
ttlMs: getVersionProbeTtlMs(result),
|
cachedAt: Date.now(),
|
||||||
});
|
ttlMs: getVersionProbeTtlMs(result),
|
||||||
|
});
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -356,13 +360,16 @@ async function probeFirstWorkingPathOpenCodeBinaryCached(
|
||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheGeneration = runtimeResolverCacheGeneration;
|
||||||
const request = probeFirstWorkingPathOpenCodeBinary(options)
|
const request = probeFirstWorkingPathOpenCodeBinary(options)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
pathProbeCache.set(cacheKey, {
|
if (cacheGeneration === runtimeResolverCacheGeneration) {
|
||||||
result,
|
pathProbeCache.set(cacheKey, {
|
||||||
cachedAt: Date.now(),
|
result,
|
||||||
ttlMs: getPathProbeTtlMs(result),
|
cachedAt: Date.now(),
|
||||||
});
|
ttlMs: getPathProbeTtlMs(result),
|
||||||
|
});
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -382,6 +389,7 @@ async function resolveVerifiedPathOpenCodeBinaryPath(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearOpenCodeRuntimeBinaryResolverCache(): void {
|
export function clearOpenCodeRuntimeBinaryResolverCache(): void {
|
||||||
|
runtimeResolverCacheGeneration += 1;
|
||||||
versionProbeCache.clear();
|
versionProbeCache.clear();
|
||||||
versionProbeInFlight.clear();
|
versionProbeInFlight.clear();
|
||||||
pathProbeCache.clear();
|
pathProbeCache.clear();
|
||||||
|
|
@ -404,15 +412,18 @@ export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheGeneration = runtimeResolverCacheGeneration;
|
||||||
const request = (async () =>
|
const request = (async () =>
|
||||||
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
|
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
|
||||||
(await resolveVerifiedPathOpenCodeBinaryPath(options)))()
|
(await resolveVerifiedPathOpenCodeBinaryPath(options)))()
|
||||||
.then((binaryPath) => {
|
.then((binaryPath) => {
|
||||||
runtimeBinaryResolveCache.set(cacheKey, {
|
if (cacheGeneration === runtimeResolverCacheGeneration) {
|
||||||
binaryPath,
|
runtimeBinaryResolveCache.set(cacheKey, {
|
||||||
cachedAt: Date.now(),
|
binaryPath,
|
||||||
ttlMs: getRuntimeBinaryResolveTtlMs(binaryPath),
|
cachedAt: Date.now(),
|
||||||
});
|
ttlMs: getRuntimeBinaryResolveTtlMs(binaryPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
return binaryPath;
|
return binaryPath;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -653,12 +664,14 @@ export class OpenCodeRuntimeInstallerService {
|
||||||
private latestStatus: OpenCodeRuntimeStatus | null = null;
|
private latestStatus: OpenCodeRuntimeStatus | null = null;
|
||||||
private latestStatusAt = 0;
|
private latestStatusAt = 0;
|
||||||
private statusPromise: Promise<OpenCodeRuntimeStatus> | null = null;
|
private statusPromise: Promise<OpenCodeRuntimeStatus> | null = null;
|
||||||
|
private statusCacheGeneration = 0;
|
||||||
|
|
||||||
setMainWindow(win: BrowserWindow | null): void {
|
setMainWindow(win: BrowserWindow | null): void {
|
||||||
this.mainWindow = win;
|
this.mainWindow = win;
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateStatusCache(): void {
|
invalidateStatusCache(): void {
|
||||||
|
this.statusCacheGeneration += 1;
|
||||||
this.latestStatus = null;
|
this.latestStatus = null;
|
||||||
this.latestStatusAt = 0;
|
this.latestStatusAt = 0;
|
||||||
this.statusPromise = null;
|
this.statusPromise = null;
|
||||||
|
|
@ -681,7 +694,8 @@ export class OpenCodeRuntimeInstallerService {
|
||||||
return this.statusPromise;
|
return this.statusPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = this.resolveStatus().finally(() => {
|
const statusCacheGeneration = this.statusCacheGeneration;
|
||||||
|
const request = this.resolveStatus(statusCacheGeneration).finally(() => {
|
||||||
if (this.statusPromise === request) {
|
if (this.statusPromise === request) {
|
||||||
this.statusPromise = null;
|
this.statusPromise = null;
|
||||||
}
|
}
|
||||||
|
|
@ -690,10 +704,10 @@ export class OpenCodeRuntimeInstallerService {
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveStatus(): Promise<OpenCodeRuntimeStatus> {
|
private async resolveStatus(statusCacheGeneration: number): Promise<OpenCodeRuntimeStatus> {
|
||||||
const appManagedStatus = await this.getAppManagedStatus();
|
const appManagedStatus = await this.getAppManagedStatus();
|
||||||
if (appManagedStatus.installed) {
|
if (appManagedStatus.installed) {
|
||||||
this.rememberStatus(appManagedStatus);
|
this.rememberStatusIfCurrent(appManagedStatus, statusCacheGeneration);
|
||||||
return appManagedStatus;
|
return appManagedStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -704,7 +718,7 @@ export class OpenCodeRuntimeInstallerService {
|
||||||
appManagedStatus.state !== 'failed'
|
appManagedStatus.state !== 'failed'
|
||||||
? pathStatus
|
? pathStatus
|
||||||
: appManagedStatus;
|
: appManagedStatus;
|
||||||
this.rememberStatus(status);
|
this.rememberStatusIfCurrent(status, statusCacheGeneration);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -719,10 +733,20 @@ export class OpenCodeRuntimeInstallerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private publish(status: OpenCodeRuntimeStatus): void {
|
private publish(status: OpenCodeRuntimeStatus): void {
|
||||||
|
this.statusCacheGeneration += 1;
|
||||||
this.rememberStatus(status);
|
this.rememberStatus(status);
|
||||||
safeSendToRenderer(this.mainWindow, CHANNEL, 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 {
|
private rememberStatus(status: OpenCodeRuntimeStatus): void {
|
||||||
this.latestStatus = status;
|
this.latestStatus = status;
|
||||||
this.latestStatusAt = Date.now();
|
this.latestStatusAt = Date.now();
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,30 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
||||||
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('coalesces concurrent OpenCode runtime status checks and serves a short warm cache', async () => {
|
||||||
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
|
const binaryPath = path.join(tempRoot!, 'homebrew', 'bin', 'opencode');
|
||||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||||
|
|
@ -336,6 +360,42 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
||||||
expect(execCliMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
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');
|
const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode');
|
||||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue