mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-04 12:13:09 +00:00
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:
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() });
|
||||
|
||||
Reference in new issue
Block a user