Skip to content

Fix the debug.log tail showing a line twice and replaying a file saved over itself - #637

Merged
zaerl merged 4 commits into
trunkfrom
fix/debug-log-tail-bounds
Oct 6, 2026
Merged

zaerl merged 4 commits into
trunkfrom
fix/debug-log-tail-bounds

Conversation

@zaerl

@zaerl zaerl commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Why

Two findings from a code review of the debug.log tail, both read from the code; both are confirmed here, by a script against Node's fs and by tests that are red on trunk.

A line WordPress writes just as the tail is reading shows up in the Debug.log pane twice. The tail stats the file, opens a stream from an offset with no end, and attaches the watcher; the stream reads to wherever the file ends by the time it gets round to it, and the change event for the same write reads from the stat's size again. The footer's badge counts the line once, so the pane and the badge disagree.

Saving debug.log from an editor refills the pane with every line it already showed, and the footer counts all of them as unseen. An editor's save writes a new file and renames it over the path, the watcher on the old file reports rename, and the tail forgot its offset and replayed the file as if it had just appeared.

What changes

Every range the tail plans ends at the size it was planned from. planInitialRead and planTailRead in src/log-tail.js return { start, end } with end inclusive, as fs.createReadStream takes it. Each read ends where the next one begins, so a byte is sent exactly once, by the read whose size saw it. startWpDebugTail already passes the range object straight to the stream; it needed no change for this half.

A file still at the path after a rename is the file the tail was reading, until its size says otherwise. attachFileWatcher now plans with planTailRead when it has an offset, by the same rule a change uses: a larger file is read from the old offset, an unchanged one not at all, a smaller one from its start, as after a Clear. The offset is forgotten only in watchForFile, when the file is actually gone, so what appears at the path later is still read whole, as a grunt clean and rebuild expects. This is the decision the second finding asked for, and it is the one the module's own comment already made for a same-length rewrite: replaying the file on every such event duplicates it into the pane, which is worse than missing a replacement that happens to be no smaller.

Nothing on screen changes shape. The renderer's backlog flag and its counting are untouched.

How to test this

Platforms: any for the pane; the rename path is where the platforms differ in which events fs.watch sends, so a pass on Windows is worth more than a second one on macOS. Build: the current head, or the Buildkite artifact for it once it exists.

The journey carries the editor's save. From the repository root:

npx playwright test --project=journeys -g "first appears while the server runs"

It is red on trunk at the new step: the footer says 3 unseen lines where 1 is right, because the whole file was replayed and counted. I checked by putting trunk's two source files back under the new tests. The duplicate from the first finding is a race between a stat and a stream's first read, which a person cannot stage by hand; node --test tests/unit/log-tail.test.cjs reproduces it on a real file (also red on trunk), and the two new tests in tests/unit/ipc-wiring.test.cjs reach the rename decision directly, with the file present and with it gone.

What the journey cannot reach, for a pass by hand against a real site with a built wordpress-develop and the dev server started:

Starting state: a site whose dev server is running, with the Logs tray open on the Debug.log tab and at least one line in the pane. build/wp-content/debug.log under the site is the file; the pane names its path.

  1. Open that file in an editor that saves atomically (VS Code, TextEdit), add a line at the end, save, and close the tray with the footer's Logs button first so the count is visible. Expected: the footer's badge says 1, and opening the tray shows the added line once under the lines that were already there.
  2. From a terminal in the site's directory (either shell), append another line: echo "PHP Notice: by hand" >> build/wp-content/debug.log. Expected: it arrives in the pane without anything being clicked. This is the tail on the new file.
  3. Click Clear, then append again. Expected: the pane empties and the appended line arrives, as before this change.

What must not have happened: no line in the pane appears twice; the badge after the save counts the added line, not the file's line count; the marker line about an earlier run appears only once, under the backlog the start replayed, and not after the save.

