Skip to content

Commit eb979a8

Browse files
committed
perf(logging): buffer the file sink and rotate it
Each record opened, wrote, and closed the log file on the calling turn, and the file grew without a cap. Batching keeps that path off disk, and crash and exit flushes keep the tail.
1 parent f5e3cd2 commit eb979a8

4 files changed

Lines changed: 251 additions & 11 deletions

File tree

‎src/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
ensureTelemetrySettings,
1212
globalSettingsPath,
1313
} from "./config/settings.js";
14-
import { installFileLogSink } from "./logging/sink.js";
14+
import { flushFileLogSinkSync, installFileLogSink } from "./logging/sink.js";
1515
import { flushPerfToOtel } from "./perf/index.js";
1616
import { createTelemetry, telemetryDisabledByEnv } from "./telemetry/index.js";
1717
import { getTelemetry, setTelemetry } from "./telemetry/singleton.js";
@@ -120,6 +120,9 @@ export async function mainWithRunners(
120120
// Keep process.exit from dropping in-flight captures; the flush itself is
121121
// deadline-capped so exit stays snappy.
122122
await getTelemetry().flush();
123+
// The file sink only queues on the hot path. Flush before returning so a
124+
// normal exit does not lose the diagnostic tail.
125+
flushFileLogSinkSync();
123126
return exitCode;
124127
}
125128

@@ -165,5 +168,9 @@ if (import.meta.main) {
165168
dest.write(exit.text);
166169
code = exit.code;
167170
}
171+
// Covers logs emitted after main returns, and the catch path, which never
172+
// reaches the flush inside mainWithRunners. process.exit skips the microtask
173+
// drain, so this has to be synchronous.
174+
flushFileLogSinkSync();
168175
process.exit(code);
169176
}

‎src/logging/sink.test.ts‎

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { describe, expect, spyOn, test } from "bun:test";
2-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2+
import fs from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { getLogger } from "@intx/log";
66

7-
import { corbitsLogFilePath, installFileLogSink } from "./sink.js";
7+
import {
8+
corbitsLogFilePath,
9+
flushFileLogSinkSync,
10+
installFileLogSink,
11+
} from "./sink.js";
12+
13+
function tempLogFile(): { dir: string; file: string } {
14+
const dir = fs.mkdtempSync(join(tmpdir(), "corbits-sink-test-"));
15+
return { dir, file: join(dir, "corbits.log") };
16+
}
817

