From 44f55d5cd0ff8b685a371bdc02366aae53a82a31 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:10:22 -0700 Subject: [PATCH] perf(fish): cache startup environment snapshot --- src/main/pty/shell-startup-env.test.ts | 69 ++++++++++++++++++++++++++ src/main/pty/shell-startup-env.ts | 55 ++++++++++++++++++-- 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts index eaf8cffeba4..bd39df8fe5d 100644 --- a/src/main/pty/shell-startup-env.test.ts +++ b/src/main/pty/shell-startup-env.test.ts @@ -387,6 +387,75 @@ describe('readShellStartupEnvVar', () => { expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/zzz') }) + it('reads startup files once for agent homes without caching unrelated exports', () => { + mockFishFiles( + { + '/home/alice/.config/fish/conf.d/10-agents.fish': + 'set -gx CODEX_HOME /from/confd\nset -gx UNQUERIED_SECRET old-secret\n', + '/home/alice/.config/fish/config.fish': + 'set -gx CODEX_HOME /from/config\nset -gx OPENCODE_CONFIG_DIR /from/config-opencode\n' + }, + ['10-agents.fish'] + ) + + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/config') + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', FISH)).toBe( + '/from/config-opencode' + ) + expect(readdirSyncMock).toHaveBeenCalledTimes(1) + expect(readFileSyncMock).toHaveBeenCalledTimes(2) + + mockFishFiles( + { + '/home/alice/.config/fish/conf.d/10-agents.fish': 'set -gx UNQUERIED_SECRET new-secret\n', + '/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /changed\n' + }, + ['10-agents.fish'] + ) + expect(readShellStartupEnvVar('UNQUERIED_SECRET', '/home/alice', FISH)).toBe('new-secret') + expect(readdirSyncMock).toHaveBeenCalledTimes(2) + expect(readFileSyncMock).toHaveBeenCalledTimes(4) + }) + + it('isolates snapshots for distinct startup contexts', () => { + readdirSyncMock.mockReturnValue([]) + mockStartupFiles({ + '/cfg-a/fish/config.fish': + 'set -gx CODEX_HOME /from/a\nset -gx OPENCODE_CONFIG_DIR /opencode/a\n', + '/cfg-b/fish/config.fish': + 'set -gx CODEX_HOME /from/b\nset -gx OPENCODE_CONFIG_DIR /opencode/b\n' + }) + + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, '/cfg-a')).toBe('/from/a') + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, '/cfg-b')).toBe('/from/b') + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', FISH, '/cfg-a')).toBe( + '/opencode/a' + ) + expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', FISH, '/cfg-b')).toBe( + '/opencode/b' + ) + expect(readdirSyncMock).toHaveBeenCalledTimes(2) + expect(readFileSyncMock).toHaveBeenCalledTimes(2) + }) + + it('evicts the oldest Fish startup context after the bounded cache fills', () => { + readdirSyncMock.mockReturnValue([]) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockImplementation( + (path: string) => `set -gx CODEX_HOME ${path.replace('/fish/config.fish', '')}\n` + ) + + for (let index = 0; index < 33; index += 1) { + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, `/cfg-${index}`)).toBe( + `/cfg-${index}` + ) + } + expect(readFileSyncMock).toHaveBeenCalledTimes(33) + + expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, '/cfg-0')).toBe('/cfg-0') + expect(readFileSyncMock).toHaveBeenCalledTimes(34) + }) + it('honors XDG_CONFIG_HOME', () => { mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/xdg\n' }) expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, '/cfg')).toBe('/from/xdg') diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts index ad6392160a9..4c7f758ced3 100644 --- a/src/main/pty/shell-startup-env.ts +++ b/src/main/pty/shell-startup-env.ts @@ -1,5 +1,6 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { posix } from 'node:path' +import { BoundedMap } from '../../shared/bounded-map' // Why: only files the user's actual shell would source. Mixing zsh and bash // files breaks the "last assignment wins matches the live shell" guarantee — @@ -16,6 +17,14 @@ const BASH_LOGIN_FILES = ['.bash_profile', '.bash_login', '.profile'] const FISH_SNIPPET_DIR = 'conf.d' const FISH_SNIPPET_SUFFIX = '.fish' const FISH_CONFIG_FILE = 'config.fish' +const FISH_AGENT_PATH_CONTEXT_LIMIT = 32 +const FISH_AGENT_PATH_NAMES = new Set([ + 'CODEX_HOME', + 'GROK_HOME', + 'OPENCODE_CONFIG_DIR', + 'PI_CODING_AGENT_DIR', + 'PRIME_AGENT_CODING_AGENT_DIR' +]) /** Assignment grammar of a startup file: `export NAME=value` vs fish `set -gx NAME value`. */ type StartupFileSyntax = 'export' | 'fish-set' @@ -194,6 +203,37 @@ function expandHome(value: string, home: string): string { } const cache = new Map() +const fishAgentPathCache = new BoundedMap>({ + maxEntries: FISH_AGENT_PATH_CONTEXT_LIMIT +}) + +function readFishAgentPaths( + home: string, + shell: string, + configHome: string | undefined +): ReadonlyMap { + const cacheKey = `${home}\0${shell}\0${configHome ?? ''}` + const cached = fishAgentPathCache.get(cacheKey) + if (cached) { + return cached + } + + const values = new Map() + for (const path of fishStartupFilePaths(home, configHome)) { + const content = readStartupFile(path) + if (content === null) { + continue + } + for (const name of FISH_AGENT_PATH_NAMES) { + const match = parseAssignedValue(content, name, home, 'fish-set') + if (match !== undefined) { + values.set(name, match) + } + } + } + fishAgentPathCache.set(cacheKey, values) + return values +} /** * Best-effort static read of a single env-var assignment from the user's @@ -214,11 +254,13 @@ const cache = new Map() * nothing. LAST matching assignment wins. * - fish universal variables (`set -Ux` stored in fish_variables) are only * seen when the assignment is also written in a config file. + * - Fish shares one scan only for the agent-path variables Orca consumes; + * arbitrary requested names retain the generic per-name behavior. * - Windows is unsupported (PowerShell profile parsing is out of scope). * - * Results are memoized per (name, home, shell, configHome) for the process - * lifetime — shell startup files do not change mid-session in any practical - * scenario, and PTY spawn is on the hot path. + * Results are memoized per variable for the process lifetime. Fish caches the + * Orca-consumed set in bounded startup contexts, so those lookups avoid later + * filesystem I/O without retaining unrelated exports or source text. */ export function readShellStartupEnvVar( name: string, @@ -235,20 +277,22 @@ export function readShellStartupEnvVar( return undefined } + if (shell && posix.basename(shell).toLowerCase() === 'fish' && FISH_AGENT_PATH_NAMES.has(name)) { + return readFishAgentPaths(home, shell, configHome).get(name) + } + const cacheKey = `${name}\0${home}\0${shell ?? ''}\0${configHome ?? ''}` if (cache.has(cacheKey)) { return cache.get(cacheKey) } let lastMatch: string | undefined - const { paths, syntax } = shellStartupFiles(home, shell, configHome) for (const path of paths) { const content = readStartupFile(path) if (content === null) { continue } - const match = parseAssignedValue(content, name, home, syntax) if (match !== undefined) { lastMatch = match @@ -294,4 +338,5 @@ export function readSessionShellStartupEnvVar( */ export function __resetShellStartupEnvCache(): void { cache.clear() + fishAgentPathCache.clear() }