Risks and limitations

  • A byte WordPress writes between the tail's statSync and its fs.watch attaching, two synchronous calls apart, now waits for the next change event instead of arriving at once and then again. WordPress logs in bursts, so the next line brings it.
  • A different and larger file put at the path while the tail is attached, such as an old debug.log copied over it, is read from the old offset and its head is not shown. The same trade-off planTailRead already makes for a same-length rewrite on a change; a smaller one is replayed from its start, as a Clear is.
  • The rename path's events were checked by script on macOS (one rename, file present, old watcher dead) and not by hand on Windows, where fs.watch watches the directory and may send a change as well; every ordering I could construct reads each byte once, the wiring tests pin the decision without a real watcher, and the journey runs on Windows in CI.
  • A file removed and recreated so fast that the rename arrives after the new file exists is taken for the old one: with the first write longer than the old file, its first bytes up to the old size are skipped. The events are asynchronous and a few milliseconds behind, so the window exists; nothing reproduces it in practice, and the inode would not tell the cases apart either, since an editor's save is a new inode too.
  • The task described this function as also changed by the badge pull request on redesign/wpds; that branch merged into trunk as Redesign the interface with the WordPress Design System #591 on 2026-10-05 with Count unseen debug.log lines on the footer's Logs button #614 in it, so this change edits the badge version directly and there is nothing left to merge it into.

Related

Follow-up to #217 (the debug.log tail and its read planner), #558 (the logs tray) and #614 (the unseen count, which is what the second finding miscounts).


Design decisions and alternatives considered

Why end rather than reordering the stat and the watcher. Attaching fs.watch before the statSync would also close the gap in the first risk above, but a stat that fails after the watcher is attached leaves a watcher to close on a failure path, and the duplicate was the user-facing bug. The bound is one field, and the plan is what the tests read.

Why the decision lives in attachFileWatcher and not in a third planner. The two planners already cover both answers; the only new fact is whether the tail had a file before, which is lastSize > 0, and the place that knows the file is gone is watchForFile. A planReattachRead(lastSize, size) would be a one-line ternary with its own tests and nothing the wiring tests do not already pin.

Why the file found after a rename is not always treated as new. It is new in the sense of being another inode, but the app cannot tell an editor's save from a grunt clean that way; the size can, as far as a size can, and the pane's own comment already chose missing a same-length rewrite over duplicating the file. The case this gets wrong, a larger unrelated file placed at the path, is in Risks.

Review outcome (required — see AGENTS.md)

1 [fix here] · 0 [follow-up] — fixed. Pre-PR review per .github/instructions/code-review.instructions.md; CodeRabbit not yet requested.

  • Review: completed — fresh agent context (Claude Fable 5.1, a subagent given the diff and the standard, separate from the session that wrote the code); reviewed head d18513a673e034beecc8e769d10ea4f12723dbf8 / base 0cf005f86417f786397a792b290f6748b21adbcb (origin/trunk); npm run lint and npm test green on that head (1961 tests, 0 failures), the two touched unit files re-run by the reviewer, the journey read and not run. Finding, tests 🟡 [fix here]: the wiring test for a file removed and brought back could not go red, because a 5-byte file after a 20-byte one is read from its start by either rule. Fixed in ce9fb0f by bringing the file back at 25 bytes; confirmed red with the reset removed and green with it. Two style notes taken: the comment in attachFileWatcher no longer says the offset is 0 only when there was no file (a Clear zeroes it too), and the remove-and-recreate window is now in Risks. Verified and not findings: no range the planners produce can make fs.createReadStream throw; two in-flight streams read disjoint ranges; every path through watchForFile is right; Windows may deliver two rename events for a replace and the second reads nothing; the journey's new matches are scoped to the tray and run after the exact byte stream was asserted.
  • Since review: d18513a → ce9fb0f3d1da8ad4623777209c21394f725c9d2d checked: one commit, the test fix above plus the comment, lint and the three wp-debug:start wiring tests green; no other file touched.
