mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(runtime-environments): keep session partitions of saved servers the listing hides (#34134)
The registry watcher now reconciles orphaned runtime sessions on every write, so the reconcile must only drop hosts the file proves are gone. It read the filtered listing, which omits newer-format records and servers hidden by an unreadable sidecar or WSL overlay, and checked file presence before reading, so a file vanishing in between read as an empty registry. Build the known set from every raw record id, fail closed on a missing file or an id-less record, and keep the catalog broadcast running if the reconcile throws. Follow-up to #34101.
This commit is contained in:
@@ -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<typeof fs>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>
|
||||
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.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user