Last active
March 13, 2026 20:00
-
-
Save davidfirst/713e33812617bcbd0b01e3d1b592fbd7 to your computer and use it in GitHub Desktop.
macOS FSEventStream limits (undocumented by Apple): 512 per-process, 1024 system-wide, NOT multiplicative. Diagnose watcher exhaustion in VS Code, Watchman, Webpack, etc.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * fsevents-exhaust — macOS FSEventStream limit tester | |
| * | |
| * Measures the maximum number of FSEventStream instances that macOS allows, | |
| * both per-process and system-wide. This is useful for diagnosing | |
| * FSEventStreamStart failures in tools like Watchman, VS Code, Webpack, etc. | |
| * | |
| * Background: | |
| * macOS's fseventsd daemon enforces a hard limit on FSEventStream clients. | |
| * This limit is NOT documented by Apple. The per-process limit is 512 | |
| * (confirmed via `log show --predicate 'process == "fseventsd"'` which | |
| * reports: "too many clients for pid <PID> (limit 512)"). | |
| * There may also be a system-wide limit (some sources report 1024). | |
| * When the limit is reached, FSEventStreamStart() silently returns false, | |
| * causing watchers to fail with no clear error from the OS. | |
| * | |
| * Modes: | |
| * | |
| * Default (no flags) — Per-process limit test | |
| * Creates watchman watches via the watchman unix socket until | |
| * FSEventStreamStart fails inside the watchman server process. | |
| * All streams are in a single PID (watchman), so this measures | |
| * the per-process limit (typically 512). | |
| * Requires: watchman installed and running. | |
| * Cleanup: shuts down watchman server to release all streams. | |
| * | |
| * --system — System-wide limit test (1 stream per process) | |
| * Compiles a tiny C helper on the fly (~50KB binary), then spawns | |
| * separate processes each holding exactly 1 FSEventStream via the | |
| * CoreServices API. Each process has its own PID, so this bypasses | |
| * the per-process limit and measures the system-wide cap. | |
| * Requires: Xcode Command Line Tools (for `cc`). | |
| * Cleanup: kills all child processes and removes temp directory. | |
| * | |
| * --system-packed — System-wide limit test (512 streams per process) | |
| * Like --system, but each process fills up its full per-PID quota (512) | |
| * before spawning the next process. This reveals how the per-process | |
| * and system-wide limits interact. | |
| * Reports: process count, streams per process, and total streams. | |
| * Requires: Xcode Command Line Tools (for `cc`). | |
| * Cleanup: kills all child processes and removes temp directory. | |
| * | |
| * Usage: | |
| * node fsevents-exhaust.mjs # test per-process limit (via watchman) | |
| * node fsevents-exhaust.mjs --system # test system-wide limit (1 per process) | |
| * node fsevents-exhaust.mjs --system-packed # test system-wide limit (512 per process) | |
| * | |
| * Notes: | |
| * - Creates temp directories under $TMPDIR (auto-cleaned on exit) | |
| * - The watchman CLI exits 0 even on failure (error is in JSON response) | |
| * - Uses fs.realpathSync() to resolve /var → /private/var (macOS symlink) | |
| * - The C helper uses dispatch queues (not CFRunLoop) to avoid deprecation warnings | |
| * - The --system tests use a C helper instead of Node.js child processes | |
| * because each Node.js process costs ~30MB RAM vs ~50KB for the C binary, | |
| * allowing us to test thousands of streams without exhausting memory | |
| */ | |
| import fs from 'fs'; | |
| import os from 'os'; | |
| import path from 'path'; | |
| import net from 'net'; | |
| import { execSync, spawn } from 'child_process'; | |
| const mode = process.argv[2]; | |
| if (mode === '--system') { | |
| testSystemWide(); | |
| } else if (mode === '--system-packed') { | |
| testSystemWidePacked(); | |
| } else { | |
| testPerProcess(); | |
| } | |
| // ── Per-process test (default) ────────────────────────────────────────── | |
| // | |
| // Connects to the watchman server's unix socket and sends watch commands | |
| // in series. Each watch creates one FSEventStream in the watchman server | |
| // process. Continues until watchman reports FSEventStreamStart failure. | |
| // Uses the socket directly (not the CLI) for speed (~1ms per watch vs | |
| // ~150ms per CLI invocation). | |
| function testPerProcess() { | |
| const tmpBase = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wm-exhaust-'))); | |
| const sockPath = execSync('watchman get-sockname 2>&1').toString(); | |
| const sock = JSON.parse(sockPath).sockname; | |
| const dirs = []; | |
| let count = 0; | |
| console.log(`Testing PER-PROCESS FSEventStream limit (via watchman)...`); | |
| console.log(`(temp dir: ${tmpBase})\n`); | |
| const client = net.createConnection(sock); | |
| let buffer = ''; | |
| client.on('data', (data) => { | |
| buffer += data.toString(); | |
| // Watchman uses newline-delimited JSON over the socket | |
| let newlineIdx; | |
| while ((newlineIdx = buffer.indexOf('\n')) !== -1) { | |
| const line = buffer.slice(0, newlineIdx); | |
| buffer = buffer.slice(newlineIdx + 1); | |
| let json; | |
| try { json = JSON.parse(line); } catch { continue; } | |
| // Watchman returns {"error": "..."} on failure (exit code is still 0) | |
| if (json.error) { | |
| console.log(`\n=== FAILED at stream #${count + 1} ===`); | |
| console.log(json.error.split('\n')[0]); | |
| cleanup(); | |
| return; | |
| } | |
| count++; | |
| if (count % 50 === 0) process.stdout.write(` ${count} streams OK...\n`); | |
| sendNext(); | |
| } | |
| }); | |
| client.on('connect', () => sendNext()); | |
| client.on('error', (err) => { console.error('Socket error:', err.message); cleanup(); }); | |
| function sendNext() { | |
| const dir = path.join(tmpBase, `d${count}`); | |
| fs.mkdirSync(dir, { recursive: true }); | |
| dirs.push(dir); | |
| client.write(JSON.stringify(['watch', dir]) + '\n'); | |
| } | |
| function cleanup() { | |
| console.log(`\nPer-process limit: ${count} FSEventStreams (in watchman PID)`); | |
| console.log(`Cleaning up — shutting down watchman server...`); | |
| try { execSync('watchman shutdown-server 2>/dev/null', { timeout: 10000 }); } catch {} | |
| fs.rmSync(tmpBase, { recursive: true, force: true }); | |
| console.log(`Done.`); | |
| client.destroy(); | |
| } | |
| } | |
| // ── Compile the C helper used by both --system modes ──────────────────── | |
| function compileHelper(tmpBase, maxStreams) { | |
| const helperSrc = path.join(tmpBase, 'fsevents-helper.c'); | |
| const helperBin = path.join(tmpBase, 'fsevents-helper'); | |
| // C program that creates up to `max` FSEventStreams in a single process. | |
| // argv[1] = base directory (creates subdirs d0, d1, ... inside it) | |
| // argv[2] = max streams to create (default: 1) | |
| // Prints the count of successfully created streams, then sleeps. | |
| // If any stream fails, prints the count so far and exits. | |
| fs.writeFileSync(helperSrc, ` | |
| #include <CoreServices/CoreServices.h> | |
| #include <stdio.h> | |
| #include <stdlib.h> | |
| #include <string.h> | |
| #include <unistd.h> | |
| #include <sys/stat.h> | |
| static void cb(ConstFSEventStreamRef s, void *i, size_t n, void *p, | |
| const FSEventStreamEventFlags *f, const FSEventStreamEventId *e) {} | |
| int main(int argc, char **argv) { | |
| if (argc < 2) { fprintf(stderr, "usage: fsevents-helper <basedir> [max]\\n"); return 1; } | |
| int max = (argc >= 3) ? atoi(argv[2]) : 1; | |
| dispatch_queue_t q = dispatch_queue_create("w", DISPATCH_QUEUE_SERIAL); | |
| int count = 0; | |
| for (int i = 0; i < max; i++) { | |
| char dir[1024]; | |
| snprintf(dir, sizeof(dir), "%s/d%d", argv[1], i); | |
| mkdir(dir, 0755); | |
| CFStringRef p = CFStringCreateWithCString(NULL, dir, kCFStringEncodingUTF8); | |
| CFArrayRef paths = CFArrayCreate(NULL, (const void **)&p, 1, NULL); | |
| FSEventStreamRef stream = FSEventStreamCreate(NULL, cb, NULL, paths, | |
| kFSEventStreamEventIdSinceNow, 1.0, kFSEventStreamCreateFlagNoDefer); | |
| FSEventStreamSetDispatchQueue(stream, q); | |
| if (!FSEventStreamStart(stream)) { | |
| break; | |
| } | |
| count++; | |
| CFRelease(p); | |
| CFRelease(paths); | |
| } | |
| printf("%d\\n", count); | |
| fflush(stdout); | |
| if (count > 0) pause(); | |
| return (count == 0) ? 1 : 0; | |
| } | |
| `); | |
| execSync(`cc -w -o '${helperBin}' '${helperSrc}' -framework CoreServices`); | |
| return helperBin; | |
| } | |
| // ── System-wide test: 1 stream per process (--system) ─────────────────── | |
| // | |
| // Tests the system-wide FSEventStream limit by spawning separate processes, | |
| // each holding exactly 1 FSEventStream. This bypasses the per-PID limit | |
| // because each stream lives in its own process. | |
| async function testSystemWide() { | |
| const tmpBase = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wm-exhaust-'))); | |
| console.log(`Testing SYSTEM-WIDE FSEventStream limit (1 stream per process)...`); | |
| console.log(`Compiling helper...`); | |
| const helperBin = compileHelper(tmpBase); | |
| const children = []; | |
| let count = 0; | |
| let failed = false; | |
| const BATCH = 50; | |
| console.log(`Spawning processes, each holding 1 FSEventStream...\n`); | |
| try { | |
| while (!failed) { | |
| const batch = []; | |
| for (let i = 0; i < BATCH; i++) { | |
| const dir = path.join(tmpBase, `p${count + i}`); | |
| fs.mkdirSync(dir); | |
| batch.push(new Promise((resolve) => { | |
| const child = spawn(helperBin, [dir, '1'], { stdio: ['ignore', 'pipe', 'ignore'] }); | |
| let output = ''; | |
| child.stdout.on('data', (data) => { | |
| output += data.toString(); | |
| const num = parseInt(output.trim()); | |
| if (!isNaN(num)) { | |
| resolve(num > 0 ? { ok: true, child, count: num } : { ok: false, child }); | |
| } | |
| }); | |
| child.on('error', (err) => resolve({ ok: false, child: null, error: err.message })); | |
| child.on('exit', (code) => { | |
| if (!output.trim()) resolve({ ok: false, child: null }); | |
| }); | |
| setTimeout(() => resolve({ ok: false, child, timeout: true }), 5000); | |
| })); | |
| } | |
| const results = await Promise.all(batch); | |
| for (let i = 0; i < results.length; i++) { | |
| const r = results[i]; | |
| if (!r.ok) { | |
| console.log(`\n=== FAILED at process #${count + i + 1} ===`); | |
| if (r.timeout) console.log('Timed out waiting for FSEventStreamStart'); | |
| if (r.error) console.log(r.error); | |
| else console.log('FSEventStreamStart returned false'); | |
| failed = true; | |
| for (let j = 0; j < i; j++) { | |
| if (results[j].child) children.push(results[j].child); | |
| } | |
| break; | |
| } | |
| if (r.child) children.push(r.child); | |
| count++; | |
| } | |
| if (!failed && count % 100 === 0) process.stdout.write(` ${count} processes OK...\n`); | |
| } | |
| } finally { | |
| console.log(`\nSystem-wide limit: ${count} FSEventStreams across ${count} processes (1 per process)`); | |
| console.log(`Cleaning up ${children.length} processes...`); | |
| for (const c of children) { try { c.kill(); } catch {} } | |
| await new Promise(r => setTimeout(r, 500)); | |
| fs.rmSync(tmpBase, { recursive: true, force: true }); | |
| console.log(`Done.`); | |
| process.exit(0); | |
| } | |
| } | |
| // ── System-wide packed test: 512 streams per process (--system-packed) ── | |
| // | |
| // Each process fills its full per-PID quota (up to 512 streams) before we | |
| // spawn the next process. This reveals how per-process and system-wide | |
| // limits interact. For example: | |
| // - If process 1 gets 512, process 2 gets 512, process 3 gets 0 | |
| // → system-wide limit is 1024 | |
| // - If process 1 gets 512, process 2 gets 400, process 3 gets 0 | |
| // → system-wide limit is 912 | |
| // Reports the count per process so you can see exactly how the budget | |
| // is distributed. | |
| async function testSystemWidePacked() { | |
| const tmpBase = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'wm-exhaust-'))); | |
| const PER_PROCESS = 512; | |
| console.log(`Testing SYSTEM-WIDE FSEventStream limit (${PER_PROCESS} streams per process)...`); | |
| console.log(`Compiling helper...`); | |
| const helperBin = compileHelper(tmpBase); | |
| const children = []; | |
| let totalStreams = 0; | |
| let processNum = 0; | |
| console.log(`Each process will grab up to ${PER_PROCESS} streams, then we spawn the next.\n`); | |
| try { | |
| while (true) { | |
| processNum++; | |
| const dir = path.join(tmpBase, `proc${processNum}`); | |
| fs.mkdirSync(dir); | |
| const got = await new Promise((resolve) => { | |
| const child = spawn(helperBin, [dir, String(PER_PROCESS)], { | |
| stdio: ['ignore', 'pipe', 'ignore'] | |
| }); | |
| let output = ''; | |
| child.stdout.on('data', (data) => { | |
| output += data.toString(); | |
| const num = parseInt(output.trim()); | |
| if (!isNaN(num)) { | |
| if (num > 0) { | |
| children.push(child); | |
| } | |
| resolve(num); | |
| } | |
| }); | |
| child.on('error', () => resolve(0)); | |
| child.on('exit', () => { | |
| if (!output.trim()) resolve(0); | |
| }); | |
| setTimeout(() => resolve(-1), 10000); | |
| }); | |
| if (got <= 0) { | |
| console.log(` Process #${processNum}: 0 streams — DONE`); | |
| break; | |
| } | |
| totalStreams += got; | |
| console.log(` Process #${processNum}: ${got} streams (total: ${totalStreams})`); | |
| // If this process got fewer than PER_PROCESS, the system is full | |
| if (got < PER_PROCESS) { | |
| console.log(` (got fewer than ${PER_PROCESS} — system limit reached)`); | |
| break; | |
| } | |
| } | |
| } finally { | |
| console.log(`\n=== RESULT ===`); | |
| console.log(`${processNum} processes, ${totalStreams} total FSEventStreams`); | |
| console.log(`Per-process limit: ${PER_PROCESS}`); | |
| console.log(`System-wide limit: ~${totalStreams}`); | |
| console.log(`\nCleaning up ${children.length} processes...`); | |
| for (const c of children) { try { c.kill(); } catch {} } | |
| await new Promise(r => setTimeout(r, 500)); | |
| fs.rmSync(tmpBase, { recursive: true, force: true }); | |
| console.log(`Done.`); | |
| process.exit(0); | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
macOS FSEventStream Limits (Undocumented)
Apple doesn't document FSEventStream limits, so we had to figure them out empirically.
Per-process limit: 512 streams
Confirmed by creating temp directories and running
watchman watchon each until failure. The system log revealed:(via
log show --predicate 'process == "fseventsd"' --last 2h | grep -i "too many\|limit\|client")System-wide limit: 1024 streams
Confirmed by spawning many processes, each watching a folder, until failure. The system log showed:
The limits are not multiplicative. The total is 1024, not 1024 × 512. If one process uses 512 streams and a second uses 512, the third process can't create any. Confirmed with the
--system-packedflag below, which fills each process to its 512-stream quota before spawning the next.Test it yourself