Skip to content

Prevent older watch rebuilds from overwriting newer CSS - #20446

Open
michaelglass wants to merge 7 commits into
tailwindlabs:mainfrom
michaelglass:fix/serialize-cli-watch-rebuilds
Open

Prevent older watch rebuilds from overwriting newer CSS#20446
michaelglass wants to merge 7 commits into
tailwindlabs:mainfrom
michaelglass:fix/serialize-cli-watch-rebuilds

Conversation

@michaelglass

@michaelglass michaelglass commented Aug 31, 2026

Copy link
Copy Markdown

Disclosure: This PR was generated entirely by Codex.

What I saw

I paused one rebuild inside a plugin, then changed the input again. The newer rebuild finished first, but the older rebuild finished afterward and overwrote it. The generated CSS was stale even though the newest edit had already been processed.

Fix

Wait for each rebuild before starting the next one. Keep changes that arrive while a rebuild is running, process them afterward, and flush a pending change before watch mode shuts down.

Also drop the files we write ourselves from a batch. A rebuild was only skipped when the output file was the single change, so our own output arriving alongside a real edit still triggered an extra rebuild.

And let a rebuild finish before shutting down. Closing stdin in the middle of one ended watch mode before the watchers had handed over what they had collected, so those changes were dropped and we exited as if all was well. Shutdown now waits for the rebuild in progress, then takes the watchers down, and only stops listening once what they hand over has been dealt with.

Test plan

  • Added a test that makes the older change's rebuild the slow one and checks the newer result is the one that gets written. It fails without the fix — against a non-serialized queue it produces ['newer-change', 'older-change'].
  • Added tests that a rebuild in progress finishes before the watchers are taken down, and that what they hand over on the way out is still processed. Both fail without the fix — the first takes them down too early, the second throws the handover away.
  • Added tests for the queue on its own: one rebuild at a time, changes arriving mid-rebuild coalesced and run afterward, work ignored after close, a failing rebuild reported without stopping the queue, and waiting for in-flight work without closing.
  • Added tests for dropping the files we write ourselves, and for shutting down while file inspection or debounce work is still pending.
  • pnpm test — 5511 tests pass, plus the Rust suite.
  • pnpm prettier --check packages/@tailwindcss-cli/src/commands/build/index.test.ts — passes.
  • tsc --noEmit — no new type errors introduced by this change.

[ci-all]

@michaelglass
michaelglass requested a review from a team as a code owner August 31, 2026 09:47
@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the current shutdown flow waits for queued rebuilds, tears down and flushes watchers while the queue remains open, and then closes only after accepted work completes.

Reviews (9): Last reviewed commit: "Test drain directly" | Re-trigger Greptile

Comment thread packages/@tailwindcss-cli/src/commands/build/index.ts Outdated
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Watch mode now routes filtered file events through a serialized batch queue. The queue waits for the initial build, coalesces pending batches, reports callback errors, and drains accepted work during shutdown. Watcher swaps and active callbacks are tracked. Shutdown awaits swaps and cleanups before closing the queue. Tests cover queue behavior and shutdown ordering.

Merge Risk: 🔵 Low · up to 0bbf2

Watch mode can still lose file changes and exit cleanly if stdin closes during watcher replacement. This is a bounded, localized correctness risk that should receive owner awareness or follow-up.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly states the primary change: preventing older watch-mode rebuilds from overwriting newer CSS.
Description check ✅ Passed The description directly explains the stale CSS issue, serialized rebuild fix, output filtering, shutdown behavior, and test coverage.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 43b2b5ea-e581-4229-b730-4a13a35cdd99

📥 Commits

Reviewing files that changed from the base of the PR and between 90f8ff4 and c8f0653.

📒 Files selected for processing (3)
  • packages/@tailwindcss-cli/src/commands/build/index.ts
  • packages/@tailwindcss-cli/src/utils/serial-batches.test.ts
  • packages/@tailwindcss-cli/src/utils/serial-batches.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment thread packages/@tailwindcss-cli/src/utils/serial-batches.ts Outdated
@michaelglass
michaelglass marked this pull request as draft August 31, 2026 10:03
@michaelglass
michaelglass force-pushed the fix/serialize-cli-watch-rebuilds branch from c8f0653 to 1209165 Compare August 31, 2026 10:23
@michaelglass michaelglass changed the title Fix overlapping CLI watch rebuilds Serialize CLI watch rebuilds Aug 31, 2026
@michaelglass michaelglass changed the title Serialize CLI watch rebuilds Keep watch output current during quick edits Aug 31, 2026
@michaelglass
michaelglass force-pushed the fix/serialize-cli-watch-rebuilds branch 3 times, most recently from a13f399 to 8b55bd9 Compare August 31, 2026 12:34
@michaelglass
michaelglass force-pushed the fix/serialize-cli-watch-rebuilds branch from 8b55bd9 to 35a1670 Compare August 31, 2026 12:34
@michaelglass
michaelglass marked this pull request as ready for review August 31, 2026 12:55
@michaelglass michaelglass changed the title Keep watch output current during quick edits Prevent older watch rebuilds from overwriting newer CSS Aug 31, 2026
@michaelglass
michaelglass marked this pull request as draft August 31, 2026 13:00
@michaelglass
michaelglass marked this pull request as ready for review August 31, 2026 13:01
@michaelglass
michaelglass marked this pull request as draft August 31, 2026 13:20
The existing tests cover the scheduling: serial-batches asserts that only
one rebuild runs at a time, and the watcher tests cover event filtering and
shutdown flushing. Neither asserts the outcome the bug was reported as —
that what ends up written is the newest change.

