Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix CLI not watching atomically renamed files #9173

Merged
merged 4 commits into from Aug 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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