From cecb6239c618f3b8009d17a0572b19ad77acba5a Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 10 Aug 2026 10:03:33 -0700 Subject: [PATCH] fix(serve): close the resident-process staleness and growth holes Adversarial review of the serve design surfaced three weaknesses a one-shot CLI never had, because it never lived long enough: - Pricing-affecting config (model aliases, price overrides, local-model savings) now participates in the parse memo key. Config reloads fresh per request (the preAction hook), but a memoized or burst-reused parse embedded costs priced under the OLD config; the widened key makes any such change an automatic memo miss. New alias-hash helper + tests. - Memory guard: past 3GB RSS the serve loop drops its in-memory memos (session cache + parse entries) and the next request re-parses once. The child never exits for this, so the client's death budget is untouched. - codeburn serve typed in an interactive terminal now explains itself on stderr instead of hanging silently on stdin. --- src/models.ts | 9 +++++++++ src/parser.ts | 7 +++++-- src/serve.ts | 21 +++++++++++++++++++++ tests/models.test.ts | 17 +++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/models.ts b/src/models.ts index d54ce9a0..f1d9ad0b 100644 --- a/src/models.ts +++ b/src/models.ts @@ -514,6 +514,15 @@ export function getLocalModelSavingsConfigHash(): string { return parts.join('\u0002') } +/// Stable hash of the model-alias map, for the same staleness class as the +/// hashes below: a resident process (codeburn serve) must not serve memoized +/// parse results priced under aliases the user has since changed. +export function getModelAliasesConfigHash(): string { + const keys = Object.keys(userAliases).sort() + if (keys.length === 0) return '' + return keys.map(k => `${k}\u0001${userAliases[k]}`).join('\u0002') +} + export function getPriceOverridesConfigHash(): string { // The builtin overrides participate so editing BUILTIN_PRICE_OVERRIDES in a // release invalidates cached daily costs the same way a user override does. diff --git a/src/parser.ts b/src/parser.ts index 90c7b2c2..2120bd0b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2,7 +2,7 @@ import { existsSync } from 'fs' import { lstat, readFile, readdir, stat } from 'fs/promises' import { basename, dirname, join, resolve, sep } from 'path' import { readSessionLines } from './fs-utils.js' -import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash } from './models.js' +import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash } from './models.js' import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js' import { normalizeContentBlocks } from './content-utils.js' import { discoverAllSessions, getProvider } from './providers/index.js' @@ -3228,7 +3228,10 @@ function cacheKey(dateRange?: DateRange, providerFilter?: string): string { const claudeEnv = (process.env['CLAUDE_CONFIG_DIRS'] ?? '') + '|' + (process.env['CLAUDE_CONFIG_DIR'] ?? '') // Proxy attribution (totalProxiedCostUSD) is computed live from proxyPaths and // then cached, so the key must change when that config changes. - return `${s}:${providerFilter ?? 'all'}:${claudeEnv}:${getProxyPathsConfigHash()}` + // Pricing-affecting config participates so a memoized parse (exact-key or + // burst-reused in a resident serve process) can never present costs priced + // under aliases/overrides/savings the user has since changed. + return `${s}:${providerFilter ?? 'all'}:${claudeEnv}:${getProxyPathsConfigHash()}:${getModelAliasesConfigHash()}:${getPriceOverridesConfigHash()}:${getLocalModelSavingsConfigHash()}` } export function clearSessionCache(): void { diff --git a/src/serve.ts b/src/serve.ts index 3762ed58..7d9c0e5b 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -30,6 +30,11 @@ import type { Command } from 'commander' // every config mutation (currency, model-alias set, budget, price-override, // proxy-path, plan), export (writes files), share/devices (network + pairing // state), menubar/web/mcp/guard/sync/act (process management or writes). +// Past this resident-set size the serve loop drops its in-memory memos and +// re-parses on the next request. 3GB leaves generous room for the largest +// observed corpora while bounding a pathological one. +const SERVE_MAX_RSS_BYTES = 3 * 1024 * 1024 * 1024 + const SERVE_COMMANDS = new Set(['status', 'overview', 'models', 'sessions', 'compare', 'yield', 'spend', 'optimize', 'audit']) type ServeRequest = { id: string | number; args: string[] } @@ -89,6 +94,9 @@ export async function runStdioServe(buildProgram: () => Command): Promise // re-running the discovery sweep per panel. Serve-only: one-shot CLI runs // never set this, so their results stay byte-exact. if (!process.env['CODEBURN_PARSE_BURST_MS']) process.env['CODEBURN_PARSE_BURST_MS'] = '10000' + if (process.stdin.isTTY) { + process.stderr.write('codeburn serve speaks JSON over stdio and exists for the desktop app to hold warm.\nNothing interactive happens here; press Ctrl+C to exit.\n') + } const write = (value: unknown): void => { process.stdout.write(JSON.stringify(value) + '\n') } write({ ready: true, pid: process.pid }) @@ -123,6 +131,19 @@ export async function runStdioServe(buildProgram: () => Command): Promise } catch (err) { write({ id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) }) } + // Memory guard: a resident process accumulates parse memos that a + // one-shot CLI never lives long enough to hold (up to 10 entries of + // full ProjectSummary trees plus the parsed cache object). Past the + // threshold, drop the in-memory memos — the next request re-parses + // once (seconds), which beats an ever-growing child. The child itself + // never exits here, so the client's death counter is untouched. + if (process.memoryUsage().rss > SERVE_MAX_RSS_BYTES) { + const { clearSessionCache } = await import('./parser.js') + const { clearLoadCacheMemo } = await import('./session-cache.js') + clearSessionCache() + clearLoadCacheMemo() + if (typeof globalThis.gc === 'function') globalThis.gc() + } }) }) diff --git a/tests/models.test.ts b/tests/models.test.ts index 75ffbea8..e8910e37 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -14,6 +14,7 @@ import { setLocalModelSavings, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, + getModelAliasesConfigHash, parseLiteLLMEntry, } from '../src/models.js' import { getDailyCacheConfigHash } from '../src/usage-aggregator.js' @@ -881,3 +882,19 @@ describe('parseLiteLLMEntry hardening', () => { expect(costs).not.toBeNull() }) }) + +describe('getModelAliasesConfigHash', () => { + it('is empty for no aliases, changes with content, ignores insertion order', () => { + setModelAliases({}) + expect(getModelAliasesConfigHash()).toBe('') + setModelAliases({ 'my-model': 'claude-opus-4-6' }) + const one = getModelAliasesConfigHash() + expect(one).not.toBe('') + setModelAliases({ 'b-model': 'gpt-5', 'my-model': 'claude-opus-4-6' }) + const two = getModelAliasesConfigHash() + expect(two).not.toBe(one) + setModelAliases({ 'my-model': 'claude-opus-4-6', 'b-model': 'gpt-5' }) + expect(getModelAliasesConfigHash()).toBe(two) + setModelAliases({}) + }) +})