Skip to content

Commit

Permalink
Fix CLI not watching atomically renamed files (#9173)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
thecrypticace committed Aug 26, 2022
1 parent 7b6ac54 commit ad7dbda
Show file tree
Hide file tree
Showing 2 changed files with 78 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Sort tags before classes when `@applying` a selector with joined classes ([#9107](https://github.com/tailwindlabs/tailwindcss/pull/9107))
- Remove invalid `outline-hidden` utility ([#9147](https://github.com/tailwindlabs/tailwindcss/pull/9147))
- Honor the `hidden` attribute on elements in preflight ([#9174](https://github.com/tailwindlabs/tailwindcss/pull/9174))
- Don't stop watching atomically renamed files ([#9173](https://github.com/tailwindlabs/tailwindcss/pull/9173))

## [3.1.8] - 2022-08-05

Expand Down
77 changes: 77 additions & 0 deletions src/cli.js
Expand Up @@ -843,6 +843,11 @@ async function build() {
}

watcher = chokidar.watch([...contextDependencies, ...extractFileGlobs(config)], {
// Force checking for atomic writes in all situations
// This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked
// This only works when watching directories though
atomic: true,

usePolling: shouldPoll,
interval: shouldPoll ? pollInterval : undefined,
ignoreInitial: true,
Expand All @@ -855,6 +860,7 @@ async function build() {
})

let chain = Promise.resolve()
let pendingRebuilds = new Set()

watcher.on('change', async (file) => {
if (contextDependencies.has(file)) {
Expand Down Expand Up @@ -885,6 +891,77 @@ async function build() {
}
})

/**
* When rapidly saving files atomically a couple of situations can happen:
* - 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.
* - 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.
*
* To work around this we retry reading the file a handful of times with a delay between each attempt
*
* @param {string} path
* @param {number} tries
* @returns {string}
* @throws {Error} If the file is still missing or busy after the specified number of tries
*/
async function readFileWithRetries(path, tries = 5) {
for (let n = 0; n < tries; n++) {
try {
return await fs.promises.readFile(path, 'utf8')
} catch (err) {
if (n < tries) {
if (err.code === 'ENOENT' || err.code === 'EBUSY') {
await new Promise((resolve) => setTimeout(resolve, 10))

continue
}
}

throw err
}
}
}

// Restore watching any files that are "removed"
// 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)
// TODO: An an optimization we should allow removal when the config changes
watcher.on('unlink', (file) => watcher.add(file))

// Some applications such as Visual Studio (but not VS Code)
// will only fire a rename event for atomic writes and not a change event
// This is very likely a chokidar bug but it's one we need to work around
// We treat this as a change event and rebuild the CSS
watcher.on('raw', (evt, filePath, meta) => {
if (evt !== 'rename') {
return
}

filePath = path.resolve(meta.watchedPath, filePath)

// Skip since we've already queued a rebuild for this file that hasn't happened yet
if (pendingRebuilds.has(filePath)) {
return
}

pendingRebuilds.add(filePath)

chain = chain.then(async () => {
let content

try {
content = await readFileWithRetries(path.resolve(filePath))
} finally {
pendingRebuilds.delete(filePath)
}

changedContent.push({
content,
extension: path.extname(filePath).slice(1),
})

await rebuild(config)
})
})

watcher.on('add', async (file) => {
chain = chain.then(async () => {
changedContent.push({
Expand Down

0 comments on commit ad7dbda

Please sign in to comment.