Skip to content
Open
5 changes: 4 additions & 1 deletion scopes/scope/scope/scope.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ')}`);
});
Expand Down
4 changes: 4 additions & 0 deletions scopes/workspace/watcher/fsevents-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
65 changes: 52 additions & 13 deletions scopes/workspace/watcher/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { execSync } 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';
Expand Down Expand Up @@ -98,6 +99,8 @@ export class Watcher {
private parcelSubscription: { unsubscribe: () => Promise<void> } | 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,
Expand Down Expand Up @@ -130,6 +133,46 @@ 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 shell executions.
*/
private isWatchmanAvailable(): boolean {
if (this.watchmanAvailable !== null) {
return this.watchmanAvailable;
}
try {
execSync('watchman version', { stdio: 'ignore', timeout: 5000 });
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The execSync call should include the shell: false option for security. While the command is hardcoded (not using user input), explicitly disabling shell interpretation is a security best practice to prevent command injection vulnerabilities.

Suggested fix:

execSync('watchman version', { stdio: 'ignore', timeout: 5000, shell: false });
Suggested change
execSync('watchman version', { stdio: 'ignore', timeout: 5000 });
execSync('watchman version', { stdio: 'ignore', timeout: 5000, shell: false });

Copilot uses AI. Check for mistakes.
this.watchmanAvailable = true;
} catch {
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider logging when Watchman detection fails or times out to help users debug watcher configuration issues. This would make it easier to diagnose problems like Watchman being installed but not responding properly.

try {
  const result = spawnSync('watchman', ['version'], { stdio: 'ignore', timeout: 5000 });
  this.watchmanAvailable = !result.error && result.status === 0;
  if (!this.watchmanAvailable && result.error) {
    this.logger.debug(`Watchman check failed: ${result.error.message}`);
  }
} catch (err: any) {
  this.logger.debug(`Watchman check threw exception: ${err.message}`);
  this.watchmanAvailable = false;
}
Suggested change
} catch {
if (!this.watchmanAvailable) {
if (result.error) {
this.logger.debug(`Watchman detection failed: ${result.error.message}`);
} else {
this.logger.debug(`Watchman detection failed: exited with status ${result.status}`);
}
}
} catch (err: any) {
this.logger.debug(`Watchman detection threw exception: ${err?.message ?? err}`);

Copilot uses AI. Check for mistakes.
this.watchmanAvailable = false;
}
return this.watchmanAvailable;
}

async watch() {
await this.setRootDirs();
const componentIds = Object.values(this.rootDirs);
Expand Down Expand Up @@ -178,9 +221,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();
Expand Down Expand Up @@ -210,9 +251,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
Expand Down Expand Up @@ -880,9 +919,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}`);
Expand Down Expand Up @@ -931,9 +968,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();
Expand Down