mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +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[];
|
windows: CodexLocalQuotaWindow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Injectable seams for deterministic tests (no real fs / no tail subprocess). */
|
/** Injectable seams for deterministic tests (no real fs). */
|
||||||
export interface CodexLocalQuotaDeps {
|
export interface CodexLocalQuotaDeps {
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
existsSyncImpl?: (p: string) => boolean;
|
existsSyncImpl?: (p: string) => boolean;
|
||||||
readdirImpl?: (dir: string) => fs.Dirent[];
|
readdirImpl?: (dir: string) => fs.Dirent[];
|
||||||
statMtimeMsImpl?: (p: string) => number;
|
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[]>;
|
tailLinesImpl?: (file: string, lines: number) => Promise<string[]>;
|
||||||
now?: number;
|
now?: number;
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,12 @@ const STALE_AFTER_MS = 5 * 60 * 1000;
|
|||||||
*/
|
*/
|
||||||
const MAX_SESSIONS_SCANNED = 20;
|
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 {
|
interface CodexRateWindow {
|
||||||
usedPercent: number;
|
usedPercent: number;
|
||||||
resetsAtSeconds: number | null;
|
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`
|
* Default tail via bounded pure fs reads. Must work under BOTH node and bun: the
|
||||||
* CLI (and `ccs bar launch`) runs under node, where `Bun.spawn` is undefined —
|
* `ccs` CLI (and `ccs bar launch`) runs under node, where `Bun.spawn` is
|
||||||
* the previous Bun-only implementation threw there, so Codex quota silently
|
* undefined. Read from the end in chunks until enough lines are available, but
|
||||||
* never surfaced in real deployments (only under the bun-run dev harness).
|
* cap bytes per file so oversized rollout logs cannot exhaust server memory.
|
||||||
* Reading the JSONL and slicing the tail is cheap (session rollouts are small)
|
|
||||||
* and carries no runtime/OS/subprocess dependency.
|
|
||||||
*/
|
*/
|
||||||
async function defaultTailLines(file: string, lines: number): Promise<string[]> {
|
async function defaultTailLines(file: string, lines: number): Promise<string[]> {
|
||||||
const text = await fs.promises.readFile(file, 'utf8');
|
if (lines <= 0) return [];
|
||||||
return text
|
|
||||||
.split('\n')
|
const handle = await fs.promises.open(file, 'r');
|
||||||
.filter((l) => l.trim().length > 0)
|
try {
|
||||||
.slice(-lines);
|
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 {
|
function computeQuotaPercentage(rate: CodexRateLimits): number {
|
||||||
|
|||||||
@@ -173,6 +173,33 @@ describe('getCodexLocalQuota', () => {
|
|||||||
expect(quota?.staleAsOf).toBeNull();
|
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 () => {
|
it('returns null when there are no rollout files at all', async () => {
|
||||||
const { env } = freshHome('empty');
|
const { env } = freshHome('empty');
|
||||||
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
const quota = await getCodexLocalQuota({ env, now: Date.now() });
|
||||||
|
|||||||
Reference in new issue
Block a user