Re-read the watcher swap while shutting down

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

View file

@ -133,7 +133,7 @@ it('does not close the queue while a watcher swap is still flushing', async () =
flushed.push('collected-during-swap')
})
let shutdown = shutdownWatchMode(swap, [], {
let shutdown = shutdownWatchMode(() => swap, [], {
async close() {
closed = true
},
@ -147,3 +147,38 @@ it('does not close the queue while a watcher swap is still flushing', async () =
expect(flushed).toEqual(['collected-during-swap'])
expect(closed).toBe(true)
})
it('waits for a watcher swap that starts after shutdown begins', async () => {
// A rebuild already in flight can swap watchers while we are shutting down.
// Reading the swap once captures whichever was current when stdin closed, and
// closes the queue while the later one is still flushing.
let closed = false
let flushed: string[] = []
let finishFirstSwap!: () => void
let firstSwap = new Promise<void>((resolve) => (finishFirstSwap = resolve)).then(() => {
flushed.push('first-swap')
})
let finishSecondSwap!: () => void
let secondSwap = new Promise<void>((resolve) => (finishSecondSwap = resolve)).then(() => {
flushed.push('second-swap')
})
let currentSwap: Promise<unknown> = firstSwap
let shutdown = shutdownWatchMode(() => currentSwap, [], {
async close() {
closed = true
},
})
// The in-flight rebuild starts its own swap while shutdown is already waiting.
currentSwap = secondSwap
finishFirstSwap()
await nextTask()
expect(closed).toBe(false)
finishSecondSwap()
await shutdown
expect(flushed).toEqual(['first-swap', 'second-swap'])
expect(closed).toBe(true)
})

View file

@ -515,7 +515,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// disable this behavior with `--watch=always`.
if (args['--watch'] !== 'always') {
process.stdin.on('end', () => {
shutdownWatchMode(watcherSwap, cleanupWatchers, eventBatches).then(
shutdownWatchMode(() => watcherSwap, cleanupWatchers, eventBatches).then(
() => process.exit(0),
() => process.exit(1),
)
@ -718,11 +718,19 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
/// 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>,
pendingSwap: () => Promise<unknown>,
cleanups: (() => Promise<void>)[],
batches: { close(): Promise<void> } | null,
) {
await watcherSwap
// A rebuild already in flight can start its own swap while we are shutting
// down, so read the current one each time round rather than capturing it
// once. Capturing it once waits for the swap that happened to be current when
// stdin closed and closes the queue while a later one is still flushing.
let awaited: Promise<unknown> | undefined
while (awaited !== pendingSwap()) {
awaited = pendingSwap()
await awaited
}
await Promise.all(cleanups.map((cleanup) => cleanup()))
await batches?.close()
}