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
This commit is contained in:
Michael Glass 2026-08-31 16:03:22 +02:00
parent d346922c11
commit 0bbf270efc
No known key found for this signature in database
GPG key ID: 25FC9DFE17C802D3
2 changed files with 58 additions and 11 deletions

View file

@ -1,6 +1,6 @@
import { expect, it } from 'vitest'
import { serializeBatches } from '../../utils/serial-batches'
import { createWatchers, filterChangedFiles } from './index'
import { createWatchers, filterChangedFiles, shutdownWatchMode } from './index'
type WatchEvent = { type: 'create' | 'update' | 'delete'; path: string }
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 () =
expect(written).toEqual(['older-change', 'newer-change'])
})
it('does not close the queue while a watcher swap is still flushing', async () => {
// A rebuild swaps the watcher generation, and the old generation flushes what
// it collected as it is torn down. If shutdown closes the queue first, those
// files land in a closed queue and the process exits with stale CSS.
let closed = false
let flushed: string[] = []
let finishSwap!: () => void
let swap = new Promise<void>((resolve) => (finishSwap = resolve)).then(() => {
flushed.push('collected-during-swap')
})
let shutdown = shutdownWatchMode(swap, [], {
async close() {
closed = true
},
})
await nextTask()
expect(closed).toBe(false)
finishSwap()
await shutdown
expect(flushed).toEqual(['collected-during-swap'])
expect(closed).toBe(true)
})

View file

@ -327,6 +327,10 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let [compiler, scanner] = await handleError(() => createCompiler(input, I))
let cleanupWatchers: (() => Promise<void>)[] = []
// A rebuild swaps the watcher generation. Shutdown must not close the queue
// while that swap is in flight, or the files the old generation flushes are
// pushed into a closed queue and silently dropped.
let watcherSwap: Promise<unknown> = Promise.resolve()
let finishInitialBuild!: () => void
let initialBuildFinished = new Promise<void>((resolve) => (finishInitialBuild = resolve))
let eventBatches: SerialBatches<string> | null = null
@ -409,12 +413,14 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
)
DEBUG && I.end('Setup new watchers')
// Clear old watchers
// Clear old watchers. Register the new generation *before* awaiting
// the old one, so shutdown never observes an empty cleanup list.
DEBUG && I.start('Cleanup old watchers')
await Promise.all(cleanupWatchers.splice(0).map((cleanup) => cleanup()))
DEBUG && I.end('Cleanup old watchers')
let previousCleanups = cleanupWatchers.splice(0)
cleanupWatchers.push(newWatchers.cleanup)
watcherSwap = Promise.all(previousCleanups.map((cleanup) => cleanup()))
await watcherSwap
DEBUG && I.end('Cleanup old watchers')
// Re-compile the CSS
DEBUG && I.start('Build CSS')
@ -509,12 +515,10 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// disable this behavior with `--watch=always`.
if (args['--watch'] !== 'always') {
process.stdin.on('end', () => {
Promise.all(cleanupWatchers.map((fn) => fn()))
.then(() => eventBatches?.close())
.then(
() => process.exit(0),
() => process.exit(1),
)
shutdownWatchMode(watcherSwap, cleanupWatchers, eventBatches).then(
() => process.exit(0),
() => process.exit(1),
)
})
}
@ -706,6 +710,23 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Load `@parcel/watcher` lazily so a missing or broken native binding only
// affects `--watch` (without `--poll`), instead of crashing one-off builds and
// polling mode as well.
/// Shut watch mode down in an order that cannot drop collected files.
///
/// A rebuild swaps the watcher generation, and the old generation flushes what it
/// collected as it is torn down. Closing the queue before that flush lands means the
/// files are pushed into a closed queue and ignored, so the process exits successfully
/// with stale CSS. Wait for an in-flight swap first, then the current generation, and
/// only then close.
export async function shutdownWatchMode(
watcherSwap: Promise<unknown>,
cleanups: (() => Promise<void>)[],
batches: { close(): Promise<void> } | null,
) {
await watcherSwap
await Promise.all(cleanups.map((cleanup) => cleanup()))
await batches?.close()
}
async function loadWatcher(): Promise<typeof import('@parcel/watcher')> {
try {
return (await import('@parcel/watcher')).default