From aed52aaa9be3191dc3ab93e596e5f6f8a10e37c4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:56:47 -0800 Subject: [PATCH] perf: stop host-status pings rebuilding title catalog and rereading server config (#39340) * perf: stop host-status pings rebuilding the title catalog and rereading server config Host-status updates walked every saved tab for title-refresh support before checking whether any host's support changed, and remote RPC/tunnel/worker checks reread, rehardened and reparsed the saved-server registry files on every call. Gate the title walk on an actual per-host capability flip, and reuse the merged registry read while a stat signature of its three files is unchanged. * perf: invalidate registry read cache on in-process writes Coarse-timestamp filesystems can reuse an inode within one tick, so a same-size rewrite could slip past the stat signature; writers in this process now bump a generation that is part of the signature. * perf: key the registry read cache on file bytes, not stat metadata A stat signature can miss a same-size rewrite on coarse-timestamp filesystems or a reused inode. Comparing the files' exact bytes (plus mode/ctime and the directory mode, which the uncached read re-hardens) means a hit can never serve data that differs from disk, from any writer. * perf: only reuse a registry read once hardening holds and the path is unchanged Reuse now requires the files' permissions to already be restricted (POSIX), so a transient chmod failure is retried on the next read as before; Windows hits keep the ACL verify-and-retry. Snapshots also record each path entry's identity, so a symlink re-pointed and restored mid-read cannot pin the value read through the other target. * perf: build the cached registry value from the exact bytes it is keyed on The value was computed by a second, independent read, so a directory or symlink swapped and restored around it could pin data the key's bytes never held. Each reader is split into read + parse-text, and on a miss the cache hardens, snapshots once, and parses that snapshot. Reuse also now requires the exact 0600/0700 modes the readers set, so an incomplete owner mode is repaired as before. * perf: require the full hardened mode, including special bits, before reusing a registry read * perf: keep Windows on the plain registry reader Measured on a Windows host: a cache hit still has to re-verify ACLs, so it cost as much as the read it skipped (0.37 ms vs 0.33 ms). Windows now always uses the original reader, keeping its behavior byte-for-byte unchanged. --- .../ai-vault-tab-title-freshness.test.ts | 65 +++++++++ .../ai-vault-tab-title-freshness.ts | 36 ++++- .../title-sync/ai-vault-tab-title-sync.ts | 20 ++- .../host-store/runtime-environment-sidecar.ts | 24 ++- .../runtime-environment-store-file.ts | 102 +++++++++++-- ...nment-store-read-cache-integration.test.ts | 138 ++++++++++++++++++ ...ntime-environment-store-read-cache.test.ts | 60 ++++++++ .../runtime-environment-store-read-cache.ts | 122 ++++++++++++++++ .../runtime-environment-store-records.ts | 36 +++-- .../host-store/wsl-host-deployment-store.ts | 35 +++-- 10 files changed, 585 insertions(+), 53 deletions(-) create mode 100644 src/shared/remote-runtime/host-store/runtime-environment-store-read-cache-integration.test.ts create mode 100644 src/shared/remote-runtime/host-store/runtime-environment-store-read-cache.test.ts create mode 100644 src/shared/remote-runtime/host-store/runtime-environment-store-read-cache.ts diff --git a/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-freshness.test.ts b/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-freshness.test.ts index 2887c9c0dd3..ba4325423c9 100644 --- a/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-freshness.test.ts +++ b/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-freshness.test.ts @@ -5,6 +5,12 @@ import { startAiVaultTabTitleSync } from './ai-vault-tab-title-sync' import { settleAiVaultTitleRequestBatches } from './ai-vault-tab-title-batches' import { collectAiVaultTitleRequests } from './ai-vault-tab-title-requests' import type * as MountedTerminalTabRegistry from '@/runtime/mounted-terminal-tab-registry' +import type * as TitleRequests from './ai-vault-tab-title-requests' + +vi.mock('./ai-vault-tab-title-requests', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, collectAiVaultTitleRequests: vi.fn(actual.collectAiVaultTitleRequests) } +}) vi.mock('@/runtime/mounted-terminal-tab-registry', async (importOriginal) => ({ ...(await importOriginal()), @@ -170,3 +176,62 @@ describe('session title freshness', () => { expect(sent).toHaveLength(64) }) }) + +describe('host-status title gating', () => { + function runtimeStatus(capabilities: string[], checkedAt: number) { + return new Map([ + [ + 'server-1', + { + checkedAt, + status: { + runtimeId: 'server-1', + rendererGraphEpoch: 1, + graphStatus: 'ready' as const, + authoritativeWindowId: null, + liveTabCount: 1, + liveLeafCount: 1, + capabilities + } + } + ] + ]) + } + + it('skips the tab catalog walk when a status ping keeps title-refresh support', async () => { + const store = storeFor('runtime:server-1') + const resolveSessionTitles = vi.fn(async () => titleResult('codex', 'First name')) + stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles }) + await vi.waitFor(() => expect(resolveSessionTitles).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setTimeout(resolve, 0)) + vi.mocked(collectAiVaultTitleRequests).mockClear() + + store.update({ runtimeStatusByEnvironmentId: runtimeStatus([], 2) }) + store.update({ runtimeStatusByEnvironmentId: runtimeStatus([], 3) }) + + expect(collectAiVaultTitleRequests).not.toHaveBeenCalled() + }) + + it('reconciles when a host gains title-refresh support', async () => { + const store = storeFor('runtime:server-1') + const resolveSessionTitles = vi.fn(async () => titleResult('codex', 'First name')) + const scheduleReconcile = vi.fn((callback: () => void) => { + queueMicrotask(callback) + return () => {} + }) + stop = startAiVaultTabTitleSync({ ...store, resolveSessionTitles, scheduleReconcile }) + await vi.waitFor(() => expect(resolveSessionTitles).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setTimeout(resolve, 0)) + scheduleReconcile.mockClear() + + store.update({ runtimeStatusByEnvironmentId: runtimeStatus([], 2) }) + expect(scheduleReconcile).not.toHaveBeenCalled() + store.update({ + runtimeStatusByEnvironmentId: runtimeStatus( + [AI_VAULT_SESSION_TITLE_REFRESH_RUNTIME_CAPABILITY], + 3 + ) + }) + expect(scheduleReconcile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-freshness.ts b/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-freshness.ts index 62bbd252d87..a21c41c61e1 100644 --- a/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-freshness.ts +++ b/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-freshness.ts @@ -21,15 +21,39 @@ export function supportsSessionTitleRefresh( const host = request.executionHostId return ( host === 'local' || - (host.startsWith('runtime:') && - Boolean( - lastVerifiedRuntimeStatus( - state.runtimeStatusByEnvironmentId?.get(host.slice(8)) - )?.capabilities?.includes(AI_VAULT_SESSION_TITLE_REFRESH_RUNTIME_CAPABILITY) - )) + (host.startsWith('runtime:') && runtimeSupportsSessionTitleRefresh(state, host.slice(8))) ) } +function runtimeSupportsSessionTitleRefresh(state: AppState, environmentId: string): boolean { + return Boolean( + lastVerifiedRuntimeStatus( + state.runtimeStatusByEnvironmentId?.get(environmentId) + )?.capabilities?.includes(AI_VAULT_SESSION_TITLE_REFRESH_RUNTIME_CAPABILITY) + ) +} + +/** Execution hosts whose title-refresh support flipped. Host-status pings arrive constantly, so callers check this + * before paying for a full tab/conversation catalog walk. */ +export function changedTitleRefreshHosts(state: AppState, previous: AppState): Set { + const changed = new Set() + const current = state.runtimeStatusByEnvironmentId + const prior = previous.runtimeStatusByEnvironmentId + if (current === prior) { + return changed + } + for (const environmentId of new Set([...(current?.keys() ?? []), ...(prior?.keys() ?? [])])) { + if ( + current?.get(environmentId) !== prior?.get(environmentId) && + runtimeSupportsSessionTitleRefresh(state, environmentId) !== + runtimeSupportsSessionTitleRefresh(previous, environmentId) + ) { + changed.add(`runtime:${environmentId}`) + } + } + return changed +} + export function titleRefreshInterval(state: AppState, request: AiVaultTitleRequest): number { const stored = state.tabsByWorktree[request.worktreeId]?.find( (tab) => tab.id === request.tabId diff --git a/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-sync.ts b/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-sync.ts index 8142091f4ba..aaec1fc20bc 100644 --- a/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-sync.ts +++ b/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-sync.ts @@ -14,6 +14,7 @@ import { aiVaultTitleSyncInputsChanged } from './ai-vault-tab-title-sync-inputs' import { aiVaultTitleRequestKey, changedOwningPaneTitleRequests, + changedTitleRefreshHosts, titleRefreshInterval, supportsSessionTitleRefresh } from './ai-vault-tab-title-freshness' @@ -239,15 +240,18 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () => dirty.add(key) } } + if (dirty.size > 0 || aiVaultTitleSyncInputsChanged(state, previous)) { + schedule() + return + } + // Why: host-status pings are frequent and usually leave capabilities alone; walking every saved tab on each + // one stalled typing in large workspaces, so only collect when some host's title-refresh support flipped. + const changedHosts = changedTitleRefreshHosts(state, previous) if ( - dirty.size > 0 || - (state.runtimeStatusByEnvironmentId !== previous.runtimeStatusByEnvironmentId && - collectAiVaultTitleRequests(state).some( - (request) => - supportsSessionTitleRefresh(state, request) !== - supportsSessionTitleRefresh(previous, request) - )) || - aiVaultTitleSyncInputsChanged(state, previous) + changedHosts.size > 0 && + collectAiVaultTitleRequests(state).some((request) => + changedHosts.has(request.executionHostId) + ) ) { schedule() } diff --git a/src/shared/remote-runtime/host-store/runtime-environment-sidecar.ts b/src/shared/remote-runtime/host-store/runtime-environment-sidecar.ts index bba12eb0e70..5ca6e3d0c6a 100644 --- a/src/shared/remote-runtime/host-store/runtime-environment-sidecar.ts +++ b/src/shared/remote-runtime/host-store/runtime-environment-sidecar.ts @@ -19,7 +19,7 @@ import { hardenExistingSecureFile } from '../../secure-file' // Shipped builds strip unknown environment fields on routine writes; preserve newer state in this untouched sidecar. const SIDECAR_FILE = 'orca-environment-sidecar.json' -const MAX_SIDECAR_FILE_BYTES = 1024 * 1024 +export const MAX_SIDECAR_FILE_BYTES = 1024 * 1024 const SidecarBindingSchema = z.object({ createdAt: z.number().finite(), @@ -61,7 +61,7 @@ const SidecarEnvelopeSchema = z.object({ }) export type RuntimeEnvironmentSidecarEntry = z.infer -type RuntimeEnvironmentSidecar = z.infer & { +export type RuntimeEnvironmentSidecar = z.infer & { /** Entries this build cannot read, kept verbatim; their environments must not be used. */ unreadable: Record } @@ -95,12 +95,24 @@ export function readRuntimeEnvironmentSidecar(userDataPath: string): RuntimeEnvi if (!existsSync(path)) { return { version: 1, entries: {}, unreadable: {} } } - let raw: Record + let text: string try { hardenExistingSecureFile(path) - raw = SidecarEnvelopeSchema.parse( - JSON.parse(readNodeFileSyncWithinLimit(path, MAX_SIDECAR_FILE_BYTES).buffer.toString('utf8')) - ).entries + text = readNodeFileSyncWithinLimit(path, MAX_SIDECAR_FILE_BYTES).buffer.toString('utf8') + } catch { + throw new RuntimeEnvironmentSidecarInvalidError(path) + } + return parseRuntimeEnvironmentSidecarText(path, text) +} + +/** Parses bytes already read from `path`; the error matches a failed read of that file. */ +export function parseRuntimeEnvironmentSidecarText( + path: string, + text: string +): RuntimeEnvironmentSidecar { + let raw: Record + try { + raw = SidecarEnvelopeSchema.parse(JSON.parse(text)).entries } catch { throw new RuntimeEnvironmentSidecarInvalidError(path) } diff --git a/src/shared/remote-runtime/host-store/runtime-environment-store-file.ts b/src/shared/remote-runtime/host-store/runtime-environment-store-file.ts index ad52fb4c396..663279ce23f 100644 --- a/src/shared/remote-runtime/host-store/runtime-environment-store-file.ts +++ b/src/shared/remote-runtime/host-store/runtime-environment-store-file.ts @@ -9,33 +9,50 @@ import { type RuntimeEnvironmentStore } from '../host-catalog/runtime-environments' import { + getRuntimeEnvironmentSidecarPath, + MAX_SIDECAR_FILE_BYTES, overlayRuntimeEnvironmentSidecar, + parseRuntimeEnvironmentSidecarText, readRuntimeEnvironmentSidecar, + type RuntimeEnvironmentSidecar, RuntimeEnvironmentSidecarInvalidError } from './runtime-environment-sidecar' import { getEnvironmentStorePath, MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES, + parseEnvironmentStoreRecordsText, readEnvironmentStoreRecords, backUpBlankEnvironmentStore, remoteServerPairingStoreError, RuntimeEnvironmentStoreError, + type EnvironmentStoreRecords, type EnvironmentStoreRecordsReadOptions, warnUnreadableEnvironmentState } from './runtime-environment-store-records' import { + getWslHostDeploymentStorePath, + MAX_WSL_HOST_DEPLOYMENTS_FILE_BYTES, overlayWslHostDeployments, + parseWslHostDeploymentsText, readWslHostDeployments, + type WslHostDeployments, WslHostDeploymentStoreInvalidError } from './wsl-host-deployment-store' import { isWslHostsServing } from '../../wsl-host-internal-gate' +import { createSecureFileContentCache } from './runtime-environment-store-read-cache' /** Readable records only; writeEnvironmentStore preserves unreadable corrupt/newer records verbatim. */ export function readPersistedEnvironmentStore( userDataPath: string, options: EnvironmentStoreRecordsReadOptions = {} ): RuntimeEnvironmentStore { - const { environments, unreadable } = readEnvironmentStoreRecords(userDataPath, options) + return persistedStoreFromRecords(userDataPath, readEnvironmentStoreRecords(userDataPath, options)) +} + +function persistedStoreFromRecords( + userDataPath: string, + { environments, unreadable }: EnvironmentStoreRecords +): RuntimeEnvironmentStore { if (unreadable.length > 0) { warnUnreadableEnvironmentState( `Skipping ${unreadable.length} unreadable server record(s) in ${getEnvironmentStorePath(userDataPath)} (ids: ${unreadable.map((record) => record.id ?? '').join(', ')}); they may need a newer version of Orca and are kept on disk.` @@ -53,11 +70,11 @@ export function readPersistedEnvironmentStore( * it, so a newer build's file is kept as it is. With hosts off it hides nothing. */ function readWslHostDeploymentsForRegistry( - userDataPath: string -): ReturnType { + readDeployments: () => WslHostDeployments +): WslHostDeployments { const gateOpen = isWslHostsServing(process.env) try { - const read = readWslHostDeployments(userDataPath) + const read = readDeployments() return gateOpen ? read : { deployments: read.deployments, unreadableEnvironmentIds: new Set() } } catch (error) { if (!(error instanceof WslHostDeploymentStoreInvalidError)) { @@ -68,19 +85,78 @@ function readWslHostDeploymentsForRegistry( } } +type MergedEnvironmentStore = { + version: 1 + environments: KnownRuntimeEnvironment[] +} + +const mergedStoreCache = createSecureFileContentCache() + export function readEnvironmentStore( userDataPath: string, options: EnvironmentStoreRecordsReadOptions = {} -): { - version: 1 - environments: KnownRuntimeEnvironment[] -} { - const store = readPersistedEnvironmentStore(userDataPath, options) - let sidecar: ReturnType - let wslDeployments: ReturnType +): MergedEnvironmentStore { + // Windows hits must re-verify ACLs anyway, which costs as much as the read it would skip (measured). + if (options.requireStoreFile || options.recoverBlankStore || process.platform === 'win32') { + return readEnvironmentStoreUncached(userDataPath, options) + } + const storePath = getEnvironmentStorePath(userDataPath) + const sidecarPath = getRuntimeEnvironmentSidecarPath(userDataPath) + const wslPath = getWslHostDeploymentStorePath(userDataPath) + return mergedStoreCache.read( + `${userDataPath}\0${isWslHostsServing(process.env)}`, + userDataPath, + [ + { path: storePath, maxBytes: MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES }, + { path: sidecarPath, maxBytes: MAX_SIDECAR_FILE_BYTES }, + { path: wslPath, maxBytes: MAX_WSL_HOST_DEPLOYMENTS_FILE_BYTES } + ], + ([storeText, sidecarText, wslText]) => + mergeEnvironmentStore(userDataPath, { + records: () => + storeText == null + ? { environments: [], unreadable: [] } + : parseEnvironmentStoreRecordsText(storePath, storeText), + sidecar: () => + sidecarText == null + ? { version: 1, entries: {}, unreadable: {} } + : parseRuntimeEnvironmentSidecarText(sidecarPath, sidecarText), + wslDeployments: () => + wslText == null + ? { deployments: [], unreadableEnvironmentIds: new Set() } + : parseWslHostDeploymentsText(wslPath, wslText) + }), + () => readEnvironmentStoreUncached(userDataPath, options) + ) +} + +type EnvironmentStoreSources = { + records: () => EnvironmentStoreRecords + sidecar: () => RuntimeEnvironmentSidecar + wslDeployments: () => WslHostDeployments +} + +function readEnvironmentStoreUncached( + userDataPath: string, + options: EnvironmentStoreRecordsReadOptions +): MergedEnvironmentStore { + return mergeEnvironmentStore(userDataPath, { + records: () => readEnvironmentStoreRecords(userDataPath, options), + sidecar: () => readRuntimeEnvironmentSidecar(userDataPath), + wslDeployments: () => readWslHostDeployments(userDataPath) + }) +} + +function mergeEnvironmentStore( + userDataPath: string, + sources: EnvironmentStoreSources +): MergedEnvironmentStore { + const store = persistedStoreFromRecords(userDataPath, sources.records()) + let sidecar: RuntimeEnvironmentSidecar + let wslDeployments: WslHostDeployments try { - sidecar = readRuntimeEnvironmentSidecar(userDataPath) - wslDeployments = readWslHostDeploymentsForRegistry(userDataPath) + sidecar = sources.sidecar() + wslDeployments = readWslHostDeploymentsForRegistry(sources.wslDeployments) } catch (error) { // Fail closed like the main file: a link we cannot read may pin a host key we must not drop. if ( diff --git a/src/shared/remote-runtime/host-store/runtime-environment-store-read-cache-integration.test.ts b/src/shared/remote-runtime/host-store/runtime-environment-store-read-cache-integration.test.ts new file mode 100644 index 00000000000..b4e74178112 --- /dev/null +++ b/src/shared/remote-runtime/host-store/runtime-environment-store-read-cache-integration.test.ts @@ -0,0 +1,138 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../pairing/pairing' +import type * as BoundedReader from '../../node-bounded-file-reader' +import { __resetSecureFileHardenedPathsForTests } from '../../secure-file' +import { addEnvironmentFromPairingCode, listEnvironments } from './runtime-environment-store' +import { getEnvironmentStorePath } from './runtime-environment-store-records' + +type ReadHook = ((path: string) => void) | undefined +const hooks = vi.hoisted((): { failChmod: boolean; beforeRead: ReadHook; afterRead: ReadHook } => ({ + failChmod: false, + beforeRead: undefined, + afterRead: undefined +})) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + chmodSync: (...args: Parameters) => { + if (hooks.failChmod) { + throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) + } + return actual.chmodSync(...args) + } + } +}) +vi.mock('../../node-bounded-file-reader', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readNodeFileSyncWithinLimit: ( + ...args: Parameters + ) => { + hooks.beforeRead?.(args[0]) + try { + return actual.readNodeFileSyncWithinLimit(...args) + } finally { + hooks.afterRead?.(args[0]) + } + } + } +}) + +let root: string +let userDataPath: string +beforeEach(() => { + root = fs.mkdtempSync(join(tmpdir(), 'orca-env-cache-integration-')) + userDataPath = join(root, 'userData') + fs.mkdirSync(userDataPath, { mode: 0o700 }) + __resetSecureFileHardenedPathsForTests() +}) +afterEach(() => { + hooks.failChmod = false + hooks.beforeRead = undefined + hooks.afterRead = undefined + fs.rmSync(root, { recursive: true, force: true }) +}) + +function addServer(profile: string, name: string, seed: number): string { + return addEnvironmentFromPairingCode(profile, { + name, + pairingCode: encodePairingOffer({ + v: 2, + publicKeyB64: Buffer.alloc(32, seed).toString('base64'), + deviceToken: `token-${seed}`, + endpoint: `wss://host-${seed}.example.com/runtime` + }), + now: 100 + seed + }).id +} + +const names = (): string[] => listEnvironments(userDataPath).map((entry) => entry.name) + +describe('saved-server registry read reuse', () => { + it('sees a registry rewritten by another writer between reads', () => { + addServer(userDataPath, 'Alpha', 1) + expect(names()).toEqual(['Alpha']) + expect(names()).toEqual(['Alpha']) + + const path = getEnvironmentStorePath(userDataPath) + const store = JSON.parse(fs.readFileSync(path, 'utf8')) + store.environments[0].name = 'Bravo' + fs.writeFileSync(path, JSON.stringify(store), { mode: 0o600 }) + + expect(names()).toEqual(['Bravo']) + }) + + it('does not keep a value read from a directory swapped in and back out mid-read', () => { + addServer(userDataPath, 'Alpha', 1) + const other = join(root, 'other') + fs.mkdirSync(other, { mode: 0o700 }) + addServer(other, 'Bravo', 2) + const parked = join(root, 'parked') + const storePath = getEnvironmentStorePath(userDataPath) + let storeReads = 0 + hooks.beforeRead = (path) => { + // The first read checks for a reusable value; the second is the one a value is built from. + if (path === storePath && ++storeReads === 2) { + fs.renameSync(userDataPath, parked) + fs.renameSync(other, userDataPath) + } + } + hooks.afterRead = (path) => { + if (path === storePath && fs.existsSync(parked)) { + fs.renameSync(userDataPath, other) + fs.renameSync(parked, userDataPath) + } + } + // The racing read may report what it saw, but must not be reused once the original is back. + names() + hooks.beforeRead = undefined + hooks.afterRead = undefined + expect(names()).toEqual(['Alpha']) + expect(names()).toEqual(['Alpha']) + }) + + it.skipIf(process.platform === 'win32').each([ + ['readable by others', 0o644], + ['owner read-only', 0o400], + ['setuid', 0o4600] + ])('retries hardening a registry left %s by a transient chmod failure', (_label, mode) => { + addServer(userDataPath, 'Alpha', 1) + const path = getEnvironmentStorePath(userDataPath) + fs.chmodSync(path, mode) + __resetSecureFileHardenedPathsForTests() + + hooks.failChmod = true + names() + names() + expect(fs.statSync(path).mode & 0o7777).toBe(mode) + + hooks.failChmod = false + names() + expect(fs.statSync(path).mode & 0o7777).toBe(0o600) + }) +}) diff --git a/src/shared/remote-runtime/host-store/runtime-environment-store-read-cache.test.ts b/src/shared/remote-runtime/host-store/runtime-environment-store-read-cache.test.ts new file mode 100644 index 00000000000..42a502bdaf9 --- /dev/null +++ b/src/shared/remote-runtime/host-store/runtime-environment-store-read-cache.test.ts @@ -0,0 +1,60 @@ +import { 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 { createSecureFileContentCache } from './runtime-environment-store-read-cache' + +let dir: string +let files: { path: string; maxBytes: number }[] +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-env-cache-')) + files = [ + { path: join(dir, 'store.json'), maxBytes: 1024 }, + { path: join(dir, 'sidecar.json'), maxBytes: 1024 } + ] + writeFileSync(files[0]!.path, '{"a":1}', { mode: 0o600 }) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +// Windows never reuses reads (ACLs are invisible to the snapshot), so there is no cache to exercise. +describe.skipIf(process.platform === 'win32')('secure file content cache', () => { + it('computes from the bytes it keys on and reuses them only while they are unchanged', () => { + const cache = createSecureFileContentCache<{ texts: (string | null)[] }>() + const compute = vi.fn((texts: (string | null)[]) => ({ texts })) + const fallback = vi.fn(() => ({ texts: [] })) + const read = () => cache.read(dir, dir, files, compute, fallback) + + read().texts.push('mutated') + expect(read()).toEqual({ texts: ['{"a":1}', null] }) + expect(compute).toHaveBeenCalledTimes(1) + + // Same size, new bytes: what a stat signature on a coarse-timestamp filesystem could miss. + writeFileSync(files[0]!.path, '{"a":2}') + expect(read()).toEqual({ texts: ['{"a":2}', null] }) + writeFileSync(files[1]!.path, '{}', { mode: 0o600 }) + expect(read()).toEqual({ texts: ['{"a":2}', '{}'] }) + expect(read()).toEqual({ texts: ['{"a":2}', '{}'] }) + expect(compute).toHaveBeenCalledTimes(3) + expect(fallback).not.toHaveBeenCalled() + }) + + it('never caches a failed compute and uses the plain reader for an unreadable file', () => { + const cache = createSecureFileContentCache() + const compute = vi + .fn<() => number>() + .mockImplementationOnce(() => { + throw new Error('invalid') + }) + .mockReturnValue(1) + const fallback = vi.fn(() => 2) + expect(() => cache.read(dir, dir, files, compute, fallback)).toThrow('invalid') + expect(cache.read(dir, dir, files, compute, fallback)).toBe(1) + + writeFileSync(files[0]!.path, 'x'.repeat(2048)) + expect(cache.read(dir, dir, files, compute, fallback)).toBe(2) + expect(cache.read(dir, dir, files, compute, fallback)).toBe(2) + expect(compute).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/shared/remote-runtime/host-store/runtime-environment-store-read-cache.ts b/src/shared/remote-runtime/host-store/runtime-environment-store-read-cache.ts new file mode 100644 index 00000000000..b514557fc55 --- /dev/null +++ b/src/shared/remote-runtime/host-store/runtime-environment-store-read-cache.ts @@ -0,0 +1,122 @@ +import { existsSync, statSync } from 'node:fs' +import { hardenExistingSecureFile } from '../../secure-file' +import { readNodeFileSyncWithinLimit } from '../../node-bounded-file-reader' + +// Why: remote RPC, tunnel and worker-server checks resolve the saved-server registry many times per second; +// rechecking, rehardening, reparsing and revalidating unchanged files each time blocked the Electron main thread. +// A cached value is computed from exactly the bytes it is keyed on, so it can never differ from what those +// bytes parse to, however the files or their directory are swapped around the read. +const MAX_CACHED_KEYS = 8 +const PROTECTED_FILE_MODE = 0o600 +const PROTECTED_DIRECTORY_MODE = 0o700 + +export type CachedFile = { path: string; maxBytes: number } + +type Snapshot = { directoryMode: number | null; files: ({ bytes: Buffer; mode: number } | null)[] } + +function isMissing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +function snapshot(directory: string, files: readonly CachedFile[]): Snapshot { + return { + directoryMode: statSync(directory, { throwIfNoEntry: false })?.mode ?? null, + files: files.map(({ path, maxBytes }) => { + try { + const { buffer, stats } = readNodeFileSyncWithinLimit(path, maxBytes) + return { bytes: buffer, mode: stats.mode } + } catch (error) { + if (isMissing(error)) { + return null + } + throw error + } + }) + } +} + +function sameBytes(a: Snapshot, b: Snapshot): boolean { + return a.files.every((file, index) => { + const other = b.files[index] + return file && other ? file.bytes.equals(other.bytes) : file === other + }) +} + +function samePresence(a: Snapshot, b: Snapshot): boolean { + return a.files.every((file, index) => (file === null) === (b.files[index] === null)) +} + +/** + * Readers re-harden on every read and retry after a failed chmod; reuse is allowed only while the modes they + * set are visibly in place (POSIX), so any drift or earlier failure is repaired by the next uncached read. + */ +function isProtected(snapshot: Snapshot): boolean { + // Windows protection lives in ACLs the snapshot cannot see; never claim it is in place. + if (process.platform === 'win32') { + return false + } + return ( + (snapshot.directoryMode === null || + (snapshot.directoryMode & 0o7777) === PROTECTED_DIRECTORY_MODE) && + snapshot.files.every((file) => file === null || (file.mode & 0o7777) === PROTECTED_FILE_MODE) + ) +} + +export type SecureFileContentCache = { + /** + * `compute` parses the given file texts (null = missing). `fallback` is the plain reader; it runs whenever + * the files cannot be snapshotted or hardened, so errors surface exactly as they did without the cache. + */ + read: ( + key: string, + directory: string, + files: readonly CachedFile[], + compute: (texts: (string | null)[]) => T, + fallback: () => T + ) => T +} + +export function createSecureFileContentCache(): SecureFileContentCache { + const cache = new Map() + return { + read(key, directory, files, compute, fallback) { + let current: Snapshot + try { + current = snapshot(directory, files) + } catch { + cache.delete(key) + return fallback() + } + const cached = cache.get(key) + if (cached && sameBytes(cached.snapshot, current) && isProtected(current)) { + // Callers own their copy; one mutating a result must not leak into the next read. + return structuredClone(cached.value) + } + cache.delete(key) + let read: Snapshot + try { + // Same hardening the readers do before reading a present file. + for (const file of files) { + if (existsSync(file.path)) { + hardenExistingSecureFile(file.path) + } + } + read = snapshot(directory, files) + } catch { + return fallback() + } + if (!samePresence(current, read)) { + // A file appeared or vanished mid-read; let the plain reader handle that state. + return fallback() + } + const value = compute(read.files.map((file) => file?.bytes.toString('utf8') ?? null)) + if (isProtected(read)) { + cache.set(key, { snapshot: read, value: structuredClone(value) }) + if (cache.size > MAX_CACHED_KEYS) { + cache.delete(cache.keys().next().value!) + } + } + return value + } + } +} diff --git a/src/shared/remote-runtime/host-store/runtime-environment-store-records.ts b/src/shared/remote-runtime/host-store/runtime-environment-store-records.ts index 7393ee5e4ac..730d7629ccb 100644 --- a/src/shared/remote-runtime/host-store/runtime-environment-store-records.ts +++ b/src/shared/remote-runtime/host-store/runtime-environment-store-records.ts @@ -104,22 +104,39 @@ export function readEnvironmentStoreRecords( if (!options.requireStoreFile && !existsSync(path)) { return { environments: [], unreadable: [] } } - let records: unknown[] + let text: string try { hardenExistingSecureFile(path) - const text = readNodeFileSyncWithinLimit( + text = readNodeFileSyncWithinLimit( path, MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES ).buffer.toString('utf8') - if (options.recoverBlankStore && /^[\s\0]*$/.test(text)) { - return { environments: [], unreadable: [], blankFile: true } - } + } catch { + throw invalidEnvironmentStoreError(path) + } + if (options.recoverBlankStore && /^[\s\0]*$/.test(text)) { + return { environments: [], unreadable: [], blankFile: true } + } + return parseEnvironmentStoreRecordsText(path, text) +} + +function invalidEnvironmentStoreError(path: string): RuntimeEnvironmentStoreError { + return new RuntimeEnvironmentStoreError( + 'runtime_error', + `Could not read Orca environments at ${path}; the file is invalid.` + ) +} + +/** Parses bytes already read from `path`; the error matches a failed read of that file. */ +export function parseEnvironmentStoreRecordsText( + path: string, + text: string +): EnvironmentStoreRecords { + let records: unknown[] + try { records = StoreEnvelopeSchema.parse(JSON.parse(text)).environments } catch { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - `Could not read Orca environments at ${path}; the file is invalid.` - ) + throw invalidEnvironmentStoreError(path) } const result: EnvironmentStoreRecords = { environments: [], unreadable: [] } for (const raw of records) { @@ -133,7 +150,6 @@ export function readEnvironmentStoreRecords( return result } -/** Copies (never moves) a blank store aside before a write replaces it, so readers never see the name missing. */ export function backUpBlankEnvironmentStore(userDataPath: string): void { const path = getEnvironmentStorePath(userDataPath) const backupPath = `${path}.corrupt-${Date.now()}-${process.pid}` diff --git a/src/shared/remote-runtime/host-store/wsl-host-deployment-store.ts b/src/shared/remote-runtime/host-store/wsl-host-deployment-store.ts index e59373d2813..721efc92f09 100644 --- a/src/shared/remote-runtime/host-store/wsl-host-deployment-store.ts +++ b/src/shared/remote-runtime/host-store/wsl-host-deployment-store.ts @@ -15,7 +15,7 @@ import { OrcadWslDeploymentLinkSchema } from '../host-catalog/wsl-host-deploymen // Isolate WSL records so older builds cannot reject the entire pairing store. const WSL_HOST_DEPLOYMENTS_FILE = 'orca-wsl-host-deployments.json' -const MAX_WSL_HOST_DEPLOYMENTS_FILE_BYTES = 256 * 1024 +export const MAX_WSL_HOST_DEPLOYMENTS_FILE_BYTES = 256 * 1024 /** A managed orcad in a WSL distro. The environment's name is its label; runtime state lives elsewhere. */ /** A Remove that could not prove the host's server stopped (E11); the host stays until one does. */ @@ -94,16 +94,22 @@ function readStoredRecords(userDataPath: string): StoredRecord[] { if (!existsSync(path)) { return [] } - let raws: unknown[] + let text: string try { hardenExistingSecureFile(path) - raws = EnvelopeSchema.parse( - JSON.parse( - readNodeFileSyncWithinLimit(path, MAX_WSL_HOST_DEPLOYMENTS_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) - ).deployments + text = readNodeFileSyncWithinLimit(path, MAX_WSL_HOST_DEPLOYMENTS_FILE_BYTES).buffer.toString( + 'utf8' + ) + } catch { + throw new WslHostDeploymentStoreInvalidError(path) + } + return parseStoredRecordsText(path, text) +} + +function parseStoredRecordsText(path: string, text: string): StoredRecord[] { + let raws: unknown[] + try { + raws = EnvelopeSchema.parse(JSON.parse(text)).deployments } catch { throw new WslHostDeploymentStoreInvalidError(path) } @@ -131,8 +137,17 @@ function readStoredRecords(userDataPath: string): StoredRecord[] { /** Throws WslHostDeploymentStoreInvalidError when the file itself is unreadable. */ export function readWslHostDeployments(userDataPath: string): WslHostDeployments { + return deploymentsFromRecords(readStoredRecords(userDataPath)) +} + +/** Parses bytes already read from `path`; throws WslHostDeploymentStoreInvalidError like a failed read. */ +export function parseWslHostDeploymentsText(path: string, text: string): WslHostDeployments { + return deploymentsFromRecords(parseStoredRecordsText(path, text)) +} + +function deploymentsFromRecords(records: StoredRecord[]): WslHostDeployments { const result: WslHostDeployments = { deployments: [], unreadableEnvironmentIds: new Set() } - for (const record of readStoredRecords(userDataPath)) { + for (const record of records) { if (record.deployment) { result.deployments.push(record.deployment) } else if (record.environmentId !== undefined) {