Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/main/ipc/runtime-environment-connectivity-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-
import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { Store } from '../persistence'
import { readSettingsWithRuntimeEnvironmentPreference } from './runtime-environment-preference'
import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime'
import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement'
import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
Expand Down Expand Up @@ -65,9 +66,11 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
ipcMain.handle('runtimeEnvironments:getStatusSnapshots', () =>
getRuntimeEnvironmentStatusSnapshots()
)
ipcMain.handle('runtimeEnvironments:list', () =>
listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
)
ipcMain.handle('runtimeEnvironments:list', () => {
const environments = listEnvironments(getUserDataPath())
readSettingsWithRuntimeEnvironmentPreference(store, getUserDataPath())
return environments.map(redactRuntimeEnvironment)
})
ipcMain.handle(
'runtimeEnvironments:addFromPairingCode',
(
Expand Down
179 changes: 179 additions & 0 deletions src/main/ipc/runtime-environment-preference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
import { encodePairingOffer } from '../../shared/pairing'
import {
addEnvironmentFromPairingCode,
getEnvironmentStorePath,
listEnvironments,
removeEnvironment
} from '../../shared/runtime-environment-store'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Store } from '../persistence'
import {
readSettingsWithRuntimeEnvironmentPreference,
watchRuntimeEnvironmentPreference
} from './runtime-environment-preference'

let userDataPath: string

beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-removed-preference-'))
})

afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
})

function preferenceStore(activeId: string | null) {
let settings = { ...getDefaultSettings(userDataPath), activeRuntimeEnvironmentId: activeId }
const updateSettings = vi.fn<Store['updateSettings']>((updates) => {
settings = { ...settings, ...updates }
return settings
})
return { getSettings: () => settings, updateSettings }
}

function saveEnvironment(name = 'server') {
return addEnvironmentFromPairingCode(userDataPath, {
name,
pairingCode: encodePairingOffer({
v: 2,
endpoint: 'ws://127.0.0.1:59999',
deviceToken: 'test-token',
publicKeyB64: Buffer.alloc(32, 1).toString('base64')
})
})
}

describe('Active Server after a saved host is removed', () => {
it('survives a failed watch installation and repairs on the next settings read', () => {
const environment = saveEnvironment()
const store = preferenceStore(environment.id)
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
let stopWatching: (() => void) | undefined
rmSync(userDataPath, { recursive: true })
try {
expect(() => {
stopWatching = watchRuntimeEnvironmentPreference(store, userDataPath)
}).not.toThrow()
expect(store.getSettings().activeRuntimeEnvironmentId).toBe(environment.id)
mkdirSync(userDataPath)
writeFileSync(getEnvironmentStorePath(userDataPath), '{"version":1,"environments":[]}')
expect(
readSettingsWithRuntimeEnvironmentPreference(store, userDataPath).activeRuntimeEnvironmentId
).toBeNull()
} finally {
stopWatching?.()
warning.mockRestore()
}
})

it('notifies the running renderer when the CLI atomically replaces the registry', async () => {
const environment = saveEnvironment()
const store = preferenceStore(environment.id)
const stopWatching = watchRuntimeEnvironmentPreference(store, userDataPath)
try {
await new Promise<void>((resolve) => setImmediate(resolve))
removeEnvironment(userDataPath, environment.id)
await vi.waitFor(() => expect(store.getSettings().activeRuntimeEnvironmentId).toBeNull(), {
timeout: 5_000
})
expect(store.updateSettings).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: null },
{ notifyListeners: true }
)
} finally {
stopWatching()
}
})

it('repairs a CLI removal before settings can route to the deleted pairing', () => {
const environment = saveEnvironment()
const store = preferenceStore(environment.id)
const before = store.getSettings()
removeEnvironment(userDataPath, environment.id)

const settings = readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)

expect(settings).toEqual({ ...before, activeRuntimeEnvironmentId: null })
expect(store.getSettings().activeRuntimeEnvironmentId).toBeNull()
expect(store.updateSettings).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: null },
{ notifyListeners: true }
)
readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)
expect(store.updateSettings).toHaveBeenCalledOnce()
})

it('does not select a same-name replacement or another saved server', () => {
const removed = saveEnvironment()
const store = preferenceStore(removed.id)
removeEnvironment(userDataPath, removed.id)
const replacement = saveEnvironment()
saveEnvironment('other')

expect(replacement.id).not.toBe(removed.id)
expect(
readSettingsWithRuntimeEnvironmentPreference(store, userDataPath).activeRuntimeEnvironmentId
).toBeNull()
})

