Skip to content

Commit ee1c7a6

Browse files
Fix CLI watcher cleanup race (#18905)
This PR supersets #18559 and fixes the same issue reported by @Gazler. Upon testing, we noticed that it's possible that two parallel invocations of file system change events could cause some cleanup functions to get swallowed. This happens because we only remember one global cleanup function but it is possible timing wise that two calls to `createWatcher()` are created before the old watchers are cleaned and thus only one of the new cleanup functions get retained. To fix this, this PR changes `cleanupWatchers` to an array and ensures that all functions are retained. In some local testing, I was able to trigger this, based on the reproduction by @Gazler in #18559, to often call a cleanup with more than one cleanup function in the array. I'm going to paste the amazing reproduction from #18559 here as well: # Requirements We need a way to stress the CPU to slow down tailwind compilation, for example stress-ng. ``` stress-ng --cpu 16 --timeout 10 ``` It can be install with apt, homebrew or similar. # Installation There is a one-liner at the bottom to perform the required setup and run the tailwindcli. Create a new directory: ```shell mkdir twtest && cd twtest ``` Create a package.json with the correct deps. ```shell cat << 'EOF' > package.json { "dependencies": { "@tailwindcss/cli": "^4.1.11", "daisyui": "^5.0.46", "tailwindcss": "^4.1.11" } } EOF ``` Create the input css: ```shell mkdir src cat << 'EOF' > src/.input.css @import "tailwindcss" source(none); @plugin "daisyui"; @source "../core_components.ex"; @source "../home.html.heex"; @source "./input.css"; EOF ``` Install tailwind, daisyui, and some HTML to make tailwind do some work: ``` npm install wget https://raw.githubusercontent.com/phoenixframework/phoenix/refs/heads/main/installer/templates/phx_web/components/core_components.ex wget https://github.com/phoenixframework/phoenix/blob/main/installer/templates/phx_web/controllers/page_html/home.html.heex ``` # Usage This is easiest with 3 terminal windows: Start a tailwindcli watcher in one terminal: ```shell npx @tailwindcss/cli -i src/input.css -o src/output.css --watch ``` Start a stress test in another: ```shell stress-ng --cpu 16 --timeout 30 ``` Force repeated compilation in another: ```shell for i in $(seq 1 50); do touch src/input.css; sleep 0.1; done ``` # Result Once the stress test has completed, you can run: ```shell touch src/input.css ``` You should see that there is repeated output, and the duration is in the multiple seconds. If this setup doesn't cause the issue, you can also add the `-p` flag which causes the CSS to be printed, slowing things down further: ```shell npx @tailwindcss/cli -i src/input.css -p --watch ``` ## One-liner ```shell mkdir twtest && cd twtest cat << 'EOF' > package.json { "dependencies": { "@tailwindcss/cli": "^4.1.11", "daisyui": "^5.0.46", "tailwindcss": "^4.1.11" } } EOF mkdir src cat << 'EOF' > src/input.css @import "tailwindcss" source(none); @plugin "daisyui"; @source "../core_components.ex"; @source "../home.html.heex"; @source "./input.css"; EOF npm install wget https://raw.githubusercontent.com/phoenixframework/phoenix/refs/heads/main/installer/templates/phx_web/components/core_components.ex wget https://github.com/phoenixframework/phoenix/blob/main/installer/templates/phx_web/controllers/page_html/home.html.heex npx @tailwindcss/cli -i src/input.css -o src/output.css --watch ``` ## Test plan - Not able to reproduce this with a local build of the CLI after the patch is applied but was able to reproduce it again once the patch was reverted. Co-authored-by: Gary Rennie <[email protected]>
1 parent b7c7e48 commit ee1c7a6

File tree

2 files changed

+9
-8
lines changed

2 files changed

+9
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888))
1717
- Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885))
1818
- Merge suggestions when using `@utility` ([#18900](https://github.com/tailwindlabs/tailwindcss/pull/18900))
19+
- Ensure that file system watchers created when using the CLI are always cleaned up ([#18905](https://github.com/tailwindlabs/tailwindcss/pull/18905))
1920

2021
## [4.1.13] - 2025-09-03
2122

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,9 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
239239

240240
// Watch for changes
241241
if (args['--watch']) {
242-
let cleanupWatchers = await createWatchers(
243-
watchDirectories(scanner),
244-
async function handle(files) {
242+
let cleanupWatchers: (() => Promise<void>)[] = []
243+
cleanupWatchers.push(
244+
await createWatchers(watchDirectories(scanner), async function handle(files) {
245245
try {
246246
// If the only change happened to the output file, then we don't want to
247247
// trigger a rebuild because that will result in an infinite loop.
@@ -304,15 +304,15 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
304304

305305
// Setup new watchers
306306
DEBUG && I.start('Setup new watchers')
307-
let newCleanupWatchers = await createWatchers(watchDirectories(scanner), handle)
307+
let newCleanupFunction = await createWatchers(watchDirectories(scanner), handle)
308308
DEBUG && I.end('Setup new watchers')
309309

310310
// Clear old watchers
311311
DEBUG && I.start('Cleanup old watchers')
312-
await cleanupWatchers()
312+
await Promise.all(cleanupWatchers.splice(0).map((cleanup) => cleanup()))
313313
DEBUG && I.end('Cleanup old watchers')
314314

315-
cleanupWatchers = newCleanupWatchers
315+
cleanupWatchers.push(newCleanupFunction)
316316

317317
// Re-compile the CSS
318318
DEBUG && I.start('Build CSS')
@@ -362,14 +362,14 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
362362
eprintln(err.toString())
363363
}
364364
}
365-
},
365+
}),
366366
)
367367

368368
// Abort the watcher if `stdin` is closed to avoid zombie processes. You can
369369
// disable this behavior with `--watch=always`.
370370
if (args['--watch'] !== 'always') {
371371
process.stdin.on('end', () => {
372-
cleanupWatchers().then(
372+
Promise.all(cleanupWatchers.map((fn) => fn())).then(
373373
() => process.exit(0),
374374
() => process.exit(1),
375375
)

0 commit comments

Comments
 (0)