diff --git a/src/main/ipc/runtime-environments/runtime-environment-session-reconcile.test.ts b/src/main/ipc/runtime-environments/runtime-environment-session-reconcile.test.ts index 6e648ef84fb..d0ed0ed9365 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-session-reconcile.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-session-reconcile.test.ts @@ -1,13 +1,33 @@ import { mkdtempSync, writeFileSync } from 'node:fs' +import * as fs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ExecutionHostId } from '../../../shared/execution-host' import { addEnvironmentFromPairingCode } from '../../../shared/runtime-environment-store' import { getEnvironmentStorePath } from '../../../shared/runtime-environment-store-file' +import { getRuntimeEnvironmentSidecarPath } from '../../../shared/runtime-environment-sidecar' import { pairingCode } from './runtime-environments-ipc-test-harness' import { reconcileOrphanedRuntimeSessions } from './runtime-environment-session-reconcile' +// Lets a test make the registry vanish between a presence check and its read. +const fsRace = vi.hoisted(() => { + const existsAnswers: boolean[] = [] + return { existsAnswers } +}) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + existsSync: (path: fs.PathLike) => fsRace.existsAnswers.shift() ?? actual.existsSync(path) + } +}) + +afterEach(() => { + fsRace.existsAnswers.length = 0 + vi.restoreAllMocks() +}) + function sessionStore(hostIds: ExecutionHostId[]) { return { getWorkspaceSessionHostIds: () => hostIds, @@ -44,4 +64,41 @@ describe('reconciling orphaned runtime sessions at startup', () => { expect(reconcileOrphanedRuntimeSessions(store, corrupt)).toEqual([]) expect(store.removeWorkspaceSessionHost).not.toHaveBeenCalled() }) + + it('keeps sessions of saved servers this build cannot read or currently hides', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-session-reconcile-hidden-')) + const linked = addEnvironmentFromPairingCode(userDataPath, { + name: 'linked', + pairingCode: pairingCode() + }) + const path = getEnvironmentStorePath(userDataPath) + const saved = JSON.parse(fs.readFileSync(path, 'utf8')) + saved.environments.push({ id: 'env-newer-format', kind: 'future-server' }) + writeFileSync(path, JSON.stringify(saved), { mode: 0o600 }) + // An entry this build cannot parse hides its server from listings. + writeFileSync( + getRuntimeEnvironmentSidecarPath(userDataPath), + JSON.stringify({ version: 1, entries: { [linked.id]: { futureLinkField: true } } }), + { mode: 0o600 } + ) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const store = sessionStore([ + `runtime:${linked.id}`, + 'runtime:env-newer-format', + 'runtime:removed-env' + ]) + + expect(reconcileOrphanedRuntimeSessions(store, userDataPath)).toEqual(['runtime:removed-env']) + expect(store.removeWorkspaceSessionHost).toHaveBeenCalledTimes(1) + }) + + it('deletes nothing when the registry disappears between its presence check and read', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-session-reconcile-race-')) + const store = sessionStore(['runtime:env-1']) + // Present when first checked, gone by the time the read looks again. + fsRace.existsAnswers.push(true, false) + + expect(reconcileOrphanedRuntimeSessions(store, userDataPath)).toEqual([]) + expect(store.removeWorkspaceSessionHost).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/runtime-environments/runtime-environment-session-reconcile.ts b/src/main/ipc/runtime-environments/runtime-environment-session-reconcile.ts index 17c1805e7c5..4af13ec073c 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-session-reconcile.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-session-reconcile.ts @@ -1,8 +1,6 @@ /** Crashes and older unlink flows can leave runtime session partitions that listings advertise as orphaned hosts. */ -import { existsSync } from 'node:fs' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import { listEnvironments } from '../../../shared/runtime-environment-store' -import { getEnvironmentStorePath } from '../../../shared/runtime-environment-store-file' +import { readEnvironmentStoreRecords } from '../../../shared/runtime-environment-store-records' type SessionHostStore = { getWorkspaceSessionHostIds: () => ExecutionHostId[] @@ -14,15 +12,19 @@ export function reconcileOrphanedRuntimeSessions( store: SessionHostStore, userDataPath: string ): ExecutionHostId[] { - // Missing or unreadable registry data cannot prove a server was removed. - if (!existsSync(getEnvironmentStorePath(userDataPath))) { - return [] - } let known: Set try { - known = new Set(listEnvironments(userDataPath).map((environment) => environment.id)) - } catch (error) { - console.warn('[runtime-environments] skipped orphaned session reconcile:', error) + // Raw records, not the listing: newer-format and overlay-hidden servers are still saved. + const records = readEnvironmentStoreRecords(userDataPath, { requireStoreFile: true }) + if (records.unreadable.some((record) => record.id === undefined)) { + return [] + } + known = new Set([ + ...records.environments.map((environment) => environment.id), + ...records.unreadable.map((record) => record.id ?? '') + ]) + } catch { + // Missing or unreadable registry data cannot prove a server was removed. return [] } // Runtime sessions are written only after their server is registered. diff --git a/src/main/ipc/runtime-environments/runtime-environments.ts b/src/main/ipc/runtime-environments/runtime-environments.ts index 118ab3ab940..f29570e3cd4 100644 --- a/src/main/ipc/runtime-environments/runtime-environments.ts +++ b/src/main/ipc/runtime-environments/runtime-environments.ts @@ -119,7 +119,11 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { stopPreferenceWatch?.() stopPreferenceWatch = watchRuntimeEnvironmentPreference(store, getUserDataPath(), () => { // CLI and recipe removals skip the Settings path; drop their session partitions here too. - reconcileOrphanedRuntimeSessions(store, getUserDataPath()) + try { + reconcileOrphanedRuntimeSessions(store, getUserDataPath()) + } catch (error) { + console.warn('[runtime-environments] orphaned session reconcile failed:', error) + } publishRuntimeEnvironmentCatalogChanged() }) for (const pending of pendingSubscriptions.values()) {