From ad7dbda7e90c3de440d7c8b1415eaf04217b4487 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 26 Aug 2022 12:47:00 -0400 Subject: [PATCH] Fix CLI not watching atomically renamed files (#9173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CHANGELOG.md | 1 + src/cli.js | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc557d8e0..070c8dba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli.js b/src/cli.js index 885f03399..e8beca6e6 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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, @@ -855,6 +860,7 @@ async function build() { }) let chain = Promise.resolve() + let pendingRebuilds = new Set() watcher.on('change', async (file) => { if (contextDependencies.has(file)) { @@ -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({