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 not rebuilding files when rename event is emit #9689

Merged
merged 5 commits into from Nov 3, 2022
Merged
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
112 changes: 101 additions & 11 deletions src/cli/build/watching.js
Expand Up @@ -8,6 +8,14 @@ import path from 'path'

import { readFileWithRetries } from './utils.js'

/**
* The core idea of this watcher is:
* 1. Whenever a file is added, changed, or renamed we queue a rebuild
* 2. Perform as few rebuilds as possible by batching them together
* 3. Coalesce events that happen in quick succession to avoid unnecessary rebuilds
* 4. Ensure another rebuild happens _if_ changed while a rebuild is in progress
*/

/**
*
* @param {*} args
Expand Down Expand Up @@ -42,27 +50,93 @@ export function createWatcher(args, { state, rebuild }) {
: false,
})

// A queue of rebuilds, file reads, etc… to run
let chain = Promise.resolve()
let pendingRebuilds = new Set()

/**
* A list of files that have been changed since the last rebuild
*
* @type {{file: string, content: () => Promise<string>, extension: string}[]}
*/
let changedContent = []

/**
* A list of files for which a rebuild has already been queued.
* This is used to prevent duplicate rebuilds when multiple events are fired for the same file.
* The rebuilt file is cleared from this list when it's associated rebuild has _started_
* This is because if the file is changed during a rebuild it won't trigger a new rebuild which it should
**/
let pendingRebuilds = new Set()

let _timer
let _reject

/**
* Rebuilds the changed files and resolves when the rebuild is
* complete regardless of whether it was successful or not
*/
async function rebuildAndContinue() {
let changes = changedContent.splice(0)

// There are no changes to rebuild so we can just do nothing
if (changes.length === 0) {
return Promise.resolve()
}

// Clear all pending rebuilds for the about-to-be-built files
changes.forEach((change) => pendingRebuilds.delete(change.file))

// Resolve the promise even when the rebuild fails
return rebuild(changes).then(
() => {},
() => {}
)
}

/**
*
* @param {*} file
* @param {(() => Promise<string>) | null} content
* @returns {Promise<void>}
*/
function recordChangedFile(file, content = null) {
file = path.resolve(file)

content = content ?? (async () => await fs.promises.readFile(file, 'utf8'))
// Applications like Vim/Neovim fire both rename and change events in succession for atomic writes
// In that case rebuild has already been queued by rename, so can be skipped in change
if (pendingRebuilds.has(file)) {
return Promise.resolve()
}

// Mark that a rebuild of this file is going to happen
// It MUST happen synchronously before the rebuild is queued for this to be effective
pendingRebuilds.add(file)

changedContent.push({
file,
content,
content: content ?? (() => fs.promises.readFile(file, 'utf8')),
extension: path.extname(file).slice(1),
})

chain = chain.then(() => rebuild(changedContent.splice(0)))
if (_timer) {
clearTimeout(_timer)
_reject()
}

// If a rebuild is already in progress we don't want to start another one until the 10ms timer has expired
chain = chain.then(
() =>
new Promise((resolve, reject) => {
_timer = setTimeout(resolve, 10)
_reject = reject
})
)

// Resolves once this file has been rebuilt (or the rebuild for this file has failed)
// This queues as many rebuilds as there are changed files
// But those rebuilds happen after some delay
// And will immediately resolve if there are no changes
chain = chain.then(rebuildAndContinue, rebuildAndContinue)

return chain
}
Expand Down Expand Up @@ -107,18 +181,34 @@ export function createWatcher(args, { state, rebuild }) {
return
}

// We'll go ahead and add the file to the pending rebuilds list here
// It'll be removed when the rebuild starts unless the read fails
// which will be taken care of as well
pendingRebuilds.add(filePath)

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

async function enqueue() {
try {
content = await readFileWithRetries(path.resolve(filePath))
} finally {
pendingRebuilds.delete(filePath)
// We need to read the file as early as possible outside of the chain
// because it may be gone by the time we get to it. doing the read
// immediately increases the chance that the file is still there
let content = await readFileWithRetries(path.resolve(filePath))

if (content === undefined) {
return
}

// This will push the rebuild onto the chain
// @ts-ignore: TypeScript isn't picking up that content is a string here
await recordChangedFile(filePath, () => content)
} catch {
// If reading the file fails, it's was probably a deleted temporary file
// So we can ignore it and no rebuild is needed
}
}

return recordChangedFile(filePath, () => content)
enqueue().then(() => {
// If the file read fails we still need to make sure the file isn't stuck in the pending rebuilds list
pendingRebuilds.delete(filePath)
})
})

Expand Down