This drives the watcher wiring with an older change whose rebuild is slow
and a newer change whose rebuild is fast, and asserts the newer result is
written last. Against the previous fire-and-forget behaviour the two
rebuilds overlap and the stale one lands on top, so the test fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lcj4iQ3fBxMwAu2rf4zLbC
@michaelglass
michaelglass marked this pull request as ready for review August 31, 2026 13:33

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 96c3020e-df7f-41d9-9fea-333af2a1afb7

📥 Commits

Reviewing files that changed from the base of the PR and between 35a1670 and 2b9a5c4.

📒 Files selected for processing (1)
  • packages/@tailwindcss-cli/src/commands/build/index.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

Comment thread packages/@tailwindcss-cli/src/commands/build/index.test.ts Outdated
The test gated the slow rebuild on `written.length === 0`, but the first
rebuild is suspended at that await, so the second rebuild saw a length of 0
too and waited on the same promise. Both then resolved in call order and the
assertion held even with no serialization at all — the test could not fail.

Count rebuilds instead, and wait for the first one to have actually started
before submitting the second change. Verified both directions against a
non-serialized queue: the old shape passed, the new one produces
['newer-change', 'older-change'] and fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lcj4iQ3fBxMwAu2rf4zLbC
Comment thread packages/@tailwindcss-cli/src/commands/build/index.ts Outdated
A rebuild spliced the old cleanups out of `cleanupWatchers` and only pushed the
new generation back after awaiting them. If stdin closed inside that window,
shutdown saw an empty list, resolved immediately and closed the queue — so the
files the old generation flushed on its way out were pushed into a closed queue
and ignored, and the process exited 0 with stale CSS.

Register the new generation before awaiting the old one, so the list is never
empty, and track the in-flight swap so shutdown waits for it before closing.

The ordering now lives in `shutdownWatchMode` so it can be asserted directly:
the queue must still be open while a swap is flushing. Against the previous
ordering that assertion fails — the queue is already closed and the flush has
not landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lcj4iQ3fBxMwAu2rf4zLbC
Comment thread packages/@tailwindcss-cli/src/commands/build/index.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1da3bc6a-0959-4069-b755-bbc48921d734

📥 Commits

Reviewing files that changed from the base of the PR and between d346922 and 0bbf270.

📒 Files selected for processing (2)
  • packages/@tailwindcss-cli/src/commands/build/index.test.ts
  • packages/@tailwindcss-cli/src/commands/build/index.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

Comment thread packages/@tailwindcss-cli/src/commands/build/index.ts Outdated
Shutdown captured the swap promise once, so it waited for whichever swap was
current when stdin closed. A rebuild already in flight can start its own swap
after that point, and the queue was then closed while the later one was still
flushing — the same dropped changes, one interleaving further out.

Read the current swap each time round instead, until it stops changing.

The regression test drives that interleaving directly: it replaces the swap
while shutdown is already waiting, and asserts the queue stays open until the
later one has flushed. Against capture-by-value the queue is already closed
with only the first swap's work landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lcj4iQ3fBxMwAu2rf4zLbC
Comment thread packages/@tailwindcss-cli/src/commands/build/index.ts Outdated
Tracking the swap promise was the wrong shape. It could always be read at a
moment when the in-flight rebuild had not assigned its swap yet, so shutdown
saw the old one settle, closed the queue, and dropped whatever the later
generation flushed. Each fix narrowed that window without closing it.

A full rebuild runs inside the queue, so the queue already knows when one is in
flight — including a rebuild that has not yet replaced its watchers. Add
`drain()`, wait for it before tearing the watchers down, and close only after
their flush has landed.

That removes the swap bookkeeping entirely.

Both orderings are pinned: cleanup must not run while a rebuild is in flight,
and what the watchers flush on the way out must still be processed. Against
no-drain the cleanup runs early; against closing first the flush is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lcj4iQ3fBxMwAu2rf4zLbC
drain() is public on SerialBatches but was only exercised through shutdown.
Cover it on its own: it waits for in-flight work and leaves the queue open, so
a shutdown can drain before the watchers flush and still have that work
accepted, and it returns immediately when nothing is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lcj4iQ3fBxMwAu2rf4zLbC
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