918
describe("corbitsLogFilePath", () => {
1019
test("nests under the settings dir, not directly in home", () => {
@@ -16,8 +25,7 @@ describe("corbitsLogFilePath", () => {
1625

1726
describe("installFileLogSink", () => {
1827
test("routes a logger's output to the file, never to stdout/stderr", () => {
19-
const dir = mkdtempSync(join(tmpdir(), "corbits-sink-test-"));
20-
const file = join(dir, "corbits.log");
28+
const { dir, file } = tempLogFile();
2129
try {
2230
installFileLogSink(file);
2331

@@ -27,6 +35,7 @@ describe("installFileLogSink", () => {
2735
getLogger(["some", "vendored", "logger"]).error("boom {detail}", {
2836
detail: "bad",
2937
});
38+
flushFileLogSinkSync();
3039
} finally {
3140
stdoutWrite.mockRestore();
3241
stderrWrite.mockRestore();
@@ -35,11 +44,95 @@ describe("installFileLogSink", () => {
3544
expect(stdoutWrite).not.toHaveBeenCalled();
3645
expect(stderrWrite).not.toHaveBeenCalled();
3746

38-
const logged = readFileSync(file, "utf8");
47+
const logged = fs.readFileSync(file, "utf8");
3948
expect(logged).toContain("boom bad");
4049
expect(logged).toContain("some.vendored.logger");
4150
} finally {
42-
rmSync(dir, { recursive: true, force: true });
51+
fs.rmSync(dir, { recursive: true, force: true });
52+
}
53+
});
54+
55+
test("queues records instead of appending once per call", () => {
56+
const { dir, file } = tempLogFile();
57+
const calls: string[] = [];
58+
const append = spyOn(fs, "appendFileSync").mockImplementation((path) => {
59+
calls.push(String(path));
60+
const until = performance.now() + 5;
61+
while (performance.now() < until) {
62+
// A per-record sync append would make the burst pay this stall each time.
63+
}
64+
});
65+
try {
66+
installFileLogSink(file, { maxBytes: 10 * 1024 * 1024 });
67+
const logger = getLogger(["logging", "burst"]);
68+
const started = performance.now();
69+
for (let i = 0; i < 20; i++) logger.info(`burst-${i}`);
70+
const elapsed = performance.now() - started;
71+
const duringBurst = calls.length;
72+
expect(duringBurst).toBe(0);
73+
// 20 sync appends would spend about 100ms inside the loop.
74+
expect(elapsed).toBeLessThan(50);
75+
expect(existsUnwritten(file)).not.toContain("burst-19");
76+
flushFileLogSinkSync();
77+
expect(calls.length).toBeGreaterThan(0);
78+
expect(calls.length).toBeLessThan(20);
79+
} finally {
80+
append.mockRestore();
81+
fs.rmSync(dir, { recursive: true, force: true });
82+
}
83+
});
84+
85+
test("rotates the active log once it passes the size cap", () => {
86+
const { dir, file } = tempLogFile();
87+
try {
88+
installFileLogSink(file, { maxBytes: 500 });
89+
const logger = getLogger(["logging", "rotate"]);
90+
for (let i = 0; i < 40; i++) {
91+
logger.info(`rotate-line-${i}-xxxxxxxxxxxxxxxxxxxx`);
92+
}
93+
flushFileLogSinkSync();
94+
const rotated = `${file}.1`;
95+
expect(fs.existsSync(rotated)).toBe(true);
96+
const previous = fs.readFileSync(rotated, "utf8");
97+
const active = fs.readFileSync(file, "utf8");
98+
const earlier = lineIndex(previous);
99+
const later = lineIndex(active);
100+
expect(earlier).toBeLessThan(later);
101+
expect(active).not.toContain(`rotate-line-${earlier}-`);
102+
expect(Buffer.byteLength(previous)).toBeLessThanOrEqual(500);
103+
expect(Buffer.byteLength(active)).toBeLessThanOrEqual(500);
104+
expect(previous + active).not.toContain("rotate-line-0-");
105+
expect(active).toContain("rotate-line-39-");
106+
} finally {
107+
fs.rmSync(dir, { recursive: true, force: true });
108+
}
109+
});
110+
111+
test("a crash or exit flush writes the pending tail", () => {
112+
const { dir, file } = tempLogFile();
113+
try {
114+
installFileLogSink(file);
115+
expect(process.listeners("exit")).toContain(flushFileLogSinkSync);
116+
getLogger(["logging", "crash"]).error("pending-tail");
117+
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
118+
expect(before).not.toContain("pending-tail");
119+
// Same function handleFatal, signal exit, and process exit call.
120+
flushFileLogSinkSync();
121+
expect(fs.readFileSync(file, "utf8")).toContain("pending-tail");
122+
} finally {
123+
fs.rmSync(dir, { recursive: true, force: true });
43124
}
44125
});
45126
});
127+
128+
function lineIndex(text: string): number {
129+
const match = /rotate-line-(\d+)-/.exec(text);
130+
if (match === null || match[1] === undefined) {
131+
throw new Error("rotated log is missing a record");
132+
}
133+
return Number(match[1]);
134+
}
135+
136+
function existsUnwritten(file: string): string {
137+
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
138+
}

‎src/logging/sink.ts‎

Lines changed: 140 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { appendFileSync, mkdirSync } from "node:fs";
1+
import fs from "node:fs";
22
import { homedir } from "node:os";
33
import { dirname, join } from "node:path";
44

@@ -19,10 +19,24 @@ interface LogRecord {
1919
readonly properties: Record<string, unknown>;
2020
}
2121

22+
/** One generation is kept beside the active file (`corbits.log.1`). */
23+
const LOG_MAX_BYTES = 10 * 1024 * 1024;
24+
2225
export function corbitsLogFilePath(home: string = homedir()): string {
2326
return join(home, SETTINGS_DIR_NAME, "logs", "corbits.log");
2427
}
2528

29+
interface FileSinkState {
30+
readonly path: string;
31+
readonly maxBytes: number;
32+
buffer: string[];
33+
size: number;
34+
drainScheduled: boolean;
35+
}
36+
37+
let state: FileSinkState | null = null;
38+
let exitHookInstalled = false;
39+
2640
function formatRecord(record: LogRecord): string {
2741
return (
2842
JSON.stringify({
@@ -35,6 +49,107 @@ function formatRecord(record: LogRecord): string {
3549
);
3650
}
3751

52+
function fileSize(path: string): number {
53+
try {
54+
return fs.statSync(path).size;
55+
} catch {
56+
return 0;
57+
}
58+
}
59+
60+
function rotateActive(current: FileSinkState): void {
61+
const rotated = `${current.path}.1`;
62+
try {
63+
fs.rmSync(rotated, { force: true });
64+
fs.renameSync(current.path, rotated);
65+
current.size = 0;
66+
} catch {
67+
try {
68+
// rename can fail (missing file, cross-device); truncating still caps growth.
69+
fs.truncateSync(current.path, 0);
70+
current.size = 0;
71+
} catch {
72+
// Keep `size` so a later flush retries instead of appending forever.
73+
}
74+
}
75+
}
76+
77+
function writeBatch(
78+
current: FileSinkState,
79+
batch: string[],
80+
bytes: number,
81+
): void {
82+
if (bytes === 0) return;
83+
try {
84+
fs.appendFileSync(current.path, batch.join(""));
85+
current.size += bytes;
86+
} catch {
87+
// A full disk must not paint the terminal or abort a crash exit.
88+
}
89+
}
90+
91+
function writeLines(current: FileSinkState, lines: readonly string[]): void {
92+
let batch: string[] = [];
93+
let batchBytes = 0;
94+
const flushBatch = (): void => {
95+
writeBatch(current, batch, batchBytes);
96+
batch = [];
97+
batchBytes = 0;
98+
};
99+
for (const line of lines) {
100+
const lineBytes = Buffer.byteLength(line);
101+
if (
102+
batchBytes > 0 &&
103+
current.size + batchBytes + lineBytes > current.maxBytes
104+
) {
105+
flushBatch();
106+
}
107+
// Rotate only once the active file already holds bytes. A single record
108+
// larger than the cap still lands, then the next record starts a new file.
109+
if (current.size > 0 && current.size + lineBytes > current.maxBytes) {
110+
rotateActive(current);
111+
}
112+
batch.push(line);
113+
batchBytes += lineBytes;
114+
}
115+
flushBatch();
116+
}
117+
118+
/**
119+
* Writes the buffered tail. Crash and exit paths must call this: the hot
120+
* path only queues, and `process.exit` does not run the microtask drain.
121+
* Also registered on `exit` so a path that skips the explicit call still
122+
* keeps the tail. Buffering can still drop records if the process is killed
123+
* before either flush runs.
124+
*/
125+
export function flushFileLogSinkSync(): void {
126+
const current = state;
127+
if (current === null) return;
128+
current.drainScheduled = false;
129+
if (current.buffer.length === 0) return;
130+
const lines = current.buffer;
131+
current.buffer = [];
132+
writeLines(current, lines);
133+
}
134+
135+
function enqueue(record: LogRecord): void {
136+
const current = state;
137+
if (current === null) return;
138+
current.buffer.push(formatRecord(record));
139+
if (current.drainScheduled) return;
140+
current.drainScheduled = true;
141+
const scheduled = current;
142+
queueMicrotask(() => {
143+
if (state !== scheduled) return;
144+
flushFileLogSinkSync();
145+
});
146+
}
147+
148+
interface FileLogSinkOptions {
149+
/** Rotate the active file once it reaches this many bytes. */
150+
readonly maxBytes?: number;
151+
}
152+
38153
/**
39154
* Routes every logger — including ones inside vendored dependencies, which
40155
* Corbits cannot edit — to a file instead of the console.
@@ -45,14 +160,35 @@ function formatRecord(record: LogRecord): string {
45160
* statement in `mainWithRunners`: the TUI holds the alternate screen for
46161
* the rest of the process, and anything landing on the real terminal
47162
* mid-frame corrupts it.
163+
*
164+
* Records are queued and appended in batches so a burst does not open the
165+
* file once per line. The active log rotates once it reaches 10 MiB.
48166
*/
49-
export function installFileLogSink(path: string = corbitsLogFilePath()): void {
50-
mkdirSync(dirname(path), { recursive: true });
167+
export function installFileLogSink(
168+
path: string = corbitsLogFilePath(),
169+
options?: FileLogSinkOptions,
170+
): void {
171+
// A later install must not drop lines still queued for the previous path.
172+
flushFileLogSinkSync();
173+
fs.mkdirSync(dirname(path), { recursive: true });
174+
const requested = options?.maxBytes;
175+
state = {
176+
path,
177+
maxBytes:
178+
requested !== undefined && requested > 0 ? requested : LOG_MAX_BYTES,
179+
buffer: [],
180+
size: fileSize(path),
181+
drainScheduled: false,
182+
};
183+
if (!exitHookInstalled) {
184+
exitHookInstalled = true;
185+
process.on("exit", flushFileLogSinkSync);
186+
}
51187
configureSync({
52188
reset: true,
53189
sinks: {
54190
file: (record: LogRecord) => {
55-
appendFileSync(path, formatRecord(record));
191+
enqueue(record);
56192
},
57193
},
58194
// "debug" (not "warning"): a file has no screen to corrupt, and the

‎src/process-handlers.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
writeCrashReport,
44
type CrashKind,
55
} from "./crash/report.js";
6+
import { flushFileLogSinkSync } from "./logging/sink.js";
67
import { getActiveRun, markCrashed } from "./session/active-run.js";
78
import { printResumeHint } from "./session/resume-hint.js";
89
import { getActiveDisposeHost } from "./session/active-host.js";
@@ -84,6 +85,8 @@ export async function handleFatal(
8485
error_class: classifyErrorClass(error),
8586
});
8687
await getTelemetry().flush();
88+
// Buffered log lines never reach disk if we exit before the microtask drain.
89+
flushFileLogSinkSync();
8790
process.exit(1);
8891
}
8992

@@ -194,6 +197,7 @@ export function installSignalHandlers(options?: ProcessHandlerOptions): void {
194197
const run = getActiveRun();
195198
if (run !== null) printResumeHint(run.sessionId);
196199
await finalizeActiveRunOnSignal(signal);
200+
flushFileLogSinkSync();
197201
process.exit(128 + SIGNAL_EXIT_NUMBER[signal]);
198202
})();
199203
});

0 commit comments

Comments
 (0)