From 31d2b7337857fd47cb6bfe14295038997d1855d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 16 Apr 2025 10:56:27 +0200 Subject: [PATCH 01/18] Revert "Revert "support @parcel/watcher as the watcher alternative cross-os perserving polling fallback"" This reverts commit 6de4d9dd14f589a393c398e279b65e20c0c30341. --- scripts/dev-bundle-tool-package.js | 3 +- tools/fs/safe-watcher.ts | 770 ++++++++++++----------------- 2 files changed, 330 insertions(+), 443 deletions(-) diff --git a/scripts/dev-bundle-tool-package.js b/scripts/dev-bundle-tool-package.js index 2b92b9e5f6..8d9292efb0 100644 --- a/scripts/dev-bundle-tool-package.js +++ b/scripts/dev-bundle-tool-package.js @@ -57,8 +57,7 @@ var packageJson = { escope: "3.6.0", split2: "3.2.2", multipipe: "2.0.1", - pathwatcher: "8.1.2", - "vscode-nsfw": "2.1.8", + "@parcel/watcher": "2.5.1", // The @wry/context package version must be compatible with the // version constraint imposed by optimism/package.json. optimism: "0.16.1", diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index cea3d74d7b..5f521fb024 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -1,476 +1,364 @@ -import { FSWatcher, Stats, BigIntStats } from "fs"; +import { Stats } from 'fs'; +import { dirname } from "path"; +import ParcelWatcher from "@parcel/watcher"; + import { Profile } from "../tool-env/profile"; -import { - statOrNull, - convertToOSPath, - watchFile, - unwatchFile, - toPosixPath, - pathRelative -} from "./files"; -import { - join as nativeJoin -} from 'path'; -import nsfw from 'vscode-nsfw'; +import { statOrNull, lstat, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile } from "./files"; -const pathwatcher = require('pathwatcher'); +export type SafeWatcher = { close: () => void; }; + +type ChangeCallback = (event: string) => void; + +interface Entry extends SafeWatcher { + callbacks: Set; + _fire(event: string): void; +} + +// Registry mapping normalized absolute paths to their watcher entry. +const entries: Record = Object.create(null); + +// Watch roots are directories for which we have an active ParcelWatcher subscription. +const watchRoots = new Set(); +// For each watch root, store its active subscription. +const dirSubscriptions = new Map(); +// A set of roots that are known to be unwatchable. +const ignoredWatchRoots = new Set(); + +// Set METEOR_WATCH_FORCE_POLLING environment variable to a truthy value to +// force the use of files.watchFile instead of ParcelWatcher. +let watcherEnabled = !JSON.parse(process.env.METEOR_WATCH_FORCE_POLLING || "false"); + +/** + * Polling fallback globals. + * The legacy Meteor strategy used polling for cases where native watchers failed. + * We keep track of files that changed, so that we can poll them faster. + */ +var DEFAULT_POLLING_INTERVAL = + +(process.env.METEOR_WATCH_POLLING_INTERVAL_MS || 5000); + +var NO_WATCHER_POLLING_INTERVAL = + +(process.env.METEOR_WATCH_POLLING_INTERVAL_MS || 500); -// Default to prioritizing changed files, but disable that behavior (and -// thus prioritize all files equally) if METEOR_WATCH_PRIORITIZE_CHANGED -// is explicitly set to a string that parses to a falsy value. var PRIORITIZE_CHANGED = true; if (process.env.METEOR_WATCH_PRIORITIZE_CHANGED && ! JSON.parse(process.env.METEOR_WATCH_PRIORITIZE_CHANGED)) { PRIORITIZE_CHANGED = false; } -var DEFAULT_POLLING_INTERVAL = - +(process.env.METEOR_WATCH_POLLING_INTERVAL_MS || 5000); - -var NO_WATCHER_POLLING_INTERVAL = - +(process.env.METEOR_WATCH_POLLING_INTERVAL_MS || 500); - -// This may seems like a long time to wait before actually closing the -// file watchers, but it's to our advantage if they survive restarts. -const WATCHER_CLEANUP_DELAY_MS = 30000; - -// Since linux doesn't have recursive file watching, nsfw has to walk the -// watched folder and create a separate watcher for each subfolder. Until it has a -// way for us to filter which folders it walks we will continue to use -// pathwatcher to avoid having too many watchers. -let watcherLibrary = process.env.METEOR_WATCHER_LIBRARY || - (process.platform === 'linux' ? 'pathwatcher' : 'nsfw'); - -// Pathwatcher complains (using console.error, ugh) if you try to watch -// two files with the same stat.ino number but different paths on linux, so we have -// to deduplicate files by ino. -const DEDUPLICATE_BY_INO = watcherLibrary === 'pathwatcher'; -// Set METEOR_WATCH_FORCE_POLLING environment variable to a truthy value to -// force the use of files.watchFile instead of watchLibrary.watch. -let watcherEnabled = ! JSON.parse( - process.env.METEOR_WATCH_FORCE_POLLING || "false" -); - -const entriesByIno = new Map; - -export type SafeWatcher = { - close: () => void; -} - -type EntryCallback = (event: string) => void; - -interface Entry extends SafeWatcher { - callbacks: Set; - rewatch: () => void; - release: (callback: EntryCallback) => void; - _fire: (event: string) => void; -} - -const entries: Record = Object.create(null); - -// Folders that are watched recursively -let watchRoots = new Set(); - // Set of paths for which a change event has been fired, watched with // watchLibrary.watch if available. This could be an LRU cache, but in // practice it should never grow large enough for that to matter. const changedPaths = new Set; +function shouldIgnorePath(absPath: string): boolean { + const posixPath = toPosixPath(absPath); + const parts = posixPath.split('/'); + + // Check for .meteor: allow the .meteor directory itself, + // but ignore its "local" subdirectory (or any immediate child folder that indicates cache). + const meteorIndex = parts.indexOf(".meteor"); + if (meteorIndex !== -1) { + const nextPart = parts[meteorIndex + 1]; + if (nextPart && nextPart === "local") { + // Ignore anything inside .meteor/local + return true; + } + // Otherwise, do not automatically ignore .meteor (which includes .meteor/packages, etc). + } + + // For node_modules: ignore contents unless the package directory is a symlink (which means it’s under active development). + const nmIndex = parts.indexOf("node_modules"); + if (nmIndex !== -1) { + try { + const stat = lstat(absPath); + if (!stat?.isSymbolicLink()) { + return true; + } + // If the package folder is symlinked, we want to watch it. + } catch (e) { + return true; + } + } + + return false; +} + +/** + * Ensure that the given directory is being watched by @parcel/watcher. + * If it is not a directory or is unwatchable, it is immediately added to an ignore set. + */ +async function ensureWatchRoot(dirPath: string): Promise { + if (!watcherEnabled || watchRoots.has(dirPath) || ignoredWatchRoots.has(dirPath)) { + return; + } + + // If an ancestor is already watched, skip this one. + for (const root of watchRoots) { + const rel = pathRelative(root, dirPath); + if (root !== dirPath && !rel.startsWith("..") && !rel.startsWith("/")) { + return; + } + } + + // Remove any existing roots that are now encompassed by the new one. + for (const root of Array.from(watchRoots)) { + const rel = pathRelative(dirPath, root); + if (root !== dirPath && !rel.startsWith("..") && !rel.startsWith("/")) { + const sub = dirSubscriptions.get(root); + if (sub) { + try { + await sub.unsubscribe(); + } catch (_) { + /* ignore errors */ + } + } + dirSubscriptions.delete(root); + watchRoots.delete(root); + } + } + + const osDirPath = convertToOSPath(dirPath); + // Check that osDirPath is indeed a directory. + try { + const stats = statOrNull(osDirPath); + if (!stats?.isDirectory()) { + console.warn(`Skipping watcher for ${osDirPath}: not a directory`); + ignoredWatchRoots.add(dirPath); + return; + } + } catch (e) { + console.error(`Failed to stat ${osDirPath}:`, e); + ignoredWatchRoots.add(dirPath); + return; + } + + // Set up ignore patterns to skip deep node_modules and .meteor/local cache + const ignorePatterns = ["**/node_modules/**", "**/.meteor/local/**"]; + try { + const subscription = await ParcelWatcher.subscribe( + osDirPath, + (err, events) => { + if (err) { + console.error(`Parcel watcher error on ${osDirPath}:`, err); + // Only disable native watching for critical errors (like ENOSPC). + // @ts-ignore + if (err.code === "ENOSPC" || err.errno === require("constants").ENOSPC) { + fallbackToPolling(); + } + return; + } + // Dispatch each event to any registered entries. + for (const event of events) { + const changedPath = toPosixPath(event.path); + const entry = entries[changedPath]; + if (!entry) continue; + // In Meteor's safe-watcher API, both create/update trigger "change" events. + const evtType = event.type === "delete" ? "delete" : "change"; + entry._fire(evtType); + } + }, + { ignore: ignorePatterns } + ); + watchRoots.add(dirPath); + dirSubscriptions.set(dirPath, subscription); + } catch (e: any) { + if ( + e && + (e.code === "ENOTDIR" || + /Not a directory/.test(e.message) || + e.code === "EBADF" || + /Bad file descriptor/.test(e.message)) + ) { + console.warn(`Skipping watcher for ${osDirPath}: not a directory`); + ignoredWatchRoots.add(dirPath); + } else { + console.error(`Failed to start watcher for ${osDirPath}:`, e); + if (e.code === "ENOSPC" || e.errno === require("constants").ENOSPC) { + fallbackToPolling(); + } + } + } +} + +/** + * Creates a new watch entry for a specific file (or directory) and + * holds its registered callbacks. + */ +function startNewEntry(absPath: string): Entry { + const callbacks = new Set(); + let closed = false; + const entry: Entry = { + callbacks, + close() { + if (closed) return; + closed = true; + delete entries[absPath]; + }, + _fire(event: string) { + callbacks.forEach(cb => { + try { + cb(event); + } catch (e) { + // Ignore callback errors. + } + }); + } + }; + return entry; +} + +/** + * The primary API function to watch a file or directory. + * This registers the callback on the internally managed entry and + * ensures that a Parcel watcher is subscribed to a covering directory. + */ +export const watch = Profile( + "safeWatcher.watch", + ( + absPath: string, + callback: ChangeCallback + ): SafeWatcher => { + absPath = toPosixPath(absPath); + // If the path should be ignored, immediately return a noop SafeWatcher. + if (shouldIgnorePath(absPath)) { + return { close() {} }; + } + // If native watching is disabled, use the polling strategy. + if (!watcherEnabled) { + return startPolling(absPath, callback); + } + // Try to reuse an existing entry if one was created before. + let entry = entries[absPath]; + if (!entry) { + entry = startNewEntry(absPath); + entries[absPath] = entry; + // Determine the directory that should be watched. + let watchTarget: string; + try { + const st = statOrNull(convertToOSPath(absPath)); + watchTarget = st?.isDirectory() ? absPath : toPosixPath(dirname(convertToOSPath(absPath))); + } catch (e) { + watchTarget = toPosixPath(dirname(convertToOSPath(absPath))); + } + // Set up a watcher on the parent directory (or the directory itself) if not already active. + ensureWatchRoot(watchTarget); + } + // Register the callback for this file. + entry.callbacks.add(callback); + return { + close() { + if (entries[absPath]) { + entries[absPath]!.callbacks.delete(callback); + if (entries[absPath]!.callbacks.size === 0) { + entries[absPath]!.close(); + } + } + } + }; +}); + +/** + * Externally force a directory to be watched. + * If the provided path is a file, its parent directory is used. + */ +export function addWatchRoot(absPath: string) { + absPath = toPosixPath(absPath); + let watchTarget = absPath; + try { + const st = statOrNull(convertToOSPath(absPath)); + if (!st?.isDirectory()) { + watchTarget = toPosixPath(dirname(convertToOSPath(absPath))); + } + } catch (e) { + watchTarget = toPosixPath(dirname(convertToOSPath(absPath))); + } + ensureWatchRoot(watchTarget); +} + +async function safeUnsubscribeSub(root: string) { + const sub = dirSubscriptions.get(root); + if (!sub) return; // Already unsubscribed. + // Remove from our maps immediately to prevent further unsubscribe calls. + dirSubscriptions.delete(root); + watchRoots.delete(root); + try { + await sub.unsubscribe(); + } catch (e) { + console.error(`Error during unsubscribe for ${root}:`, e); + } +} + +export async function closeAllWatchers() { + for (const root of Array.from(watchRoots)) { + await safeUnsubscribeSub(root); + } +} + function hasPriority(absPath: string) { // If we're not prioritizing changed files, then all files have // priority, which means they should be watched with native file // watchers if the platform supports them. If we are prioritizing // changed files, then only changed files get priority. return PRIORITIZE_CHANGED - ? changedPaths.has(absPath) - : true; + ? changedPaths.has(absPath) + : true; } -function acquireWatcher(absPath: string, callback: EntryCallback) { - const entry = entries[absPath] || ( - entries[absPath] = startNewWatcher(absPath)); - - // Watches successfully established in the past may have become invalid - // because the watched file was deleted or renamed, so we need to make - // sure we're still watching every time we call safeWatcher.watch. - entry.rewatch(); - - // The size of the entry.callbacks Set also serves as a reference count - // for this watcher. - entry.callbacks.add(callback); - - return entry; -} - -function startNewWatcher(absPath: string): Entry { - let stat: Stats | BigIntStats | null | undefined = null; - - if (DEDUPLICATE_BY_INO) { - stat = statOrNull(absPath); - if (stat && stat.ino > 0 && entriesByIno.has(stat.ino)) { - const entry = entriesByIno.get(stat.ino); - if (entries[absPath] === entry) { - return entry; - } - } - } else { - let entry = entries[absPath]; - if (entry) { - return entry; - } - } - - function safeUnwatch() { - if (watcher) { - watcher.close(); - watcher = null; - if (stat && stat.ino > 0) { - entriesByIno.delete(stat.ino); - } - } - } - - let lastWatcherEventTime = Date.now(); - const callbacks = new Set(); - let watcherCleanupTimer: ReturnType | null = null; - let watcher: FSWatcher | null = null; - - // Determines the polling interval to be used for the fs.watchFile-based - // safety net that works on all platforms and file systems. - function getPollingInterval() { - if (hasPriority(absPath)) { - // Regardless of whether we have a native file watcher and it works - // correctly on this file system, poll prioritized files (that is, - // files that have been changed at least once) at a higher frequency - // (every 500ms by default). - return NO_WATCHER_POLLING_INTERVAL; - } - - if (watcherEnabled || PRIORITIZE_CHANGED) { - // As long as native file watching is enabled (even if it doesn't - // work correctly) and the developer hasn't explicitly opted out of - // the file watching priority system, poll unchanged files at a - // lower frequency (every 5000ms by default). - return DEFAULT_POLLING_INTERVAL; - } - - // If native file watching is disabled and the developer has - // explicitly opted out of the priority system, poll everything at the - // higher frequency (every 500ms by default). Note that this leads to - // higher idle CPU usage, so the developer may want to adjust the - // METEOR_WATCH_POLLING_INTERVAL_MS environment variable. +// Determines the polling interval to be used for the fs.watchFile-based +// safety net that works on all platforms and file systems. +function getPollingInterval(absPath: string): number { + if (hasPriority(absPath)) { + // Regardless of whether we have a native file watcher and it works + // correctly on this file system, poll prioritized files (that is, + // files that have been changed at least once) at a higher frequency + // (every 500ms by default). return NO_WATCHER_POLLING_INTERVAL; } - function fire(event: string) { - if (event !== "change") { - // When we receive a "delete" or "rename" event, the watcher is - // probably not going to generate any more notifications for this - // file, so we close and nullify the watcher to ensure that - // entry.rewatch() will attempt to reestablish the watcher the next - // time we call safeWatcher.watch. - safeUnwatch(); + if (watcherEnabled || PRIORITIZE_CHANGED) { + // As long as native file watching is enabled (even if it doesn't + // work correctly) and the developer hasn't explicitly opted out of + // the file watching priority system, poll unchanged files at a + // lower frequency (every 5000ms by default). + return DEFAULT_POLLING_INTERVAL; + } - // Make sure we don't throttle the watchFile callback after a - // "delete" or "rename" event, since it is now our only reliable - // source of file change notifications. - lastWatcherEventTime = 0; + // If native file watching is disabled and the developer has + // explicitly opted out of the priority system, poll everything at the + // higher frequency (every 500ms by default). Note that this leads to + // higher idle CPU usage, so the developer may want to adjust the + // METEOR_WATCH_POLLING_INTERVAL_MS environment variable. + return NO_WATCHER_POLLING_INTERVAL; +} - } else { +function startPolling(absPath: string, callback: ChangeCallback): SafeWatcher { + const osPath = convertToOSPath(absPath); + // Initial polling interval. + let interval = getPollingInterval(absPath); + const pollCallback = (curr: Stats, prev: Stats) => { + // Compare modification times to detect a change. + if (+curr.mtime !== +prev.mtime) { changedPaths.add(absPath); - rewatch(); + callback("change"); } - - callbacks.forEach(cb => cb(event)); - } - - function watchWrapper(event: string) { - lastWatcherEventTime = Date.now(); - fire(event); - - // It's tempting to call unwatchFile(absPath, watchFileWrapper) here, - // but previous watcher success is no guarantee of future watcher - // reliability. For example, watchLibrary.watch works just fine when file - // changes originate from within a Vagrant VM, but changes to shared - // files made outside the VM are invisible to watcher, so our only - // hope of catching them is to continue polling. - } - - function rewatch() { - if (hasPriority(absPath)) { - if (watcher) { - // Already watching; nothing to do. - return; - } - watcher = watchLibraryWatch(absPath, watchWrapper); - } else if (watcher) { - safeUnwatch(); - } - - // Since we're about to restart the stat-based file watcher, we don't - // want to miss any of its events because of the lastWatcherEventTime - // throttling that it attempts to do. - lastWatcherEventTime = 0; - - // We use files.watchFile in addition to watcher.watch as a fail-safe - // to detect file changes even on network file systems. However - // (unless the user disabled watcher or this watcher call failed), we - // use a relatively long default polling interval of 5000ms to save - // CPU cycles. - statWatch(absPath, getPollingInterval(), watchFileWrapper); - } - - function watchFileWrapper(newStat: Stats, oldStat: Stats) { - if (newStat.ino === 0 && - oldStat.ino === 0 && - +newStat.mtime === +oldStat.mtime) { - // Node calls the watchFile listener once with bogus identical stat - // objects, which should not trigger a file change event. - return; - } - - // If a watcher event fired in the last polling interval, ignore - // this event. - if (Date.now() - lastWatcherEventTime > getPollingInterval()) { - fire("change"); - } - } - - const entry = { - callbacks, - rewatch, - - release(callback: EntryCallback) { - if (! entries[absPath]) { - return; - } - - callbacks.delete(callback); - if (callbacks.size > 0) { - return; - } - - // Once there are no more callbacks in the Set, close both watchers - // and nullify the shared data. - if (watcherCleanupTimer) { - clearTimeout(watcherCleanupTimer); - } - - watcherCleanupTimer = setTimeout(() => { - if (callbacks.size > 0) { - // If another callback was added while the timer was pending, we - // can avoid tearing anything down. - return; - } - entry.close(); - }, WATCHER_CLEANUP_DELAY_MS); - }, - - close() { - if (entries[absPath] !== entry) return; - entries[absPath] = null; - - if (watcherCleanupTimer) { - clearTimeout(watcherCleanupTimer); - watcherCleanupTimer = null; - } - - safeUnwatch(); - - unwatchFile(absPath, watchFileWrapper); - }, - _fire: fire }; - - if (stat && stat.ino > 0) { - entriesByIno.set(stat.ino, entry); - } - - return entry; -} - -export function closeAllWatchers() { - Object.keys(entries).forEach(absPath => { - const entry = entries[absPath]; - if (entry) { - entry.close(); + watchFile(osPath, { interval }, pollCallback); + return { + close() { + unwatchFile(osPath, pollCallback); + changedPaths.delete(absPath); } - }); + }; } -const statWatchers = Object.create(null); - -function statWatch( - absPath: string, - interval: number, - callback: (current: Stats, previous: Stats) => void, -) { - let statWatcher = statWatchers[absPath]; - - if (!statWatcher) { - statWatcher = { - interval, - changeListeners: [], - stat: null - }; - statWatchers[absPath] = statWatcher; - } - - // If the interval needs to be changed, replace the watcher. - // Node will only recreate the watcher with the new interval if all old - // watchers are stopped (which unwatchFile does when not passed a - // specific listener) - if (statWatcher.interval !== interval && statWatcher.stat) { - // This stops all stat watchers for the file, not just those created by - // statWatch - unwatchFile(absPath); - statWatcher.stat = null; - statWatcher.interval = interval; - } - - if (!statWatcher.changeListeners.includes(callback)) { - statWatcher.changeListeners.push(callback); - } - - if (!statWatcher.stat) { - const newStat = watchFile(absPath, { - persistent: false, // never persistent - interval, - }, (newStat, oldStat) => { - statWatcher.changeListeners.forEach(( - listener: (newStat: Stats, oldStat: Stats) => void - ) => { - listener(newStat, oldStat); - }); - }); - - newStat.on("stop", () => { - if (statWatchers[absPath] === statWatch) { - delete statWatchers[absPath]; - } - }); - - statWatcher.stat = newStat; - } - - return statWatcher; -} - -function watchLibraryWatch(absPath: string, callback: EntryCallback) { - if (watcherEnabled && watcherLibrary === 'pathwatcher') { - try { - return pathwatcher.watch(convertToOSPath(absPath), callback); - } catch (e: any) { - maybeSuggestRaisingWatchLimit(e); - // ... ignore the error. We'll still have watchFile, which is good - // enough. - } - } - - return null; -} - -let suggestedRaisingWatchLimit = false; - -function maybeSuggestRaisingWatchLimit(error: Error & { errno: number }) { - var constants = require('constants'); - var archinfo = require('../utils/archinfo'); - if (! suggestedRaisingWatchLimit && - // Note: the not-super-documented require('constants') maps from - // strings to SYSTEM errno values. System errno values aren't the same - // as the numbers used internally by libuv! Once we're upgraded - // to Node 0.12, we'll have the system errno as a string (on 'code'), - // but the support for that wasn't in Node 0.10's uv. - // See our PR https://github.com/atom/node-pathwatcher/pull/53 - // (and make sure to read the final commit message, not the original - // proposed PR, which had a slightly different interface). - error.errno === constants.ENOSPC && - // The only suggestion we currently have is for Linux. - archinfo.matches(archinfo.host(), 'os.linux')) { - - // Check suggestedRaisingWatchLimit again because archinfo.host() may - // have yielded. - if (suggestedRaisingWatchLimit) return; - suggestedRaisingWatchLimit = true; - - var Console = require('../console/console.js').Console; - if (! Console.isHeadless()) { - Console.arrowWarn( - "It looks like a simple tweak to your system's configuration will " + - "make many tools (including this Meteor command) more efficient. " + - "To learn more, see " + - Console.url("https://github.com/meteor/docs/blob/master/long-form/file-change-watcher-efficiency.md")); - } +/** + * Fall back to polling. If a critical error occurs, + * we disable native watching and close all existing native watchers. + */ +function fallbackToPolling() { + if (watcherEnabled) { + console.error("Critical native watcher error encountered. Falling back to polling for all entries."); + watcherEnabled = false; + closeAllWatchers(); } } - -export const watch = Profile( - "safeWatcher.watch", - (absPath: string, callback: EntryCallback) => { - const entry = acquireWatcher(absPath, callback); - return { - close() { - entry.release(callback); - } - } as SafeWatcher; - } -); - -const fireNames = { - [nsfw.actions.CREATED]: 'change', - [nsfw.actions.MODIFIED]: 'change', - [nsfw.actions.DELETED]: 'delete' -} - -export function addWatchRoot(absPath: string) { - if (watchRoots.has(absPath) || watcherLibrary !== 'nsfw' || !watcherEnabled) { - return; - } - - watchRoots.add(absPath); - - // If there already is a watcher for a parent directory, there is no need - // to create this watcher. - for (const path of watchRoots) { - let relativePath = pathRelative(path, absPath); - if ( - path !== absPath && - !relativePath.startsWith('..') && - !relativePath.startsWith('/') - ) { - return; - } - } - - // TODO: check if there are any existing watchers that are children of this - // watcher and stop them - - nsfw( - convertToOSPath(absPath), - (events) => { - events.forEach(event => { - if(event.action === nsfw.actions.RENAMED) { - let oldPath = nativeJoin(event.directory, event.oldFile); - let oldEntry = entries[toPosixPath(oldPath)]; - if (oldEntry) { - oldEntry._fire('rename'); - } - - let path = nativeJoin(event.newDirectory, event.newFile); - let newEntry = entries[toPosixPath(path)]; - if (newEntry) { - newEntry._fire('change'); - } - } else { - let path = nativeJoin(event.directory, event.file); - let entry = entries[toPosixPath(path)]; - if (entry) { - entry._fire(fireNames[event.action]); - } - } - }) - } - ).then(watcher => { - watcher.start() - }); -} From 95c59bed286caafe98d147bf76e8cf614bfaa3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 16 Apr 2025 11:54:47 +0200 Subject: [PATCH 02/18] bump bundle version --- meteor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meteor b/meteor index b743dccaa9..b86a040689 100755 --- a/meteor +++ b/meteor @@ -1,6 +1,6 @@ #!/usr/bin/env bash -BUNDLE_VERSION=22.14.0.10 +BUNDLE_VERSION=22.14.0.11 # OS Check. Put here because here is where we download the precompiled # bundles that are arch specific. From 04477d52dc742bc1c54d564bf8107174401742b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 16 Apr 2025 12:04:12 +0200 Subject: [PATCH 03/18] re-run checks From 2b2300639c7ae884f5c00bdb4a52659ff72ffbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 21 Apr 2025 17:52:41 +0200 Subject: [PATCH 04/18] adapt code to support lru and intent to solve tests breaking --- tools/fs/safe-watcher.ts | 51 +++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index 5f521fb024..b0e4165bef 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -1,6 +1,7 @@ import { Stats } from 'fs'; import { dirname } from "path"; import ParcelWatcher from "@parcel/watcher"; +import LRUCache from 'lru-cache'; import { Profile } from "../tool-env/profile"; import { statOrNull, lstat, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile } from "./files"; @@ -14,8 +15,39 @@ interface Entry extends SafeWatcher { _fire(event: string): void; } +// Set METEOR_WATCH_USE_LRU environment variable to a truthy value to +// enable LRU caching for watcher entries to reduce memory usage. +const useLRU = Boolean(JSON.parse(process.env.METEOR_WATCH_USE_LRU || "true")); + // Registry mapping normalized absolute paths to their watcher entry. -const entries: Record = Object.create(null); +// If LRU caching is enabled, this will be an LRUCache instance. +// Otherwise, we use a Map object for entries. +const entries = useLRU + ? new LRUCache({ + max: Math.pow(2, 20), // 1MB max size + length: (entry, key) => { + return key.length + (entry ? 100 : 10); + }, + dispose: (key, entry) => { + return entry.close() + }, + }) + : new Map(); + +function getEntry(path: string): Entry | null | undefined { + return entries.get(path); +} + +function setEntry(path: string, entry: Entry | null): void { + entries.set(path, entry); +} + +function deleteEntry(path: string): void { + if (useLRU) { + return entries.del(path); + } + return entries.delete(path); +} // Watch roots are directories for which we have an active ParcelWatcher subscription. const watchRoots = new Set(); @@ -150,7 +182,7 @@ async function ensureWatchRoot(dirPath: string): Promise { // Dispatch each event to any registered entries. for (const event of events) { const changedPath = toPosixPath(event.path); - const entry = entries[changedPath]; + const entry = getEntry(changedPath); if (!entry) continue; // In Meteor's safe-watcher API, both create/update trigger "change" events. const evtType = event.type === "delete" ? "delete" : "change"; @@ -192,7 +224,7 @@ function startNewEntry(absPath: string): Entry { close() { if (closed) return; closed = true; - delete entries[absPath]; + deleteEntry(absPath); }, _fire(event: string) { callbacks.forEach(cb => { @@ -228,10 +260,10 @@ export const watch = Profile( return startPolling(absPath, callback); } // Try to reuse an existing entry if one was created before. - let entry = entries[absPath]; + let entry = getEntry(absPath); if (!entry) { entry = startNewEntry(absPath); - entries[absPath] = entry; + setEntry(absPath, entry); // Determine the directory that should be watched. let watchTarget: string; try { @@ -247,10 +279,11 @@ export const watch = Profile( entry.callbacks.add(callback); return { close() { - if (entries[absPath]) { - entries[absPath]!.callbacks.delete(callback); - if (entries[absPath]!.callbacks.size === 0) { - entries[absPath]!.close(); + const entry = getEntry(absPath); + if (entry) { + entry.callbacks.delete(callback); + if (entry.callbacks.size === 0) { + entry.close(); } } } From d6c1ecacb7dd2c641efb1089ebdf5a3e27535381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 21 Apr 2025 18:21:08 +0200 Subject: [PATCH 05/18] dont activate lru by default --- tools/fs/safe-watcher.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index b0e4165bef..659e3fc54c 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -17,19 +17,22 @@ interface Entry extends SafeWatcher { // Set METEOR_WATCH_USE_LRU environment variable to a truthy value to // enable LRU caching for watcher entries to reduce memory usage. -const useLRU = Boolean(JSON.parse(process.env.METEOR_WATCH_USE_LRU || "true")); +const useLRU = Boolean(JSON.parse(process.env.METEOR_WATCH_USE_LRU || "false")); + +// enable to tweak maximum entries size (default 500MB). +const sizeMaxLRU = JSON.parse(process.env.METEOR_WATCH_USE_LRU_SIZE || Math.pow(2, 20) * 500); // Registry mapping normalized absolute paths to their watcher entry. // If LRU caching is enabled, this will be an LRUCache instance. // Otherwise, we use a Map object for entries. const entries = useLRU ? new LRUCache({ - max: Math.pow(2, 20), // 1MB max size + max: sizeMaxLRU, // 1MB max size length: (entry, key) => { return key.length + (entry ? 100 : 10); }, dispose: (key, entry) => { - return entry.close() + return entry.close(); }, }) : new Map(); From cca65e3751f77e17c73719aeacd1e0b2b99f3a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 21 Apr 2025 18:29:20 +0200 Subject: [PATCH 06/18] fix precheck --- tools/fs/safe-watcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index 659e3fc54c..f7a2c86296 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -32,7 +32,7 @@ const entries = useLRU return key.length + (entry ? 100 : 10); }, dispose: (key, entry) => { - return entry.close(); + return entry?.close(); }, }) : new Map(); From d6f52c3e77df065cb924970cd7c8a8688f6f2095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 21 Apr 2025 19:16:28 +0200 Subject: [PATCH 07/18] use polling on CI as using a linux env with inotify backend --- .circleci/config.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index be2fe2cae4..65b5920634 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -115,6 +115,16 @@ build_machine_environment: NUM_GROUPS: 12 RUNNING_AVG_LENGTH: 6 + # Force Meteor to use polling to avoid errors like: + # "double free or corruption (out) IOT instruction" + # Happens on Linux when using the inotify backend. + # Doesn't affect watching files with @parcel/watch, + # but causes Meteor commands to exit errored. + # This impacts CI pipelines. + # Using Watchman can fix the issue as well. + METEOR_WATCH_FORCE_POLLING: true + METEOR_WATCH_POLLING_INTERVAL_MS: 1000 + jobs: Get Ready: <<: *build_machine_environment From fe36f5ed7088d07eaf19e78ac3a1767830557eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 21 Apr 2025 19:21:48 +0200 Subject: [PATCH 08/18] fix --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 65b5920634..7dace8fe26 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -117,7 +117,7 @@ build_machine_environment: # Force Meteor to use polling to avoid errors like: # "double free or corruption (out) IOT instruction" - # Happens on Linux when using the inotify backend. + # Happens on Linux when using the fts (brute force) backend. # Doesn't affect watching files with @parcel/watch, # but causes Meteor commands to exit errored. # This impacts CI pipelines. From ab067c7e7800a634b687857783426c0e1cdc8825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Tue, 22 Apr 2025 16:39:53 +0200 Subject: [PATCH 09/18] fix issue with memory by ensure unsubscribing all watchers --- .circleci/config.yml | 10 ---------- tools/fs/safe-watcher.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7dace8fe26..be2fe2cae4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -115,16 +115,6 @@ build_machine_environment: NUM_GROUPS: 12 RUNNING_AVG_LENGTH: 6 - # Force Meteor to use polling to avoid errors like: - # "double free or corruption (out) IOT instruction" - # Happens on Linux when using the fts (brute force) backend. - # Doesn't affect watching files with @parcel/watch, - # but causes Meteor commands to exit errored. - # This impacts CI pipelines. - # Using Watchman can fix the issue as well. - METEOR_WATCH_FORCE_POLLING: true - METEOR_WATCH_POLLING_INTERVAL_MS: 1000 - jobs: Get Ready: <<: *build_machine_environment diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index f7a2c86296..6834975f03 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -6,6 +6,43 @@ import LRUCache from 'lru-cache'; import { Profile } from "../tool-env/profile"; import { statOrNull, lstat, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile } from "./files"; +// Register process exit handlers to ensure subscriptions are properly cleaned up +const registerExitHandlers = () => { + + // For SIGINT and SIGTERM, we need to handle the async cleanup before the process exits + const cleanupAndExit = (signal: string) => { + // Clear the timeout if cleanup completes successfully + closeAllWatchers().then(() => { + process.exit(0); + }).catch(err => { + console.error(`Error closing watchers on ${signal}:`, err); + process.exit(1); + }); + }; + + // Handle SIGINT (Ctrl+C) + process.on('SIGINT', () => cleanupAndExit('SIGINT')); + + // Handle SIGTERM + process.on('SIGTERM', () => cleanupAndExit('SIGTERM')); + + // Handle 'exit' event + process.on('exit', () => { + try { + for (const root of Array.from(watchRoots)) { + const sub = dirSubscriptions.get(root); + if (sub) { + sub.unsubscribe(); + dirSubscriptions.delete(root); + watchRoots.delete(root); + } + } + } catch (err) { + console.error('Error during synchronous cleanup on exit:', err); + } + }); +}; + export type SafeWatcher = { close: () => void; }; type ChangeCallback = (event: string) => void; @@ -398,3 +435,6 @@ function fallbackToPolling() { closeAllWatchers(); } } + +// Register exit handlers to ensure proper cleanup of subscriptions +registerExitHandlers(); From e4a057cf7f27a41d49d2d5e3dba9c1c1819d9524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 23 Apr 2025 08:58:02 +0200 Subject: [PATCH 10/18] ensure to mark a path as being subscribed beforehand to avoid parallel repeated subscriptions to the same folder context --- tools/fs/safe-watcher.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index 6834975f03..f70c7aef3d 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -207,6 +207,7 @@ async function ensureWatchRoot(dirPath: string): Promise { // Set up ignore patterns to skip deep node_modules and .meteor/local cache const ignorePatterns = ["**/node_modules/**", "**/.meteor/local/**"]; try { + watchRoots.add(dirPath); const subscription = await ParcelWatcher.subscribe( osDirPath, (err, events) => { @@ -217,6 +218,7 @@ async function ensureWatchRoot(dirPath: string): Promise { if (err.code === "ENOSPC" || err.errno === require("constants").ENOSPC) { fallbackToPolling(); } + watchRoots.delete(dirPath); return; } // Dispatch each event to any registered entries. @@ -231,7 +233,6 @@ async function ensureWatchRoot(dirPath: string): Promise { }, { ignore: ignorePatterns } ); - watchRoots.add(dirPath); dirSubscriptions.set(dirPath, subscription); } catch (e: any) { if ( @@ -249,6 +250,7 @@ async function ensureWatchRoot(dirPath: string): Promise { fallbackToPolling(); } } + watchRoots.delete(dirPath); } } From a50f07b7802ab6ca08a057a91f980895784be0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 23 Apr 2025 09:05:21 +0200 Subject: [PATCH 11/18] remove LRU on file replacement since finally was not needed to fix an issue --- tools/fs/safe-watcher.ts | 30 +++--------------------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index f70c7aef3d..984470028c 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -1,7 +1,6 @@ import { Stats } from 'fs'; import { dirname } from "path"; import ParcelWatcher from "@parcel/watcher"; -import LRUCache from 'lru-cache'; import { Profile } from "../tool-env/profile"; import { statOrNull, lstat, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile } from "./files"; @@ -52,27 +51,8 @@ interface Entry extends SafeWatcher { _fire(event: string): void; } -// Set METEOR_WATCH_USE_LRU environment variable to a truthy value to -// enable LRU caching for watcher entries to reduce memory usage. -const useLRU = Boolean(JSON.parse(process.env.METEOR_WATCH_USE_LRU || "false")); - -// enable to tweak maximum entries size (default 500MB). -const sizeMaxLRU = JSON.parse(process.env.METEOR_WATCH_USE_LRU_SIZE || Math.pow(2, 20) * 500); - // Registry mapping normalized absolute paths to their watcher entry. -// If LRU caching is enabled, this will be an LRUCache instance. -// Otherwise, we use a Map object for entries. -const entries = useLRU - ? new LRUCache({ - max: sizeMaxLRU, // 1MB max size - length: (entry, key) => { - return key.length + (entry ? 100 : 10); - }, - dispose: (key, entry) => { - return entry?.close(); - }, - }) - : new Map(); +const entries = new Map(); function getEntry(path: string): Entry | null | undefined { return entries.get(path); @@ -83,10 +63,7 @@ function setEntry(path: string, entry: Entry | null): void { } function deleteEntry(path: string): void { - if (useLRU) { - return entries.del(path); - } - return entries.delete(path); + entries.delete(path); } // Watch roots are directories for which we have an active ParcelWatcher subscription. @@ -118,8 +95,7 @@ if (process.env.METEOR_WATCH_PRIORITIZE_CHANGED && } // Set of paths for which a change event has been fired, watched with -// watchLibrary.watch if available. This could be an LRU cache, but in -// practice it should never grow large enough for that to matter. +// watchLibrary.watch if available. const changedPaths = new Set; function shouldIgnorePath(absPath: string): boolean { From 2338850138108833ff9748d1f2dec6e67a6bc198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 23 Apr 2025 10:04:24 +0200 Subject: [PATCH 12/18] watch properly add/delete events --- tools/fs/safe-watcher.ts | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index 984470028c..4da8a8fe2b 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -1,9 +1,8 @@ import { Stats } from 'fs'; -import { dirname } from "path"; import ParcelWatcher from "@parcel/watcher"; import { Profile } from "../tool-env/profile"; -import { statOrNull, lstat, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile } from "./files"; +import { statOrNull, lstat, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile, pathResolve, pathDirname } from "./files"; // Register process exit handlers to ensure subscriptions are properly cleaned up const registerExitHandlers = () => { @@ -66,6 +65,27 @@ function deleteEntry(path: string): void { entries.delete(path); } +function findNearestEntry(startPath: string): Entry | null { + let currentPath = pathResolve(startPath); + + while (true) { + const entry = getEntry(currentPath); + if (entry) { + return entry; // Found it! + } + + const parentPath = pathDirname(currentPath); + if (parentPath === currentPath) { + // Reached root + break; + } + + currentPath = parentPath; + } + + return null; +} + // Watch roots are directories for which we have an active ParcelWatcher subscription. const watchRoots = new Set(); // For each watch root, store its active subscription. @@ -187,6 +207,7 @@ async function ensureWatchRoot(dirPath: string): Promise { const subscription = await ParcelWatcher.subscribe( osDirPath, (err, events) => { + console.log("--> (safe-watcher.ts-Line: 212) events: ", events); if (err) { console.error(`Parcel watcher error on ${osDirPath}:`, err); // Only disable native watching for critical errors (like ENOSPC). @@ -200,7 +221,7 @@ async function ensureWatchRoot(dirPath: string): Promise { // Dispatch each event to any registered entries. for (const event of events) { const changedPath = toPosixPath(event.path); - const entry = getEntry(changedPath); + const entry = findNearestEntry(changedPath); if (!entry) continue; // In Meteor's safe-watcher API, both create/update trigger "change" events. const evtType = event.type === "delete" ? "delete" : "change"; @@ -286,9 +307,9 @@ export const watch = Profile( let watchTarget: string; try { const st = statOrNull(convertToOSPath(absPath)); - watchTarget = st?.isDirectory() ? absPath : toPosixPath(dirname(convertToOSPath(absPath))); + watchTarget = st?.isDirectory() ? absPath : toPosixPath(pathDirname(convertToOSPath(absPath))); } catch (e) { - watchTarget = toPosixPath(dirname(convertToOSPath(absPath))); + watchTarget = toPosixPath(pathDirname(convertToOSPath(absPath))); } // Set up a watcher on the parent directory (or the directory itself) if not already active. ensureWatchRoot(watchTarget); @@ -318,10 +339,10 @@ export function addWatchRoot(absPath: string) { try { const st = statOrNull(convertToOSPath(absPath)); if (!st?.isDirectory()) { - watchTarget = toPosixPath(dirname(convertToOSPath(absPath))); + watchTarget = toPosixPath(pathDirname(convertToOSPath(absPath))); } } catch (e) { - watchTarget = toPosixPath(dirname(convertToOSPath(absPath))); + watchTarget = toPosixPath(pathDirname(convertToOSPath(absPath))); } ensureWatchRoot(watchTarget); } From 2a4728fe6cec8effe3844b0976804436d0a1f06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 23 Apr 2025 10:07:21 +0200 Subject: [PATCH 13/18] clean logs --- tools/fs/safe-watcher.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index 4da8a8fe2b..2a003e5c79 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -207,7 +207,6 @@ async function ensureWatchRoot(dirPath: string): Promise { const subscription = await ParcelWatcher.subscribe( osDirPath, (err, events) => { - console.log("--> (safe-watcher.ts-Line: 212) events: ", events); if (err) { console.error(`Parcel watcher error on ${osDirPath}:`, err); // Only disable native watching for critical errors (like ENOSPC). From 5933b9ac8490e7897b38b40f86f53244749660c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 23 Apr 2025 11:17:45 +0200 Subject: [PATCH 14/18] watch node_modules outside npm context to fix a test and avoid regression (keep performance gains) --- tools/fs/safe-watcher.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index 2a003e5c79..719638429e 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -2,7 +2,7 @@ import { Stats } from 'fs'; import ParcelWatcher from "@parcel/watcher"; import { Profile } from "../tool-env/profile"; -import { statOrNull, lstat, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile, pathResolve, pathDirname } from "./files"; +import { statOrNull, lstat, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile, pathResolve, pathDirname, exists } from "./files"; // Register process exit handlers to ensure subscriptions are properly cleaned up const registerExitHandlers = () => { @@ -134,6 +134,12 @@ function shouldIgnorePath(absPath: string): boolean { // Otherwise, do not automatically ignore .meteor (which includes .meteor/packages, etc). } + const cwd = toPosixPath(process.cwd()); + const isWithinCwd = absPath.startsWith(cwd); + if (isWithinCwd) { + return absPath.includes(`${cwd}/node_modules`); + } + // For node_modules: ignore contents unless the package directory is a symlink (which means it’s under active development). const nmIndex = parts.indexOf("node_modules"); if (nmIndex !== -1) { @@ -200,8 +206,11 @@ async function ensureWatchRoot(dirPath: string): Promise { return; } - // Set up ignore patterns to skip deep node_modules and .meteor/local cache - const ignorePatterns = ["**/node_modules/**", "**/.meteor/local/**"]; + // Set up ignore patterns to skip node_modules and .meteor/local cache + const cwd = toPosixPath(process.cwd()); + const isWithinCwd = dirPath.startsWith(cwd); + const ignPrefix = isWithinCwd ? "" : "**/"; + const ignorePatterns = [`${ignPrefix}node_modules/**`, `${ignPrefix}.meteor/local/**`]; try { watchRoots.add(dirPath); const subscription = await ParcelWatcher.subscribe( From 76428cfdb2bb70ff3a81e0971454eeff7f938ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 23 Apr 2025 11:25:08 +0200 Subject: [PATCH 15/18] early exit on .meteor/local context --- tools/fs/safe-watcher.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index 719638429e..4c793979d8 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -122,6 +122,13 @@ function shouldIgnorePath(absPath: string): boolean { const posixPath = toPosixPath(absPath); const parts = posixPath.split('/'); + const cwd = toPosixPath(process.cwd()); + const isWithinCwd = absPath.startsWith(cwd); + + if (isWithinCwd && absPath.includes(`${cwd}/.meteor/local`)) { + return true; + } + // Check for .meteor: allow the .meteor directory itself, // but ignore its "local" subdirectory (or any immediate child folder that indicates cache). const meteorIndex = parts.indexOf(".meteor"); @@ -134,8 +141,6 @@ function shouldIgnorePath(absPath: string): boolean { // Otherwise, do not automatically ignore .meteor (which includes .meteor/packages, etc). } - const cwd = toPosixPath(process.cwd()); - const isWithinCwd = absPath.startsWith(cwd); if (isWithinCwd) { return absPath.includes(`${cwd}/node_modules`); } From 69e6e55ba3ec548154717af1b28a71ed534e73f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 23 Apr 2025 12:06:35 +0200 Subject: [PATCH 16/18] disable symbolic link node_module watching since was not originally supported and covered by tests --- tools/fs/safe-watcher.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tools/fs/safe-watcher.ts b/tools/fs/safe-watcher.ts index 4c793979d8..e99a139400 100644 --- a/tools/fs/safe-watcher.ts +++ b/tools/fs/safe-watcher.ts @@ -2,7 +2,7 @@ import { Stats } from 'fs'; import ParcelWatcher from "@parcel/watcher"; import { Profile } from "../tool-env/profile"; -import { statOrNull, lstat, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile, pathResolve, pathDirname, exists } from "./files"; +import { statOrNull, toPosixPath, convertToOSPath, pathRelative, watchFile, unwatchFile, pathResolve, pathDirname } from "./files"; // Register process exit handlers to ensure subscriptions are properly cleaned up const registerExitHandlers = () => { @@ -141,22 +141,15 @@ function shouldIgnorePath(absPath: string): boolean { // Otherwise, do not automatically ignore .meteor (which includes .meteor/packages, etc). } + // For project node_modules: ignore npm node_modules, rest are valid if (isWithinCwd) { return absPath.includes(`${cwd}/node_modules`); } - // For node_modules: ignore contents unless the package directory is a symlink (which means it’s under active development). + // For external node_modules: ignore contents const nmIndex = parts.indexOf("node_modules"); if (nmIndex !== -1) { - try { - const stat = lstat(absPath); - if (!stat?.isSymbolicLink()) { - return true; - } - // If the package folder is symlinked, we want to watch it. - } catch (e) { - return true; - } + return true; } return false; From dc17c9003f2f576751ec7a6fa010ef2d842efb1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 23 Apr 2025 12:39:16 +0200 Subject: [PATCH 17/18] ensure old tests can exit properly on using the new closeAllWatchers --- tools/tests/old/test-bundler-assets.js | 9 ++++++++- tools/tests/old/test-bundler-options.js | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tools/tests/old/test-bundler-assets.js b/tools/tests/old/test-bundler-assets.js index c0c1278434..6f7479f7bc 100644 --- a/tools/tests/old/test-bundler-assets.js +++ b/tools/tests/old/test-bundler-assets.js @@ -172,6 +172,13 @@ makeGlobalAsyncLocalStorage().run( // Allow the process to exit normally, since optimistic file watchers // may be keeping the event loop busy. - safeWatcher.closeAllWatchers(); + safeWatcher.closeAllWatchers() + .then(() => { + process.exit(0); + }) + .catch(err => { + console.error(`Error closing watchers:`, err); + process.exit(1); + }); } ); diff --git a/tools/tests/old/test-bundler-options.js b/tools/tests/old/test-bundler-options.js index 2bf8d00aa1..871146afb6 100644 --- a/tools/tests/old/test-bundler-options.js +++ b/tools/tests/old/test-bundler-options.js @@ -200,6 +200,13 @@ makeGlobalAsyncLocalStorage().run( // Allow the process to exit normally, since optimistic file watchers // may be keeping the event loop busy. - safeWatcher.closeAllWatchers(); + safeWatcher.closeAllWatchers() + .then(() => { + process.exit(0); + }) + .catch(err => { + console.error(`Error closing watchers:`, err); + process.exit(1); + }); } ); From c2dcae2d3953b5eb51c237c5dba3116daa423859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Wed, 23 Apr 2025 12:44:07 +0200 Subject: [PATCH 18/18] ensure old tests can exit properly on using the new closeAllWatchers --- tools/tests/old/test-bundler-assets.js | 10 ++-------- tools/tests/old/test-bundler-options.js | 10 ++-------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/tools/tests/old/test-bundler-assets.js b/tools/tests/old/test-bundler-assets.js index 6f7479f7bc..d15af06f27 100644 --- a/tools/tests/old/test-bundler-assets.js +++ b/tools/tests/old/test-bundler-assets.js @@ -172,13 +172,7 @@ makeGlobalAsyncLocalStorage().run( // Allow the process to exit normally, since optimistic file watchers // may be keeping the event loop busy. - safeWatcher.closeAllWatchers() - .then(() => { - process.exit(0); - }) - .catch(err => { - console.error(`Error closing watchers:`, err); - process.exit(1); - }); + safeWatcher.closeAllWatchers(); + process.exit(0); } ); diff --git a/tools/tests/old/test-bundler-options.js b/tools/tests/old/test-bundler-options.js index 871146afb6..9d2b75a299 100644 --- a/tools/tests/old/test-bundler-options.js +++ b/tools/tests/old/test-bundler-options.js @@ -200,13 +200,7 @@ makeGlobalAsyncLocalStorage().run( // Allow the process to exit normally, since optimistic file watchers // may be keeping the event loop busy. - safeWatcher.closeAllWatchers() - .then(() => { - process.exit(0); - }) - .catch(err => { - console.error(`Error closing watchers:`, err); - process.exit(1); - }); + safeWatcher.closeAllWatchers(); + process.exit(0); } );