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.
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-16 08:45:30 -04:00
1 parent c83d004783
commit 06e83091be
2 files changed
+77 -13

No files matched your search

@@ -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<string[]>;
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<string[]> {
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 {
@@ -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() });