Skip to content

Commit 0bbf270

Browse files
Michael Glassclaude
andcommitted
Don't close the queue while a watcher swap is in flight
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
1 parent d346922 commit 0bbf270

2 files changed

Lines changed: 58 additions & 11 deletions

File tree

packages/@tailwindcss-cli/src/commands/build/index.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, it } from 'vitest'
22
import { serializeBatches } from '../../utils/serial-batches'
3-
import { createWatchers, filterChangedFiles } from './index'
3+
import { createWatchers, filterChangedFiles, shutdownWatchMode } from './index'
44

55
type WatchEvent = { type: 'create' | 'update' | 'delete'; path: string }
66
type WatchCallback = (error: Error | null, events: WatchEvent[]) => Promise<void>
@@ -121,3 +121,29 @@ it('writes the newest change last when an earlier rebuild is slower', async () =
121121

122122
expect(written).toEqual(['older-change', 'newer-change'])
123123
})
124+
125+
it('does not close the queue while a watcher swap is still flushing', async () => {
126+
// A rebuild swaps the watcher generation, and the old generation flushes what
127+
// it collected as it is torn down. If shutdown closes the queue first, those
128+
// files land in a closed queue and the process exits with stale CSS.
129+
let closed = false
130+
let flushed: string[] = []
131+
let finishSwap!: () => void
132+
let swap = new Promise<void>((resolve) => (finishSwap = resolve)).then(() => {
133+
flushed.push('collected-during-swap')
134+
})
135+
136+
let shutdown = shutdownWatchMode(swap, [], {
137+
async close() {
138+
closed = true
139+
},
140+
})
141+
await nextTask()
142+
expect(closed).toBe(false)
143+
144+
finishSwap()
145+
await shutdown
146+
147+
expect(flushed).toEqual(['collected-during-swap'])
148+
expect(closed).toBe(true)
149+
})

packages/@tailwindcss-cli/src/commands/build/index.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,10 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
327327

328328
let [compiler, scanner] = await handleError(() => createCompiler(input, I))
329329
let cleanupWatchers: (() => Promise<void>)[] = []
330+
// A rebuild swaps the watcher generation. Shutdown must not close the queue
331+
// while that swap is in flight, or the files the old generation flushes are
332+
// pushed into a closed queue and silently dropped.
333+
let watcherSwap: Promise<unknown> = Promise.resolve()
330334
let finishInitialBuild!: () => void
331335
let initialBuildFinished = new Promise<void>((resolve) => (finishInitialBuild = resolve))
332336
let eventBatches: SerialBatches<string> | null = null
@@ -409,12 +413,14 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
409413
)
410414
DEBUG && I.end('Setup new watchers')
411415

412-
// Clear old watchers
416+
// Clear old watchers. Register the new generation *before* awaiting
417+
// the old one, so shutdown never observes an empty cleanup list.
413418
DEBUG && I.start('Cleanup old watchers')
414-
await Promise.all(cleanupWatchers.splice(0).map((cleanup) => cleanup()))
415-
DEBUG && I.end('Cleanup old watchers')
416-
419+
let previousCleanups = cleanupWatchers.splice(0)
417420
cleanupWatchers.push(newWatchers.cleanup)
421+
watcherSwap = Promise.all(previousCleanups.map((cleanup) => cleanup()))
422+
await watcherSwap
423+
DEBUG && I.end('Cleanup old watchers')
418424

419425
// Re-compile the CSS
420426
DEBUG && I.start('Build CSS')
@@ -509,12 +515,10 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
509515
// disable this behavior with `--watch=always`.
510516
if (args['--watch'] !== 'always') {
511517
process.stdin.on('end', () => {
512-
Promise.all(cleanupWatchers.map((fn) => fn()))
513-
.then(() => eventBatches?.close())
514-
.then(
515-
() => process.exit(0),
516-
() => process.exit(1),
517-
)
518+
shutdownWatchMode(watcherSwap, cleanupWatchers, eventBatches).then(
519+
() => process.exit(0),
520+
() => process.exit(1),
521+
)
518522
})
519523
}
520524

@@ -706,6 +710,23 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
706710
// Load `@parcel/watcher` lazily so a missing or broken native binding only
707711
// affects `--watch` (without `--poll`), instead of crashing one-off builds and
708712
// polling mode as well.
713+
/// Shut watch mode down in an order that cannot drop collected files.
714+
///
715+
/// A rebuild swaps the watcher generation, and the old generation flushes what it
716+
/// collected as it is torn down. Closing the queue before that flush lands means the
717+
/// files are pushed into a closed queue and ignored, so the process exits successfully
718+
/// with stale CSS. Wait for an in-flight swap first, then the current generation, and
719+
/// only then close.
720+
export async function shutdownWatchMode(
721+
watcherSwap: Promise<unknown>,
722+
cleanups: (() => Promise<void>)[],
723+
batches: { close(): Promise<void> } | null,
724+
) {
725+
await watcherSwap
726+
await Promise.all(cleanups.map((cleanup) => cleanup()))
727+
await batches?.close()
728+
}
729+
709730
async function loadWatcher(): Promise<typeof import('@parcel/watcher')> {
710731
try {
711732
return (await import('@parcel/watcher')).default

0 commit comments

Comments
 (0)