diff --git a/scopes/scope/scope/scope.main.runtime.ts b/scopes/scope/scope/scope.main.runtime.ts index f31a6db838aa..5fa4de48a361 100644 --- a/scopes/scope/scope/scope.main.runtime.ts +++ b/scopes/scope/scope/scope.main.runtime.ts @@ -566,7 +566,10 @@ export class ScopeMain implements ComponentFactory { const globalConfigFile = globalStore.getPath(); const scopeJsonPath = scopeStore.getPath(); const pathsToWatch = [scopeIndexFile, remoteLanesDir, globalConfigFile, scopeJsonPath]; - const watcher = chokidar.watch(pathsToWatch, watchOptions); + // Use polling to reduce FSEvents stream consumption on macOS. + // These files change infrequently (mainly during import/export operations), + // so the small CPU overhead of polling is acceptable. + const watcher = chokidar.watch(pathsToWatch, { ...watchOptions, usePolling: true, interval: 300 }); watcher.on('ready', () => { this.logger.debug(`watchSystemFiles has started, watching ${pathsToWatch.join(', ')}`); }); diff --git a/scopes/workspace/watcher/fsevents-error.ts b/scopes/workspace/watcher/fsevents-error.ts index a793d88a13aa..b1016aa4639d 100644 --- a/scopes/workspace/watcher/fsevents-error.ts +++ b/scopes/workspace/watcher/fsevents-error.ts @@ -108,6 +108,10 @@ Each running watcher process may consume multiple streams internally.`; } message += `\n +${chalk.green('Recommended solution:')} Install Watchman to avoid FSEvents limits entirely. +Watchman is a file watching service that uses a single daemon for all watchers: + ${chalk.cyan('brew install watchman')} + ${chalk.yellow('Note:')} If you're using "bit start" or "bit run", you don't need "bit watch" separately. With the VS Code Bit extension, you can use "Compile on Change" instead of running a watcher manually. diff --git a/scopes/workspace/watcher/watcher.ts b/scopes/workspace/watcher/watcher.ts index 57d91a623881..de16d9709836 100644 --- a/scopes/workspace/watcher/watcher.ts +++ b/scopes/workspace/watcher/watcher.ts @@ -26,8 +26,9 @@ import type { CheckTypes } from './check-types'; import type { WatcherMain } from './watcher.main.runtime'; import { WatchQueue } from './watch-queue'; import type { Logger } from '@teambit/logger'; -import type { Event } from '@parcel/watcher'; +import type { Event, Options as ParcelWatcherOptions } from '@parcel/watcher'; import ParcelWatcher from '@parcel/watcher'; +import { spawnSync } from 'child_process'; import { sendEventsToClients } from '@teambit/harmony.modules.send-server-sent-events'; import { WatcherDaemon, WatcherClient, getOrCreateWatcherConnection, type WatcherError } from './watcher-daemon'; import { formatFSEventsErrorMessage } from './fsevents-error'; @@ -98,6 +99,8 @@ export class Watcher { private parcelSubscription: { unsubscribe: () => Promise } | null = null; // Signal handlers for cleanup (to avoid accumulation) private signalCleanupHandler: (() => void) | null = null; + // Cached Watchman availability (checked once per process lifetime) + private watchmanAvailable: boolean | null = null; constructor( private workspace: Workspace, private pubsub: PubsubMain, @@ -130,6 +133,48 @@ export class Watcher { ]; } + /** + * Get Parcel watcher options, preferring Watchman on macOS when available. + * On macOS, FSEvents is the default but has a system-wide limit of ~500 streams. + * Watchman is a single-daemon solution that avoids this limit. + */ + private getParcelWatcherOptions(): ParcelWatcherOptions { + const options: ParcelWatcherOptions = { + ignore: this.getParcelIgnorePatterns(), + }; + + // On macOS, prefer Watchman if available to avoid FSEvents stream limit + if (process.platform === 'darwin') { + if (this.isWatchmanAvailable()) { + options.backend = 'watchman'; + this.logger.debug('Using Watchman backend for file watching'); + } else { + this.logger.debug('Using FSEvents backend for file watching (Watchman not available)'); + } + } + + return options; + } + + /** + * Check if Watchman is installed. + * Result is cached to avoid repeated executions. + */ + private isWatchmanAvailable(): boolean { + if (this.watchmanAvailable !== null) { + return this.watchmanAvailable; + } + try { + // Use spawnSync with shell: false (default) for security - prevents command injection + const result = spawnSync('watchman', ['version'], { stdio: 'ignore', timeout: 5000 }); + // Check for spawn errors (e.g., command not found) or non-zero exit status + this.watchmanAvailable = !result.error && result.status === 0; + } catch { + this.watchmanAvailable = false; + } + return this.watchmanAvailable; + } + async watch() { await this.setRootDirs(); const componentIds = Object.values(this.rootDirs); @@ -178,9 +223,7 @@ export class Watcher { // Original direct Parcel watcher logic (fallback) try { - await ParcelWatcher.subscribe(this.workspace.path, this.onParcelWatch.bind(this), { - ignore: this.getParcelIgnorePatterns(), - }); + await ParcelWatcher.subscribe(this.workspace.path, this.onParcelWatch.bind(this), this.getParcelWatcherOptions()); // Write initial snapshot for FSEvents buffer overflow recovery await this.writeSnapshotIfNeeded(); @@ -210,9 +253,7 @@ export class Watcher { this.parcelSubscription = await ParcelWatcher.subscribe( this.workspace.path, this.onParcelWatchAsDaemon.bind(this), - { - ignore: this.getParcelIgnorePatterns(), - } + this.getParcelWatcherOptions() ); // Write initial snapshot for FSEvents buffer overflow recovery @@ -880,9 +921,7 @@ export class Watcher { } try { - await ParcelWatcher.writeSnapshot(this.workspace.path, this.snapshotPath, { - ignore: this.getParcelIgnorePatterns(), - }); + await ParcelWatcher.writeSnapshot(this.workspace.path, this.snapshotPath, this.getParcelWatcherOptions()); this.logger.debug('Watcher snapshot written successfully'); } catch (err: any) { this.logger.debug(`Failed to write watcher snapshot: ${err.message}`); @@ -931,9 +970,11 @@ export class Watcher { } // Get all events since last snapshot - const missedEvents = await ParcelWatcher.getEventsSince(this.workspace.path, this.snapshotPath, { - ignore: this.getParcelIgnorePatterns(), - }); + const missedEvents = await ParcelWatcher.getEventsSince( + this.workspace.path, + this.snapshotPath, + this.getParcelWatcherOptions() + ); // Write new snapshot immediately after reading events to prevent re-processing same events await this.writeSnapshotIfNeeded();