From d0e5e402b2f4ea7f347b81f3ed7c6d00f9235937 Mon Sep 17 00:00:00 2001 From: Rinse Date: Tue, 9 Jun 2026 00:52:44 +0000 Subject: [PATCH] fix(logs): close race that dropped appends in -f follow mode (issue #77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (proven by the test diagnostics + local reproduction under CPU saturation): follow mode set its starting byte offset from a SEPARATE fsPromise.stat() taken AFTER the initial readFile + parse + stdout write: const existingContent = await readFile(file) // reads N bytes, prints them ...parse/filter/write to stdout... // append can land here const stat = await stat(file) // size now N+M let position = stat.size // = N+M, skips the M new bytes Any append landing between the readFile and the stat is silently dropped: the new bytes were not in the dumped content, and position jumps past them so the poll loop (which reads from position) never re-reads them. Under load the window between the two awaits widens, which is why CI failed intermittently on all three platforms with the appended line never appearing in stdout. The follow-trace diagnostics showed position==observedSize==81 (full file including the append) from the very first poll, with bytesThisCycle=0 forever — position had already been advanced past the unread append. Fix: anchor position to Buffer.byteLength of the exact content we just read, before any further awaits. Anything appended afterwards is at offset >= position and is picked up by the poll. Verified 20/20 green under 48-process CPU saturation that previously reproduced the drop within ~2 runs. Refs #77 --- src/cli/commands/logs.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/logs.ts b/src/cli/commands/logs.ts index 50dc3e5..43eaca5 100644 --- a/src/cli/commands/logs.ts +++ b/src/cli/commands/logs.ts @@ -187,6 +187,15 @@ export default class Logs extends Command { let currentLogFile = latestLogFile; const existingContent = await fsPromise.readFile(currentLogFile, "utf-8"); + // Anchor the follow offset to exactly the bytes we just read, NOT a separate + // fsPromise.stat() taken afterwards. A later stat is racy: any append landing between + // this read and the stat is skipped (position jumps past it) yet was never in the dump + // above, so follow mode silently drops those lines. Under load that window widens — this + // was the cause of the intermittent CI failure where an appended line was never surfaced + // (issue #77). Byte length (not string length) because position indexes bytes in the file. + let position = Buffer.byteLength(existingContent, "utf-8"); + let pendingBuffer = ""; + const entries = this._parseLogEntries(existingContent); const filtered = this._filterEntries(entries, since, until); const streamFiltered = streamFilter ? this._filterByStream(filtered, streamFilter) : filtered; @@ -194,10 +203,6 @@ export default class Logs extends Command { const initialOutput = tailed.map((e) => e.lines.join("\n")).join("\n"); if (initialOutput) process.stdout.write(initialOutput + "\n"); - const stat = await fsPromise.stat(currentLogFile); - let position = stat.size; - let pendingBuffer = ""; - // Watch for new data by reading directly from `position`. We intentionally do // NOT gate on fsPromise.stat().size — on Windows + NTFS, stat() returns a stale // size for a short window after another process appends, which causes the gate