Skip to content

Commit ad7dbda

Browse files
Fix CLI not watching atomically renamed files (#9173)
* Fix CLI not watching atomically renamed files Chokdar should take care of this itself but sometimes it doesn’t do so OR is otherwise very sensitive to timing problems * Force chokidar to always check for atomic writes * Handle repeated atomic saves by retrying file reads * Update changelog
1 parent 7b6ac54 commit ad7dbda

File tree

2 files changed

+78
-0
lines changed

2 files changed

+78
-0
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Sort tags before classes when `@applying` a selector with joined classes ([#9107](https://github.com/tailwindlabs/tailwindcss/pull/9107))
2222
- Remove invalid `outline-hidden` utility ([#9147](https://github.com/tailwindlabs/tailwindcss/pull/9147))
2323
- Honor the `hidden` attribute on elements in preflight ([#9174](https://github.com/tailwindlabs/tailwindcss/pull/9174))
24+
- Don't stop watching atomically renamed files ([#9173](https://github.com/tailwindlabs/tailwindcss/pull/9173))
2425

2526
## [3.1.8] - 2022-08-05
2627

src/cli.js

+77
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,11 @@ async function build() {
843843
}
844844

845845
watcher = chokidar.watch([...contextDependencies, ...extractFileGlobs(config)], {
846+
// Force checking for atomic writes in all situations
847+
// This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked
848+
// This only works when watching directories though
849+
atomic: true,
850+
846851
usePolling: shouldPoll,
847852
interval: shouldPoll ? pollInterval : undefined,
848853
ignoreInitial: true,
@@ -855,6 +860,7 @@ async function build() {
855860
})
856861

857862
let chain = Promise.resolve()
863+
let pendingRebuilds = new Set()
858864

859865
watcher.on('change', async (file) => {
860866
if (contextDependencies.has(file)) {
@@ -885,6 +891,77 @@ async function build() {
885891
}
886892
})
887893

894+
/**
895+
* When rapidly saving files atomically a couple of situations can happen:
896+
* - The file is missing since the external program has deleted it by the time we've gotten around to reading it from the earlier save.
897+
* - The file is being written to by the external program by the time we're going to read it and is thus treated as busy because a lock is held.
898+
*
899+
* To work around this we retry reading the file a handful of times with a delay between each attempt
900+
*
901+
* @param {string} path
902+
* @param {number} tries
903+
* @returns {string}
904+
* @throws {Error} If the file is still missing or busy after the specified number of tries
905+
*/
906+
async function readFileWithRetries(path, tries = 5) {
907+
for (let n = 0; n < tries; n++) {
908+
try {
909+
return await fs.promises.readFile(path, 'utf8')
910+
} catch (err) {
911+
if (n < tries) {
912+
if (err.code === 'ENOENT' || err.code === 'EBUSY') {
913+
await new Promise((resolve) => setTimeout(resolve, 10))
914+
915+
continue
916+
}
917+
}
918+
919+
throw err
920+
}
921+
}
922+
}
923+
924+
// Restore watching any files that are "removed"
925+
// This can happen when a file is pseudo-atomically replaced (a copy is created, overwritten, the old one is unlinked, and the new one is renamed)
926+
// TODO: An an optimization we should allow removal when the config changes
927+
watcher.on('unlink', (file) => watcher.add(file))
928+
929+
// Some applications such as Visual Studio (but not VS Code)
930+
// will only fire a rename event for atomic writes and not a change event
931+
// This is very likely a chokidar bug but it's one we need to work around
932+
// We treat this as a change event and rebuild the CSS
933+
watcher.on('raw', (evt, filePath, meta) => {
934+
if (evt !== 'rename') {
935+
return
936+
}
937+
938+
filePath = path.resolve(meta.watchedPath, filePath)
939+
940+
// Skip since we've already queued a rebuild for this file that hasn't happened yet
941+
if (pendingRebuilds.has(filePath)) {
942+
return
943+
}
944+
945+
pendingRebuilds.add(filePath)
946+
947+
chain = chain.then(async () => {
948+
let content
949+
950+
try {
951+
content = await readFileWithRetries(path.resolve(filePath))
952+
} finally {
953+
pendingRebuilds.delete(filePath)
954+
}
955+
956+
changedContent.push({
957+
content,
958+
extension: path.extname(filePath).slice(1),
959+
})
960+
961+
await rebuild(config)
962+
})
963+
})
964+
888965
watcher.on('add', async (file) => {
889966
chain = chain.then(async () => {
890967
changedContent.push({

0 commit comments

Comments
 (0)