Skip to content

Commit 94ccfa9

Browse files
committed
feat: follow mode switches to new log file after daemon restart
When `bitsocial logs -f` is running and the daemon restarts (e.g. via `bitsocial update install`), the new daemon writes to a new timestamped log file. Previously, follow mode kept watching the old file and missed all output from the restarted daemon. Add a 3-second polling interval that detects newer log files and seamlessly switches the watcher, printing a separator to stderr. Closes #31
1 parent 0a37084 commit 94ccfa9

File tree

2 files changed

+191
-7
lines changed

2 files changed

+191
-7
lines changed

src/cli/commands/logs.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,24 +184,26 @@ export default class Logs extends Command {
184184
}
185185

186186
// Follow mode: dump existing content (filtered + tailed) then watch for new data
187-
const existingContent = await fsPromise.readFile(latestLogFile, "utf-8");
187+
let currentLogFile = latestLogFile;
188+
189+
const existingContent = await fsPromise.readFile(currentLogFile, "utf-8");
188190
const entries = this._parseLogEntries(existingContent);
189191
const filtered = this._filterEntries(entries, since, until);
190192
const streamFiltered = streamFilter ? this._filterByStream(filtered, streamFilter) : filtered;
191193
const tailed = this._tailEntries(streamFiltered, flags.tail);
192194
const initialOutput = tailed.map((e) => e.lines.join("\n")).join("\n");
193195
if (initialOutput) process.stdout.write(initialOutput + "\n");
194196

195-
const stat = await fsPromise.stat(latestLogFile);
197+
const stat = await fsPromise.stat(currentLogFile);
196198
let position = stat.size;
197199
let pendingBuffer = "";
198200

199201
// Watch for new data using polling (works across filesystems including Docker volumes)
200202
const readNewData = async () => {
201203
try {
202-
const currentStat = await fsPromise.stat(latestLogFile);
204+
const currentStat = await fsPromise.stat(currentLogFile);
203205
if (currentStat.size > position) {
204-
const fd = await fsPromise.open(latestLogFile, "r");
206+
const fd = await fsPromise.open(currentLogFile, "r");
205207
const buf = new Uint8Array(currentStat.size - position);
206208
const { bytesRead } = await fd.read(buf, 0, buf.length, position);
207209
await fd.close();
@@ -233,15 +235,68 @@ export default class Logs extends Command {
233235
}
234236
};
235237

236-
fs.watchFile(latestLogFile, { interval: 300 }, readNewData);
238+
// Periodically check if a newer log file has appeared (e.g. after daemon restart)
239+
const checkForNewLogFile = async () => {
240+
try {
241+
const newestFile = await this._findLatestLogFile(logPath);
242+
if (newestFile === currentLogFile) return;
243+
244+
// Flush any remaining partial line from old file
245+
if (pendingBuffer) {
246+
if (!since && !until && !streamFilter) {
247+
process.stdout.write(pendingBuffer + "\n");
248+
} else {
249+
const pbEntries = this._parseLogEntries(pendingBuffer);
250+
const pbFiltered = this._filterEntries(pbEntries, since, until);
251+
const pbStreamFiltered = streamFilter ? this._filterByStream(pbFiltered, streamFilter) : pbFiltered;
252+
const pbOutput = pbStreamFiltered.map((e) => e.lines.join("\n")).join("\n");
253+
if (pbOutput) process.stdout.write(pbOutput + "\n");
254+
}
255+
}
256+
257+
// Switch watchers
258+
fs.unwatchFile(currentLogFile, readNewData);
259+
currentLogFile = newestFile;
260+
pendingBuffer = "";
261+
262+
process.stderr.write(`\n--- switched to new log file: ${path.basename(newestFile)} ---\n\n`);
263+
264+
// Read and output entire new file content (with filters, no tail limit)
265+
const newContent = await fsPromise.readFile(currentLogFile, "utf-8");
266+
if (newContent) {
267+
if (!since && !until && !streamFilter) {
268+
process.stdout.write(newContent);
269+
} else {
270+
const newEntries = this._parseLogEntries(newContent.replace(/\n$/, ""));
271+
const filteredNew = this._filterEntries(newEntries, since, until);
272+
const streamFilteredNew = streamFilter
273+
? this._filterByStream(filteredNew, streamFilter)
274+
: filteredNew;
275+
const output = streamFilteredNew.map((e) => e.lines.join("\n")).join("\n");
276+
if (output) process.stdout.write(output + "\n");
277+
}
278+
}
279+
280+
const newStat = await fsPromise.stat(currentLogFile);
281+
position = newStat.size;
282+
fs.watchFile(currentLogFile, { interval: 300 }, readNewData);
283+
} catch {
284+
// Directory listing failed or file disappeared — retry next cycle
285+
}
286+
};
287+
288+
fs.watchFile(currentLogFile, { interval: 300 }, readNewData);
289+
const newFileCheckInterval = setInterval(checkForNewLogFile, 3000);
237290

238291
// Keep the process alive and clean up on exit
239292
process.on("SIGINT", () => {
240-
fs.unwatchFile(latestLogFile, readNewData);
293+
clearInterval(newFileCheckInterval);
294+
fs.unwatchFile(currentLogFile, readNewData);
241295
process.exit(0);
242296
});
243297
process.on("SIGTERM", () => {
244-
fs.unwatchFile(latestLogFile, readNewData);
298+
clearInterval(newFileCheckInterval);
299+
fs.unwatchFile(currentLogFile, readNewData);
245300
process.exit(0);
246301
});
247302

test/cli/logs.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,135 @@ describe("bitsocial logs --stdout/--stderr filtering (synthetic)", () => {
332332
});
333333
});
334334

