mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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.
This commit is contained in:
+65
@@ -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<typeof TitleRequests>()
|
||||
return { ...actual, collectAiVaultTitleRequests: vi.fn(actual.collectAiVaultTitleRequests) }
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/mounted-terminal-tab-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MountedTerminalTabRegistry>()),
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+30
-6
@@ -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<string> {
|
||||
const changed = new Set<string>()
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<typeof SidecarEntrySchema>
|
||||
type RuntimeEnvironmentSidecar = z.infer<typeof SidecarSchema> & {
|
||||
export type RuntimeEnvironmentSidecar = z.infer<typeof SidecarSchema> & {
|
||||
/** Entries this build cannot read, kept verbatim; their environments must not be used. */
|
||||
unreadable: Record<string, unknown>
|
||||
}
|
||||
@@ -95,12 +95,24 @@ export function readRuntimeEnvironmentSidecar(userDataPath: string): RuntimeEnvi
|
||||
if (!existsSync(path)) {
|
||||
return { version: 1, entries: {}, unreadable: {} }
|
||||
}
|
||||
let raw: Record<string, unknown>
|
||||
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<string, unknown>
|
||||
try {
|
||||
raw = SidecarEnvelopeSchema.parse(JSON.parse(text)).entries
|
||||
} catch {
|
||||
throw new RuntimeEnvironmentSidecarInvalidError(path)
|
||||
}
|
||||
|
||||
@@ -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 ?? '<none>').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<typeof readWslHostDeployments> {
|
||||
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<MergedEnvironmentStore>()
|
||||
|
||||
export function readEnvironmentStore(
|
||||
userDataPath: string,
|
||||
options: EnvironmentStoreRecordsReadOptions = {}
|
||||
): {
|
||||
version: 1
|
||||
environments: KnownRuntimeEnvironment[]
|
||||
} {
|
||||
const store = readPersistedEnvironmentStore(userDataPath, options)
|
||||
let sidecar: ReturnType<typeof readRuntimeEnvironmentSidecar>
|
||||
let wslDeployments: ReturnType<typeof readWslHostDeployments>
|
||||
): 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 (
|
||||
|
||||
+138
@@ -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<typeof fs>()
|
||||
return {
|
||||
...actual,
|
||||
chmodSync: (...args: Parameters<typeof actual.chmodSync>) => {
|
||||
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<typeof BoundedReader>()
|
||||
return {
|
||||
...actual,
|
||||
readNodeFileSyncWithinLimit: (
|
||||
...args: Parameters<typeof actual.readNodeFileSyncWithinLimit>
|
||||
) => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<number>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<T> = {
|
||||
/**
|
||||
* `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<T>(): SecureFileContentCache<T> {
|
||||
const cache = new Map<string, { snapshot: Snapshot; value: T }>()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user