Implementation notes
  • src/log-tail.js: end: size - 1 on every range; header comment says why. A one-byte file is the edge the new unit test pins ({ start: 0, end: 0 }).
  • src/main.js, startWpDebugTail: attachFileWatcher chooses planTailRead when state.lastSize > 0, planInitialRead otherwise; watchForFile zeroes lastSize when the file is absent; reattachAfterLoss no longer zeroes it. The fromBefore flag and the marker are as they were, so a file found after a rename is told as news without the marker.
  • tests/unit/log-tail.test.cjs: ranges updated, a range-end test, and a real-file test that opens a stream from a plan, appends before it reads, and asserts the stream carried only what the plan saw.
  • tests/unit/ipc-wiring.test.cjs: fakeTailedFile stands in for existsSync, statSync, createReadStream and watch on the log path only, falling through to the real fs elsewhere, so a watcher's event is delivered with the file in the state the test says. One test for a rename with the file present (continued from the offset, old watcher closed, one new watcher, no marker, nothing read for an unchanged size), one for a rename with the file gone (folder watched, file read whole when it comes back).
  • tests/e2e/journeys/logs.spec.js: the "first appears" test gains an editor's save over the file and an append after it, asserting the exact text the window was told and the count on the footer.
  • Confirmation scripts, not committed: a stream opened from { start: 0 } on a 14-byte file, appended to before it read, delivered 21 bytes, and a re-read from 14 delivered the same 7 again; fs.watch on a file renamed over on macOS delivered one rename with the path present at its new size and nothing for a later append.
Screenshots or recording

Nothing on screen changed shape: the same pane shows fewer lines in two cases where it showed too many.

🤖 Generated with Claude Code

zaerl and others added 2 commits October 6, 2026 10:21
…d over itself

Every read the tail plans now ends at the size it was planned from. A stream
opened with a `start` alone reads to wherever the file ends when it gets round
to reading, which is after the stat that planned it, so a line WordPress
appended in between arrived from that stream and again from the next 'change'
event, whose read starts at the remembered size. The panel showed those lines
twice, and counted them once.

A debug.log replaced under the watcher, as an editor saves it, reports 'rename'
with the file still at the path. The tail used to forget its offset and replay
the file, so every line it held was shown a second time and counted as unseen.
It now carries on from where it stopped, by the same rule a change uses: a
larger file is read from the old offset, an unchanged one not at all, a smaller
one from its start. The offset is forgotten only when the file is actually
gone, so what appears at the path later is read whole, as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A 5-byte file after a 20-byte one is read from its start by either rule, so
the test passed with the reset removed. The file now comes back longer than the
old one, which is the realistic shape too: a `grunt clean` removes a short file
and the first notice WordPress writes to the new one is longer. Found by the
pre-PR review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: WordPress/contributor-toolkit/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0e278d2b-6695-49b9-8113-5ad648645af5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: WordPress/contributor-toolkit/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 11389095-3be6-40d4-ab72-82673bac3023
📥 Commits

Reviewing files that changed from the base of the PR and between 0cf005f and ce9fb0f.

📒 Files selected for processing (5)
  • src/log-tail.js
  • src/main.js
  • tests/e2e/journeys/logs.spec.js
  • tests/unit/ipc-wiring.test.cjs
  • tests/unit/log-tail.test.cjs

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Initial and tail reads now end at the final byte observed when the read is planned. Watcher reattachment resumes from the previous offset after a file replacement. If the file is absent, the offset resets so a returning file is read from the beginning. Unit and end-to-end tests cover bounded reads, replacement, removal, recreation, and subsequent appends.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to ce9fb

A line appended during log-file reattachment may not appear until another change. The PR is mergeable with awareness of this narrow delay.

🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description follows the repository template and explains the problem, approach, testing, risks, related work, design decisions, and review outcome. It gives clear reproduction steps and reports wh…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@zaerl

zaerl commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@zaerl
zaerl merged commit 2ad5e93 into trunk Oct 6, 2026
8 checks passed
@zaerl
zaerl deleted the fix/debug-log-tail-bounds branch October 6, 2026 16:04
@zaerl zaerl self-assigned this Oct 6, 2026
@zaerl zaerl added this to the v2.0.0 milestone Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant