Skip to content

Commit 5302f30

Browse files
Michael Glassclaude
andcommitted
Drain the queue on shutdown instead of tracking watcher swaps
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
1 parent 26fcae0 commit 5302f30

3 files changed

Lines changed: 60 additions & 71 deletions

File tree

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

Lines changed: 40 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -122,63 +122,55 @@ it('writes the newest change last when an earlier rebuild is slower', async () =
122122
expect(written).toEqual(['older-change', 'newer-change'])
123123
})
124124

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')
125+
it('waits for an in-flight rebuild before tearing down the watchers', async () => {
126+
// A full rebuild runs inside the queue and swaps the watcher generation while
127+
// it does. Tearing the watchers down first races that swap.
128+
let order: string[] = []
129+
let finishRebuild!: () => void
130+
let rebuildDone = new Promise<void>((resolve) => (finishRebuild = resolve))
131+
let queue = serializeBatches<string>(async () => {
132+
order.push('rebuild:start')
133+
await rebuildDone
134+
order.push('rebuild:end')
134135
})
135136

136-
let shutdown = shutdownWatchMode(() => swap, [], {
137-
async close() {
138-
closed = true
139-
},
140-
})
137+
void queue.push(['change'])
141138
await nextTask()
142-
expect(closed).toBe(false)
143139

144-
finishSwap()
140+
let shutdown = shutdownWatchMode(
141+
[
142+
async () => {
143+
order.push('cleanup')
144+
},
145+
],
146+
queue,
147+
)
148+
await nextTask()
149+
expect(order).toEqual(['rebuild:start'])
150+
151+
finishRebuild()
145152
await shutdown
146153

147-
expect(flushed).toEqual(['collected-during-swap'])
148-
expect(closed).toBe(true)
154+
expect(order).toEqual(['rebuild:start', 'rebuild:end', 'cleanup'])
149155
})
150156

151-
it('waits for a watcher swap that starts after shutdown begins', async () => {
152-
// A rebuild already in flight can swap watchers while we are shutting down.
153-
// Reading the swap once captures whichever was current when stdin closed, and
154-
// closes the queue while the later one is still flushing.
155-
let closed = false
156-
let flushed: string[] = []
157-
let finishFirstSwap!: () => void
158-
let firstSwap = new Promise<void>((resolve) => (finishFirstSwap = resolve)).then(() => {
159-
flushed.push('first-swap')
160-
})
161-
let finishSecondSwap!: () => void
162-
let secondSwap = new Promise<void>((resolve) => (finishSecondSwap = resolve)).then(() => {
163-
flushed.push('second-swap')
164-
})
165-
166-
let currentSwap: Promise<unknown> = firstSwap
167-
let shutdown = shutdownWatchMode(() => currentSwap, [], {
168-
async close() {
169-
closed = true
170-
},
157+
it('processes what the watchers flush on the way out', async () => {
158+
// The watchers flush what they collected as they are torn down. That has to
159+
// land in a queue that is still open, or it is dropped and we exit as if all
160+
// was well.
161+
let processed: string[][] = []
162+
let queue = serializeBatches<string>(async (files) => {
163+
processed.push(files)
171164
})
172165

173-
// The in-flight rebuild starts its own swap while shutdown is already waiting.
174-
currentSwap = secondSwap
175-
finishFirstSwap()
176-
await nextTask()
177-
expect(closed).toBe(false)
178-
179-
finishSecondSwap()
180-
await shutdown
166+
await shutdownWatchMode(
167+
[
168+
async () => {
169+
void queue.push(['flushed-on-shutdown'])
170+
},
171+
],
172+
queue,
173+
)
181174

182-
expect(flushed).toEqual(['first-swap', 'second-swap'])
183-
expect(closed).toBe(true)
175+
expect(processed).toEqual([['flushed-on-shutdown']])
184176
})

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

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -327,10 +327,6 @@ 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()
334330
let finishInitialBuild!: () => void
335331
let initialBuildFinished = new Promise<void>((resolve) => (finishInitialBuild = resolve))
336332
let eventBatches: SerialBatches<string> | null = null
@@ -418,8 +414,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
418414
DEBUG && I.start('Cleanup old watchers')
419415
let previousCleanups = cleanupWatchers.splice(0)
420416
cleanupWatchers.push(newWatchers.cleanup)
421-
watcherSwap = Promise.all(previousCleanups.map((cleanup) => cleanup()))
422-
await watcherSwap
417+
await Promise.all(previousCleanups.map((cleanup) => cleanup()))
423418
DEBUG && I.end('Cleanup old watchers')
424419

425420
// Re-compile the CSS
@@ -515,7 +510,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
515510
// disable this behavior with `--watch=always`.
516511
if (args['--watch'] !== 'always') {
517512
process.stdin.on('end', () => {
518-
shutdownWatchMode(() => watcherSwap, cleanupWatchers, eventBatches).then(
513+
shutdownWatchMode(cleanupWatchers, eventBatches).then(
519514
() => process.exit(0),
520515
() => process.exit(1),
521516
)
@@ -712,25 +707,16 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
712707
// polling mode as well.
713708
/// Shut watch mode down in an order that cannot drop collected files.
714709
///
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.
710+
/// A full rebuild runs inside the queue and swaps the watcher generation while it
711+
/// does, so draining the queue is what waits for a rebuild to finish — including
712+
/// one that has not yet replaced its watchers. Only then is it safe to tear the
713+
/// watchers down, because the files they flush on the way out have to land in a
714+
/// queue that is still open. Closing first drops them and exits with stale CSS.
720715
export async function shutdownWatchMode(
721-
pendingSwap: () => Promise<unknown>,
722716
cleanups: (() => Promise<void>)[],
723-
batches: { close(): Promise<void> } | null,
717+
batches: { drain(): Promise<void>; close(): Promise<void> } | null,
724718
) {
725-
// A rebuild already in flight can start its own swap while we are shutting
726-
// down, so read the current one each time round rather than capturing it
727-
// once. Capturing it once waits for the swap that happened to be current when
728-
// stdin closed and closes the queue while a later one is still flushing.
729-
let awaited: Promise<unknown> | undefined
730-
while (awaited !== pendingSwap()) {
731-
awaited = pendingSwap()
732-
await awaited
733-
}
719+
await batches?.drain()
734720
await Promise.all(cleanups.map((cleanup) => cleanup()))
735721
await batches?.close()
736722
}

packages/@tailwindcss-cli/src/utils/serial-batches.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
export interface SerialBatches<T> {
22
push(batch: T[]): Promise<void>
3+
/// Wait for in-flight work to finish, without closing. Callers that need the
4+
/// queue to still accept work afterwards — a shutdown that has yet to flush
5+
/// what the watchers collected — drain first and close last.
6+
drain(): Promise<void>
37
close(): Promise<void>
48
}
59

@@ -57,6 +61,13 @@ export function serializeBatches<T>(
5761

5862
return {
5963
push,
64+
async drain() {
65+
// `inFlight` is re-chained by finalization when work arrived mid-drain, so
66+
// loop until it settles rather than awaiting whichever promise is current.
67+
while (inFlight) {
68+
await inFlight
69+
}
70+
},
6071
async close() {
6172
closed = true
6273
await inFlight

0 commit comments

Comments
 (0)