Skip to content

Commit 2fd6138

Browse files
committed
fix(logging): recover a crashed rotation without stealing the lock
Move the previous generation aside before publishing the temp, and adopt a leftover rotating file on the next flush. Reclaim a lock only while its claim is still the dead pid that was read, and skip the flush when the lock cannot be acquired.
1 parent 7930675 commit 2fd6138

2 files changed

Lines changed: 276 additions & 33 deletions

File tree

‎src/logging/sink.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, spyOn, test } from "bun:test";
2+
import { spawn, type ChildProcess } from "node:child_process";
23
import fs from "node:fs";
34
import { tmpdir } from "node:os";
45
import { join } from "node:path";
@@ -15,6 +16,14 @@ function tempLogFile(): { dir: string; file: string } {
1516
return { dir, file: join(dir, "corbits.log") };
1617
}
1718

19+
function liveLockHolder(): ChildProcess {
20+
const holder = spawn("sleep", ["30"], { stdio: "ignore" });
21+
if (holder.pid === undefined) {
22+
throw new Error("lock holder did not start");
23+
}
24+
return holder;
25+
}
26+
1827
describe("corbitsLogFilePath", () => {
1928
test("nests under the settings dir, not directly in home", () => {
2029
expect(corbitsLogFilePath("/home/dev")).toBe(
@@ -226,6 +235,129 @@ describe("installFileLogSink", () => {
226235
}
227236
});
228237

238+
test("a crash after .1 is removed restores the rotating temp on the next flush", () => {
239+
const { dir, file } = tempLogFile();
240+
try {
241+
const temp = `${file}.${process.pid}.7.rotating`;
242+
const marker = "crashed-rotation-bytes\n";
243+
fs.writeFileSync(temp, marker);
244+
installFileLogSink(file, { maxBytes: 10_000 });
245+
getLogger(["logging", "recover"]).info("after-crash");
246+
flushFileLogSinkSync();
247+
const active = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
248+
const rotated = fs.existsSync(`${file}.1`)
249+
? fs.readFileSync(`${file}.1`, "utf8")
250+
: "";
251+
expect(`${active}${rotated}`).toContain("crashed-rotation-bytes");
252+
expect(fs.existsSync(temp)).toBe(false);
253+
} finally {
254+
fs.rmSync(dir, { recursive: true, force: true });
255+
}
256+
});
257+
258+
test("a second reclaimer does not delete the lock a winner just created", () => {
259+
const { dir, file } = tempLogFile();
260+
const holder = liveLockHolder();
261+
const lockPath = `${file}.lock`;
262+
const realRead = fs.readFileSync.bind(fs);
263+
let lied = false;
264+
try {
265+
installFileLogSink(file, { maxBytes: 300 });
266+
const first = getLogger(["logging", "reclaim-a"]);
267+
first.info("first-marker");
268+
first.info("first-marker-b");
269+
first.info("short-active");
270+
flushFileLogSinkSync();
271+
const rotated = `${file}.1`;
272+
const marker = fs.readFileSync(rotated, "utf8");
273+
fs.writeFileSync(lockPath, "999999:dead");
274+
const hooked = spyOn(fs, "readFileSync").mockImplementation(((
275+
path: fs.PathOrFileDescriptor,
276+
) => {
277+
if (String(path) === lockPath && !lied) {
278+
lied = true;
279+
fs.writeFileSync(lockPath, `${holder.pid}:winner`);
280+
return "999999:dead";
281+
}
282+
return realRead(path, "utf8");
283+
}) as unknown as typeof fs.readFileSync);
284+
try {
285+
getLogger(["logging", "reclaim-b"]).info(`b-wide-${"x".repeat(80)}`);
286+
flushFileLogSinkSync();
287+
expect(fs.readFileSync(rotated, "utf8")).toBe(marker);
288+
expect(fs.readFileSync(lockPath, "utf8")).toContain("winner");
289+
} finally {
290+
hooked.mockRestore();
291+
}
292+
} finally {
293+
holder.kill();
294+
fs.rmSync(dir, { recursive: true, force: true });
295+
}
296+
});
297+
298+
test("a flush that cannot acquire the lock does not rotate", () => {
299+
const { dir, file } = tempLogFile();
300+
const holder = liveLockHolder();
301+
try {
302+
installFileLogSink(file, { maxBytes: 300 });
303+
const first = getLogger(["logging", "lock-a"]);
304+
first.info("first-marker");
305+
first.info("first-marker-b");
306+
first.info("short-active");
307+
flushFileLogSinkSync();
308+
const rotated = `${file}.1`;
309+
const marker = fs.readFileSync(rotated, "utf8");
310+
const activeBefore = fs.readFileSync(file, "utf8");
311+
fs.writeFileSync(`${file}.lock`, `${holder.pid}:held`);
312+
getLogger(["logging", "lock-b"]).info(`b-wide-${"x".repeat(80)}`);
313+
flushFileLogSinkSync();
314+
expect(fs.readFileSync(rotated, "utf8")).toBe(marker);
315+
expect(fs.readFileSync(file, "utf8")).toBe(activeBefore);
316+
} finally {
317+
holder.kill();
318+
fs.rmSync(dir, { recursive: true, force: true });
319+
}
320+
});
321+
322+
test("a lock that cannot be read is not stolen", () => {
323+
const { dir, file } = tempLogFile();
324+
const holder = liveLockHolder();
325+
const lockPath = `${file}.lock`;
326+
const realRead = fs.readFileSync.bind(fs);
327+
try {
328+
installFileLogSink(file, { maxBytes: 300 });
329+
const first = getLogger(["logging", "lock-read-a"]);
330+
first.info("first-marker");
331+
first.info("first-marker-b");
332+
first.info("short-active");
333+
flushFileLogSinkSync();
334+
const rotated = `${file}.1`;
335+
const marker = fs.readFileSync(rotated, "utf8");
336+
const activeBefore = fs.readFileSync(file, "utf8");
337+
fs.writeFileSync(lockPath, `${holder.pid}:held`);
338+
const hooked = spyOn(fs, "readFileSync").mockImplementation(((
339+
path: fs.PathOrFileDescriptor,
340+
) => {
341+
if (String(path) === lockPath) {
342+
throw Object.assign(new Error("EIO"), { code: "EIO" });
343+
}
344+
return realRead(path, "utf8");
345+
}) as unknown as typeof fs.readFileSync);
346+
try {
347+
getLogger(["logging", "lock-read-b"]).info(`b-wide-${"x".repeat(80)}`);
348+
flushFileLogSinkSync();
349+
} finally {
350+
hooked.mockRestore();
351+
}
352+
expect(fs.readFileSync(rotated, "utf8")).toBe(marker);
353+
expect(fs.readFileSync(file, "utf8")).toBe(activeBefore);
354+
expect(fs.readFileSync(lockPath, "utf8")).toContain("held");
355+
} finally {
356+
holder.kill();
357+
fs.rmSync(dir, { recursive: true, force: true });
358+
}
359+
});
360+
229361
test("a failed rotation keeps already-written records in the active file", () => {
230362
const { dir, file } = tempLogFile();
231363
try {

0 commit comments

Comments
 (0)