diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index af50e7a1..b747847a 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -6,6 +6,36 @@ const createCommandTimeoutMs = () => { return 5 * 60 * 1000; }; +// How long a cached git-read result stays fresh. The location of a repo's git +// directory is effectively static while the app runs, so a short TTL safely +// absorbs the burst of identical lookups a fresh client (e.g. right after a +// page reload) fires for every project. Set to 0 to disable caching. +const createGitReadCacheTtlMs = () => { + const raw = Number(process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS); + if (Number.isFinite(raw) && raw >= 0) return raw; + return 30 * 1000; +}; + +// Only deterministic, side-effect-free git plumbing path queries are cacheable. +// Anything outside this allowlist (including any non-git command) runs normally +// — we never cache arbitrary exec. +const normalizeCommand = (command) => + typeof command === 'string' ? command.trim().replace(/\s+/g, ' ') : ''; + +const isCacheableGitReadCommand = (command) => { + const normalized = normalizeCommand(command); + return /^git rev-parse(?: --(?:absolute-git-dir|git-common-dir|show-toplevel)){1,3}$/.test(normalized); +}; + +// Dual-constraint bound per the project's caching policy (count + bytes). Git +// rev-parse outputs are tiny, so these ceilings are generous and only guard +// against pathological growth on long-lived, many-directory deployments. +const GIT_READ_CACHE_MAX_ENTRIES = 500; +const GIT_READ_CACHE_MAX_BYTES = 1024 * 1024; + +const gitReadEntryBytes = (key, result) => + key.length + (result?.stdout?.length || 0) + (result?.stderr?.length || 0); + const isPathWithinRoot = (resolvedPath, rootPath, path, os) => { const resolvedRoot = path.resolve(rootPath || os.homedir()); const relative = path.relative(resolvedRoot, resolvedPath); @@ -243,6 +273,9 @@ export const registerFsRoutes = (app, dependencies) => { const execJobs = new Map(); const commandTimeoutMs = createCommandTimeoutMs(); + const gitReadCacheTtlMs = createGitReadCacheTtlMs(); + const gitReadCache = new Map(); + const inFlightGitReadCache = new Map(); const pruneExecJobs = () => { const now = Date.now(); @@ -258,6 +291,94 @@ export const registerFsRoutes = (app, dependencies) => { } }; + const pruneGitReadCache = () => { + if (gitReadCacheTtlMs <= 0) { + return; + } + const now = Date.now(); + for (const [key, entry] of gitReadCache.entries()) { + if (!entry || now - entry.at > gitReadCacheTtlMs) { + gitReadCache.delete(key); + } + } + }; + + // Insert with LRU (oldest-first) eviction enforcing both count and byte caps. + // Map iteration order is insertion order, so deleting+re-setting a key moves + // it to the most-recently-used position. + const setGitReadCacheEntry = (key, result) => { + gitReadCache.delete(key); + gitReadCache.set(key, { result, at: Date.now() }); + + let totalBytes = 0; + for (const [k, entry] of gitReadCache) { + totalBytes += gitReadEntryBytes(k, entry.result); + } + while ( + gitReadCache.size > GIT_READ_CACHE_MAX_ENTRIES || + (totalBytes > GIT_READ_CACHE_MAX_BYTES && gitReadCache.size > 1) + ) { + const oldest = gitReadCache.entries().next().value; + if (!oldest) { + break; + } + totalBytes -= gitReadEntryBytes(oldest[0], oldest[1].result); + gitReadCache.delete(oldest[0]); + } + }; + + // Runs a command, transparently serving/storing cacheable git-read results. + // Non-cacheable commands always execute and are never stored. + const runCommandWithGitReadCache = async ({ shell, shellFlag, command, resolvedCwd }) => { + const cacheable = gitReadCacheTtlMs > 0 && isCacheableGitReadCommand(command); + const cacheKey = cacheable ? `${resolvedCwd}${normalizeCommand(command)}` : null; + + if (cacheKey) { + const cached = gitReadCache.get(cacheKey); + if (cached && Date.now() - cached.at < gitReadCacheTtlMs) { + // Refresh recency for LRU without altering the entry's age/TTL. + gitReadCache.delete(cacheKey); + gitReadCache.set(cacheKey, cached); + return { ...cached.result, command }; + } + if (cached) { + gitReadCache.delete(cacheKey); + } + + const inFlight = inFlightGitReadCache.get(cacheKey); + if (inFlight) { + const result = await inFlight; + return { ...result, command }; + } + } + + const runPromise = runCommandInDirectory({ + shell, + shellFlag, + command, + resolvedCwd, + spawn, + buildAugmentedPath, + commandTimeoutMs, + }).then((result) => { + // Only cache successful results — failures may be transient. + if (cacheKey && result && result.success) { + setGitReadCacheEntry(cacheKey, result); + } + return result; + }).finally(() => { + if (cacheKey && inFlightGitReadCache.get(cacheKey) === runPromise) { + inFlightGitReadCache.delete(cacheKey); + } + }); + + if (cacheKey) { + inFlightGitReadCache.set(cacheKey, runPromise); + } + + return runPromise; + }; + const runExecJob = async (job) => { job.status = 'running'; job.updatedAt = Date.now(); @@ -270,14 +391,11 @@ export const registerFsRoutes = (app, dependencies) => { } try { - const result = await runCommandInDirectory({ + const result = await runCommandWithGitReadCache({ shell: job.shell, shellFlag: job.shellFlag, command, resolvedCwd: job.resolvedCwd, - spawn, - buildAugmentedPath, - commandTimeoutMs, }); results.push(result); } catch (error) { @@ -816,6 +934,7 @@ export const registerFsRoutes = (app, dependencies) => { } pruneExecJobs(); + pruneGitReadCache(); try { const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd)); diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js new file mode 100644 index 00000000..4280a011 --- /dev/null +++ b/packages/web/server/lib/fs/routes.test.js @@ -0,0 +1,251 @@ +import { EventEmitter } from 'events'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerFsRoutes } from './routes.js'; + +const createRouteRegistry = () => { + const routes = new Map(); + return { + app: { + get(routePath, handler) { + routes.set(`GET ${routePath}`, handler); + }, + post(routePath, handler) { + routes.set(`POST ${routePath}`, handler); + }, + }, + getRoute(method, routePath) { + return routes.get(`${method} ${routePath}`); + }, + }; +}; + +const createMockResponse = () => { + let statusCode = 200; + let body = null; + return { + status(code) { + statusCode = code; + return this; + }, + json(payload) { + body = payload; + return this; + }, + get statusCode() { + return statusCode; + }, + get body() { + return body; + }, + }; +}; + +// Fake child process: emits the configured stdout then closes with the given code. +const createSpawn = ({ stdoutByCommand = {}, exitCode = 0 } = {}) => { + const calls = []; + const spawn = vi.fn((_shell, args) => { + const command = args[args.length - 1]; + calls.push(command); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => {}; + queueMicrotask(() => { + const out = stdoutByCommand[command]; + if (out) child.stdout.emit('data', Buffer.from(out)); + child.emit('close', exitCode, null); + }); + return child; + }); + return { spawn, calls }; +}; + +const createDeferredSpawn = ({ stdoutByCommand = {}, exitCode = 0 } = {}) => { + const calls = []; + const pending = []; + const spawn = vi.fn((_shell, args) => { + const command = args[args.length - 1]; + calls.push(command); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => {}; + pending.push({ child, command }); + return child; + }); + const closeNext = () => { + const entry = pending.shift(); + if (!entry) return; + const out = stdoutByCommand[entry.command]; + if (out) entry.child.stdout.emit('data', Buffer.from(out)); + entry.child.emit('close', exitCode, null); + }; + return { spawn, calls, closeNext }; +}; + +const registerExec = ({ spawn }) => { + const { app, getRoute } = createRouteRegistry(); + registerFsRoutes(app, { + os: { homedir: () => '/home/user' }, + path, + fsPromises: { stat: async () => ({ isDirectory: () => true }) }, + spawn, + crypto: { randomUUID: (() => { let n = 0; return () => `job-${n++}`; })() }, + normalizeDirectoryPath: (p) => p, + resolveProjectDirectory: async () => ({ directory: '/repo' }), + buildAugmentedPath: () => '/usr/bin', + resolveGitBinaryForSpawn: () => 'git', + openchamberUserConfigRoot: '/home/user/.config', + }); + return getRoute('POST', '/api/fs/exec'); +}; + +const callExec = async (handler, body) => { + const res = createMockResponse(); + await handler({ body }, res); + return res; +}; + +describe('fs exec git-read cache', () => { + beforeEach(() => { + delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS; + }); + + afterEach(() => { + delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS; + }); + + it('caches an allowlisted git rev-parse across identical requests', async () => { + const command = 'git rev-parse --absolute-git-dir --git-common-dir'; + const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } }); + const handler = registerExec({ spawn }); + + const first = await callExec(handler, { commands: [command], cwd: '/repo' }); + const second = await callExec(handler, { commands: [command], cwd: '/repo' }); + + expect(first.body.results[0].stdout).toBe('/repo/.git\n.git'); + expect(second.body.results[0].stdout).toBe('/repo/.git\n.git'); + expect(second.body.success).toBe(true); + // Spawned once; the second request is served from cache. + expect(calls.length).toBe(1); + }); + + it('dedupes concurrent identical git-read requests while the first is in flight', async () => { + const command = 'git rev-parse --absolute-git-dir --git-common-dir'; + const { spawn, calls, closeNext } = createDeferredSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } }); + const handler = registerExec({ spawn }); + + const first = callExec(handler, { commands: [command], cwd: '/repo' }); + const second = callExec(handler, { commands: [command], cwd: '/repo' }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls.length).toBe(1); + + closeNext(); + const [firstRes, secondRes] = await Promise.all([first, second]); + + expect(firstRes.body.results[0].stdout).toBe('/repo/.git\n.git'); + expect(secondRes.body.results[0].stdout).toBe('/repo/.git\n.git'); + expect(calls.length).toBe(1); + }); + + it('returns the current request command for normalized cache hits', async () => { + const firstCommand = 'git rev-parse --absolute-git-dir'; + const secondCommand = 'git rev-parse --absolute-git-dir'; + const { spawn, calls } = createSpawn({ stdoutByCommand: { [firstCommand]: '/repo/.git\n' } }); + const handler = registerExec({ spawn }); + + const first = await callExec(handler, { commands: [firstCommand], cwd: '/repo' }); + const second = await callExec(handler, { commands: [secondCommand], cwd: '/repo' }); + + expect(first.body.results[0].command).toBe(firstCommand); + expect(second.body.results[0].command).toBe(secondCommand); + expect(calls.length).toBe(1); + }); + + it('keys the cache by working directory', async () => { + const command = 'git rev-parse --absolute-git-dir'; + const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/x/.git\n' } }); + const handler = registerExec({ spawn }); + + await callExec(handler, { commands: [command], cwd: '/repo-a' }); + await callExec(handler, { commands: [command], cwd: '/repo-b' }); + + expect(calls.length).toBe(2); + }); + + it('never caches non-allowlisted commands', async () => { + const command = 'git status'; + const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: 'clean\n' } }); + const handler = registerExec({ spawn }); + + await callExec(handler, { commands: [command], cwd: '/repo' }); + await callExec(handler, { commands: [command], cwd: '/repo' }); + + expect(calls.length).toBe(2); + }); + + it('does not cache failed git-read results', async () => { + const command = 'git rev-parse --absolute-git-dir'; + const { spawn, calls } = createSpawn({ stdoutByCommand: {}, exitCode: 128 }); + const handler = registerExec({ spawn }); + + await callExec(handler, { commands: [command], cwd: '/not-a-repo' }); + await callExec(handler, { commands: [command], cwd: '/not-a-repo' }); + + expect(calls.length).toBe(2); + }); + + it('disables caching when TTL is 0', async () => { + process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS = '0'; + const command = 'git rev-parse --absolute-git-dir'; + const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n' } }); + const handler = registerExec({ spawn }); + + await callExec(handler, { commands: [command], cwd: '/repo' }); + await callExec(handler, { commands: [command], cwd: '/repo' }); + + expect(calls.length).toBe(2); + }); + + it('re-runs once a cached entry ages past the TTL', async () => { + vi.useFakeTimers(); + try { + const command = 'git rev-parse --absolute-git-dir'; + const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n' } }); + const handler = registerExec({ spawn }); // default 30s TTL + + await callExec(handler, { commands: [command], cwd: '/repo' }); + vi.advanceTimersByTime(31_000); + await callExec(handler, { commands: [command], cwd: '/repo' }); + + // Stale entry is not served; a fresh subprocess fires. + expect(calls.length).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it('bounds the cache by evicting the least-recently-used entry past the count cap', async () => { + const command = 'git rev-parse --absolute-git-dir'; + const { spawn, calls } = createSpawn(); // exit 0, empty stdout — still cacheable + const handler = registerExec({ spawn }); + + // Fill to the 500-entry ceiling with distinct working directories. + for (let i = 0; i < 500; i += 1) { + await callExec(handler, { commands: [command], cwd: `/repo-${i}` }); + } + const afterFill = calls.length; + expect(afterFill).toBe(500); + + // One more distinct dir evicts the oldest entry (/repo-0). + await callExec(handler, { commands: [command], cwd: '/repo-overflow' }); + // Evicted entry must re-run; a surviving entry must still be served. + await callExec(handler, { commands: [command], cwd: '/repo-0' }); // evicted -> spawns + await callExec(handler, { commands: [command], cwd: '/repo-499' }); // cached -> no spawn + + expect(calls.length).toBe(afterFill + 2); + }); +});