it('keeps a saved host even when its endpoint is offline', () => {
const environment = saveEnvironment()
const store = preferenceStore(environment.id)
const settings = store.getSettings()

expect(readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)).toBe(settings)
expect(store.updateSettings).not.toHaveBeenCalled()
})

it.each(['missing', 'corrupt', 'unsupported'] as const)(
'preserves the preference when the registry is %s',
(kind) => {
const environment = saveEnvironment()
const store = preferenceStore(environment.id)
const registryPath = getEnvironmentStorePath(userDataPath)
if (kind === 'missing') {
rmSync(registryPath)
} else {
writeFileSync(registryPath, kind === 'corrupt' ? '{' : '{"version":99,"environments":[]}')
}

expect(readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)).toBe(
store.getSettings()
)
expect(store.updateSettings).not.toHaveBeenCalled()
}
)

it('keeps local defaults without requiring a pairing registry', () => {
const store = preferenceStore(null)
expect(readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)).toBe(
store.getSettings()
)
expect(store.updateSettings).not.toHaveBeenCalled()
})

it('does not swallow a settings write failure after confirming removal', () => {
const environment = saveEnvironment()
const store = preferenceStore(environment.id)
removeEnvironment(userDataPath, environment.id)
store.updateSettings.mockImplementation((): GlobalSettings => {
throw new Error('settings write failed')
})

expect(() => readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)).toThrow(
'settings write failed'
)
})

it('distinguishes a new profile from an unavailable registry for repair reads', () => {
expect(listEnvironments(userDataPath)).toEqual([])
expect(() => listEnvironments(userDataPath, { requireStoreFile: true })).toThrow(
'Could not read Orca environments'
)
})
})
73 changes: 73 additions & 0 deletions src/main/ipc/runtime-environment-preference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { watch, type FSWatcher } from 'node:fs'
import { basename } from 'node:path'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { getEnvironmentStorePath, listEnvironments } from '../../shared/runtime-environment-store'
import type { Store } from '../persistence'

type PreferenceStore = Pick<Store, 'getSettings' | 'updateSettings'>

function reconcileRuntimeEnvironmentPreference(
store: PreferenceStore,
environments: readonly { id: string }[]
): GlobalSettings {
const settings = store.getSettings()
const activeId = settings?.activeRuntimeEnvironmentId?.trim()
if (!activeId || environments.some((environment) => environment.id === activeId)) {
return settings
}
// A deleted default must not keep routing unrelated requests to its retired pairing.
return store.updateSettings({ activeRuntimeEnvironmentId: null }, { notifyListeners: true })
}

export function readSettingsWithRuntimeEnvironmentPreference(
store: PreferenceStore,
userDataPath: string
): GlobalSettings {
const settings = store.getSettings()
if (!settings?.activeRuntimeEnvironmentId?.trim()) {
return settings
}
let environments: { id: string }[]
try {
// An unavailable registry cannot prove the selected host was removed.
environments = listEnvironments(userDataPath, { requireStoreFile: true })
} catch {
return settings
}
return reconcileRuntimeEnvironmentPreference(store, environments)
}

export function watchRuntimeEnvironmentPreference(
store: PreferenceStore,
userDataPath: string
): () => void {
const registryName = basename(getEnvironmentStorePath(userDataPath))
const repairPreference = (): void => {
try {
readSettingsWithRuntimeEnvironmentPreference(store, userDataPath)
} catch (error) {
console.warn('[runtime-environments] Active Server preference repair failed:', error)
}
}
// Reconcile changes made while the native watch was being installed.
const initialCheck = setImmediate(repairPreference)
let watcher: FSWatcher | undefined
try {
// Watch the directory because registry writes atomically replace the file.
watcher = watch(userDataPath, { persistent: false }, (_event, filename) => {
if (filename === null || filename.toString() === registryName) {
repairPreference()
}
})
watcher.on('error', (error) => {
watcher?.close()
console.warn('[runtime-environments] Active Server preference watch failed:', error)
})
} catch (error) {
console.warn('[runtime-environments] Active Server preference watch failed:', error)
}
return () => {
clearImmediate(initialCheck)
watcher?.close()
}
}
4 changes: 4 additions & 0 deletions src/main/ipc/runtime-environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-hand
import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime'
import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler'
import { advanceRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence'
import { watchRuntimeEnvironmentPreference } from './runtime-environment-preference'

const remoteRuntimeSubscriptions = new Map<string, RetainedRemoteRuntimeSubscription>()
const getUserDataPath = (): string => app.getPath('userData')
Expand Down Expand Up @@ -75,8 +76,11 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Pr
}

