From 06e83091bec3cf4dff344019d02f836fdb7d2c13 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:45:30 -0400 Subject: [PATCH] fix: bound Codex rollout tail reads (#1547) Replaces unbounded rollout-file reads with a bounded backward tail (1 MiB cap), preserving last-N-lines semantics. --- .../usage/codex-local-quota-collector.ts | 63 +++++++++++++++---- .../codex-local-quota-collector.test.ts | 27 ++++++++ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/web-server/usage/codex-local-quota-collector.ts b/src/web-server/usage/codex-local-quota-collector.ts index 82e077a8..6527416a 100644 --- a/src/web-server/usage/codex-local-quota-collector.ts +++ b/src/web-server/usage/codex-local-quota-collector.ts @@ -56,14 +56,14 @@ export interface CodexLocalQuota { windows: CodexLocalQuotaWindow[]; } -/** Injectable seams for deterministic tests (no real fs / no tail subprocess). */ +/** Injectable seams for deterministic tests (no real fs). */ export interface CodexLocalQuotaDeps { env?: NodeJS.ProcessEnv; homeDir?: string; existsSyncImpl?: (p: string) => boolean; readdirImpl?: (dir: string) => fs.Dirent[]; statMtimeMsImpl?: (p: string) => number; - /** Returns the last N lines of a file (default: Bun tail). */ + /** Returns the last N lines of a file (default: bounded fs tail). */ tailLinesImpl?: (file: string, lines: number) => Promise; now?: number; } @@ -81,6 +81,12 @@ const STALE_AFTER_MS = 5 * 60 * 1000; */ const MAX_SESSIONS_SCANNED = 20; +/** Maximum bytes read while tailing one rollout; prevents large-log DoS. */ +const TAIL_MAX_BYTES = 1024 * 1024; + +/** Chunk size for backwards tail reads. */ +const TAIL_READ_CHUNK_BYTES = 64 * 1024; + interface CodexRateWindow { usedPercent: number; resetsAtSeconds: number | null; @@ -167,19 +173,50 @@ function collectRolloutFiles( } /** - * Default tail via a pure fs read. Must work under BOTH node and bun: the `ccs` - * CLI (and `ccs bar launch`) runs under node, where `Bun.spawn` is undefined — - * the previous Bun-only implementation threw there, so Codex quota silently - * never surfaced in real deployments (only under the bun-run dev harness). - * Reading the JSONL and slicing the tail is cheap (session rollouts are small) - * and carries no runtime/OS/subprocess dependency. + * Default tail via bounded pure fs reads. Must work under BOTH node and bun: the + * `ccs` CLI (and `ccs bar launch`) runs under node, where `Bun.spawn` is + * undefined. Read from the end in chunks until enough lines are available, but + * cap bytes per file so oversized rollout logs cannot exhaust server memory. */ async function defaultTailLines(file: string, lines: number): Promise { - const text = await fs.promises.readFile(file, 'utf8'); - return text - .split('\n') - .filter((l) => l.trim().length > 0) - .slice(-lines); + if (lines <= 0) return []; + + const handle = await fs.promises.open(file, 'r'); + try { + const stat = await handle.stat(); + let position = stat.size; + let bytesReadTotal = 0; + const chunks: Buffer[] = []; + let newlineCount = 0; + + while (position > 0 && bytesReadTotal < TAIL_MAX_BYTES && newlineCount <= lines) { + const bytesToRead = Math.min( + TAIL_READ_CHUNK_BYTES, + position, + TAIL_MAX_BYTES - bytesReadTotal + ); + position -= bytesToRead; + + const buffer = Buffer.allocUnsafe(bytesToRead); + const result = await handle.read(buffer, 0, bytesToRead, position); + const chunk = + result.bytesRead === bytesToRead ? buffer : buffer.subarray(0, result.bytesRead); + chunks.unshift(chunk); + bytesReadTotal += result.bytesRead; + + for (let i = 0; i < chunk.length; i++) { + if (chunk[i] === 0x0a) newlineCount++; + } + } + + return Buffer.concat(chunks) + .toString('utf8') + .split('\n') + .filter((l) => l.trim().length > 0) + .slice(-lines); + } finally { + await handle.close(); + } } function computeQuotaPercentage(rate: CodexRateLimits): number { diff --git a/tests/unit/web-server/codex-local-quota-collector.test.ts b/tests/unit/web-server/codex-local-quota-collector.test.ts index 97a15e55..c0c5efb7 100644 --- a/tests/unit/web-server/codex-local-quota-collector.test.ts +++ b/tests/unit/web-server/codex-local-quota-collector.test.ts @@ -173,6 +173,33 @@ describe('getCodexLocalQuota', () => { expect(quota?.staleAsOf).toBeNull(); }); + it('tails large rollout files without losing quota near the end', async () => { + const { env, sessions } = freshHome('large-tail'); + const day = path.join(sessions, '2026', '06', '09'); + fs.mkdirSync(day, { recursive: true }); + const file = path.join(day, 'rollout-2026-06-09T10-00-00-aaaa.jsonl'); + + const fd = fs.openSync(file, 'w'); + try { + fs.writeSync(fd, `${'x'.repeat(2 * 1024 * 1024)}\n`); + fs.writeSync( + fd, + `${tokenCountLine({ + primary: { used_percent: 20, window_minutes: 300, resets_at: 1781033803 }, + secondary: { used_percent: 35, window_minutes: 10080, resets_at: 1781192122 }, + plan_type: 'pro', + })}\n` + ); + } finally { + fs.closeSync(fd); + } + + const quota = await getCodexLocalQuota({ env, now: Date.now() }); + expect(quota).not.toBeNull(); + expect(quota?.quotaPercentage).toBe(65); + expect(quota?.tier).toBe('pro'); + }); + it('returns null when there are no rollout files at all', async () => { const { env } = freshHome('empty'); const quota = await getCodexLocalQuota({ env, now: Date.now() });