335+
describe("bitsocial logs -f log file rotation (synthetic)", () => {
336+
it("switches to new log file when one appears", async () => {
337+
const { logDir } = await createLogDir();
338+
const file1 = path.join(logDir, "bitsocial_cli_daemon_2026-03-01T00-00-00.000Z.log");
339+
await fsPromise.writeFile(file1, buildLogLine(new Date("2026-03-01T00:00:00.000Z"), "INITIAL_MARKER") + "\n");
340+
341+
const result = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
342+
const proc = spawn("node", ["./bin/run", "logs", "--logPath", logDir, "-f"], {
343+
stdio: ["pipe", "pipe", "pipe"]
344+
});
345+
346+
let stdout = "";
347+
let stderr = "";
348+
let createdNewFile = false;
349+
proc.stdout.on("data", (data: Buffer) => {
350+
stdout += data.toString();
351+
// Wait for initial output before creating the new file
352+
if (!createdNewFile && stdout.includes("INITIAL_MARKER")) {
353+
createdNewFile = true;
354+
const file2 = path.join(logDir, "bitsocial_cli_daemon_2026-03-01T01-00-00.000Z.log");
355+
fsPromise.writeFile(file2, buildLogLine(new Date("2026-03-01T01:00:00.000Z"), "NEW_FILE_MARKER") + "\n");
356+
}
357+
});
358+
proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); });
359+
360+
// Wait long enough for the 3-second new-file check to fire, then kill
361+
const timer = setTimeout(() => {
362+
proc.kill("SIGINT");
363+
}, 8000);
364+
365+
proc.on("close", () => {
366+
clearTimeout(timer);
367+
resolve({ stdout, stderr });
368+
});
369+
proc.on("error", (err) => {
370+
clearTimeout(timer);
371+
reject(err);
372+
});
373+
});
374+
375+
expect(result.stdout).toContain("INITIAL_MARKER");
376+
expect(result.stdout).toContain("NEW_FILE_MARKER");
377+
expect(result.stderr).toContain("switched to new log file");
378+
});
379+
380+
it("applies --stdout filter after switching to new log file", async () => {
381+
const { logDir } = await createLogDir();
382+
const file1 = path.join(logDir, "bitsocial_cli_daemon_2026-04-01T00-00-00.000Z.log");
383+
await fsPromise.writeFile(file1, buildLogLine(new Date("2026-04-01T00:00:00.000Z"), "initial stdout", "stdout") + "\n");
384+
385+
const result = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
386+
const proc = spawn("node", ["./bin/run", "logs", "--logPath", logDir, "--stdout", "-f"], {
387+
stdio: ["pipe", "pipe", "pipe"]
388+
});
389+
390+
let stdout = "";
391+
let stderr = "";
392+
let createdNewFile = false;
393+
proc.stdout.on("data", (data: Buffer) => {
394+
stdout += data.toString();
395+
if (!createdNewFile && stdout.includes("initial stdout")) {
396+
createdNewFile = true;
397+
const file2 = path.join(logDir, "bitsocial_cli_daemon_2026-04-01T01-00-00.000Z.log");
398+
const content = [
399+
buildLogLine(new Date("2026-04-01T01:00:00.000Z"), "new stdout msg", "stdout"),
400+
buildLogLine(new Date("2026-04-01T01:01:00.000Z"), "new stderr msg", "stderr")
401+
].join("\n") + "\n";
402+
fsPromise.writeFile(file2, content);
403+
}
404+
});
405+
proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); });
406+
407+
const timer = setTimeout(() => {
408+
proc.kill("SIGINT");
409+
}, 8000);
410+
411+
proc.on("close", () => {
412+
clearTimeout(timer);
413+
resolve({ stdout, stderr });
414+
});
415+
proc.on("error", (err) => {
416+
clearTimeout(timer);
417+
reject(err);
418+
});
419+
});
420+
421+
expect(result.stdout).toContain("initial stdout");
422+
expect(result.stdout).toContain("new stdout msg");
423+
expect(result.stdout).not.toContain("new stderr msg");
424+
});
425+
426+
it("continues watching old file if no new file appears", async () => {
427+
const { logDir } = await createLogDir();
428+
const file1 = path.join(logDir, "bitsocial_cli_daemon_2026-05-01T00-00-00.000Z.log");
429+
await fsPromise.writeFile(file1, buildLogLine(new Date("2026-05-01T00:00:00.000Z"), "initial line") + "\n");
430+
431+
const result = await new Promise<{ stdout: string }>((resolve, reject) => {
432+
const proc = spawn("node", ["./bin/run", "logs", "--logPath", logDir, "-f"], {
433+
stdio: ["pipe", "pipe", "pipe"]
434+
});
435+
436+
let stdout = "";
437+
proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); });
438+
439+
// Append to same file after a short delay
440+
setTimeout(async () => {
441+
await fsPromise.appendFile(file1, buildLogLine(new Date("2026-05-01T00:01:00.000Z"), "APPENDED_LINE") + "\n");
442+
}, 500);
443+
444+
// Wait for the appended data to be picked up, then kill
445+
const timer = setTimeout(() => {
446+
proc.kill("SIGINT");
447+
}, 2000);
448+
449+
proc.on("close", () => {
450+
clearTimeout(timer);
451+
resolve({ stdout });
452+
});
453+
proc.on("error", (err) => {
454+
clearTimeout(timer);
455+
reject(err);
456+
});
457+
});
458+
459+
expect(result.stdout).toContain("initial line");
460+
expect(result.stdout).toContain("APPENDED_LINE");
461+
});
462+
});
463+
335464
describe("bitsocial logs (live daemon tests)", async () => {
336465
let daemonProcess: ManagedChildProcess;
337466
let logDir: string;

0 commit comments

Comments
 (0)