const pendingSubscriptions = new Map<string, PendingRuntimeSubscription>()
let stopPreferenceWatch: (() => void) | undefined

export function registerRuntimeEnvironmentHandlers(store: Store): void {
stopPreferenceWatch?.()
stopPreferenceWatch = watchRuntimeEnvironmentPreference(store, getUserDataPath())
for (const pending of pendingSubscriptions.values()) {
pending.close()
}
Expand Down
27 changes: 26 additions & 1 deletion src/main/ipc/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
previewWarpThemeImportMock,
prepareLocalWorktreeRootsForReposMock,
resolveEnvironmentMock,
listEnvironmentsMock,
rebuildAppMenuMock,
applyBrowserSessionProxiesMock,
applySessionSearchSettingsChangeMock,
Expand All @@ -27,6 +28,7 @@ const {
previewWarpThemeImportMock: vi.fn(),
prepareLocalWorktreeRootsForReposMock: vi.fn(),
resolveEnvironmentMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
rebuildAppMenuMock: vi.fn(),
applyBrowserSessionProxiesMock: vi.fn(),
applySessionSearchSettingsChangeMock: vi.fn(),
Expand Down Expand Up @@ -81,7 +83,8 @@ vi.mock('../menu/register-app-menu', () => ({
}))

vi.mock('../../shared/runtime-environment-store', () => ({
resolveEnvironment: resolveEnvironmentMock
resolveEnvironment: resolveEnvironmentMock,
listEnvironments: listEnvironmentsMock
}))

import { registerSettingsHandlers } from './settings'
Expand Down Expand Up @@ -126,6 +129,28 @@ describe('registerSettingsHandlers', () => {
store.getSettings.mockReset()
store.updateSettings.mockReset()
store.onSettingsChanged.mockClear()
listEnvironmentsMock.mockReset().mockReturnValue([])
})

it('repairs removed Active Server preferences on asynchronous and synchronous reads', () => {
store.getSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'removed' })
store.updateSettings.mockImplementation((updates) => {
const next = { ...store.getSettings(), ...updates }
store.getSettings.mockReturnValue(next)
return next
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This handler only uses the settings methods supplied by the fixture.
registerSettingsHandlers(store as never)
const get = handleMock.mock.calls.find(([channel]) => channel === 'settings:get')?.[1]
const getSync = onMock.mock.calls.find(([channel]) => channel === 'settings:get-sync')?.[1]

expect(get()).toEqual({ activeRuntimeEnvironmentId: null })
store.getSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'removed-again' })
const event = { returnValue: undefined }
getSync(event)
expect(event.returnValue).toEqual({ activeRuntimeEnvironmentId: null })
expect(store.updateSettings).toHaveBeenCalledTimes(2)
expect(listEnvironmentsMock).toHaveBeenCalledWith('/test/user-data', { requireStoreFile: true })
})

it('registers settings:previewGhosttyImport handler', () => {
Expand Down
5 changes: 3 additions & 2 deletions src/main/ipc/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { readSettingsWithRuntimeEnvironmentPreference } from './runtime-environment-preference'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
import {
normalizeMobilePairingCustomAddress,
Expand Down Expand Up @@ -98,7 +99,7 @@ export function registerSettingsHandlers(
})

ipcMain.handle('settings:get', () => {
return store.getSettings()
return readSettingsWithRuntimeEnvironmentPreference(store, app.getPath('userData'))
})

ipcMain.handle(
Expand All @@ -120,7 +121,7 @@ export function registerSettingsHandlers(
// synchronously or pre-hydration bindings would always pick main authority
// (terminal-side-effect-authority.md, migration switch).
ipcMain.on('settings:get-sync', (event) => {
event.returnValue = store.getSettings()
event.returnValue = readSettingsWithRuntimeEnvironmentPreference(store, app.getPath('userData'))
})

ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
Expand Down
Loading
Loading