Skip to content

Instantly share code, notes, and snippets.

@jkoppel
Created February 5, 2026 18:06
Show Gist options
  • Select an option

  • Save jkoppel/05f45c9cbd2b5d3b57425915dc699c3d to your computer and use it in GitHub Desktop.

Select an option

Save jkoppel/05f45c9cbd2b5d3b57425915dc699c3d to your computer and use it in GitHub Desktop.
@parcel/watcher DirTree cache bug PoC - ignores patterns not included in cache key (Linux only)

@parcel/watcher DirTree Cache Bug - Proof of Concept

Summary

There is a bug in @parcel/watcher where the DirTree is cached by directory path only, not including ignore patterns. This causes watchers with different ignore patterns to share an incorrectly filtered directory tree.

Affected Platforms

  • Linux (inotify): AFFECTED - Uses DirTree::getCached()
  • macOS (FSEvents): NOT AFFECTED - Creates fresh DirTree per subscription
  • Windows: LIKELY AFFECTED - Uses BruteForceBackend like Linux

The Bug

Code Flow

  1. InotifyBackend::subscribe() calls getTree(watcher)
  2. BruteForceBackend::getTree() calls DirTree::getCached(watcher->mDir)
  3. DirTree::getCached() uses only the directory path as cache key
  4. If cache miss, readTree(watcher, tree) is called which respects watcher->isIgnored()
  5. Tree is marked isComplete = true and cached

The Problem

// src/DirTree.cc - Cache key is ONLY the directory path!
std::shared_ptr<DirTree> DirTree::getCached(std::string root) {
  auto found = cache.find(root);  // No ignore patterns in key!
  ...
}

When a second watcher subscribes to the same directory with different ignore patterns:

  • It gets the cached tree built with the FIRST watcher's ignore patterns
  • tree->isComplete is already true, so readTree() is NOT called
  • The second watcher's ignore patterns are never applied to tree construction

Reproduction Steps

  1. Create Watcher A on /dir with ignore: ['subdir']

    • DirTree is built excluding subdir/
    • Tree is cached with key /dir
  2. Create Watcher B on /dir with ignore: [] (nothing ignored)

    • DirTree::getCached('/dir') returns the cached tree
    • Tree is already complete, readTree() is skipped
    • Watcher B gets a tree missing subdir/
  3. Modify a file in subdir/

    • Expected: Watcher B receives the event
    • Actual (Linux): Watcher B receives NOTHING - no inotify watch exists for subdir/

Running the PoC

On macOS (will NOT reproduce - shows correct behavior)

npm install
node bug-poc-final.js

On Linux (WILL reproduce the bug)

# Using Docker from macOS/Windows
./run-on-linux.sh

# Or manually
docker build -t watcher-bug-poc .
docker run --rm watcher-bug-poc

# Or directly on a Linux machine
npm install
node bug-poc-final.js

Expected Output on Linux

=== RESULTS ===

*** BUG CONFIRMED ***
Watcher 2 received NO events from subdir!

Root cause: DirTree was cached without subdir from Watcher 1.
Watcher 2 inherited this incomplete tree and never set up
an inotify watch for the subdir directory.

Code References

File Function Issue
src/DirTree.cc getCached() Cache key is only directory path
src/shared/BruteForceBackend.cc getTree() Sets isComplete = true after first build
src/linux/InotifyBackend.cc subscribe() Uses cached tree without checking ignores
src/unix/fts.cc readTree() Uses FTS_SKIP for ignored dirs, but result is cached globally

Suggested Fix

Option 1: Include ignore patterns in cache key

std::string cacheKey = root + "|" + serializeIgnorePatterns(watcher);
auto found = cache.find(cacheKey);

Option 2: Don't cache DirTree globally, create per-watcher like FSEvents does

// In InotifyBackend::subscribe()
auto tree = std::make_shared<DirTree>(watcher->mDir);
readTree(watcher, tree);

Option 3: Check ignore patterns match before returning cached tree

if (found != cache.end()) {
  auto tree = found->second.lock();
  if (tree && tree->ignorePatterns == watcher->ignorePatterns) {
    return tree;
  }
}
/**
* @parcel/watcher DirTree Cache Bug - Proof of Concept
*
* This demonstrates a bug where DirTree is cached by directory path only,
* not by ignore patterns. This causes incorrect behavior when multiple
* watchers with different ignore patterns watch the same directory.
*
* BUG AFFECTS: Linux (inotify), likely Windows (ReadDirectoryChangesW)
* NOT AFFECTED: macOS (FSEvents creates fresh DirTree per subscription)
*
* Run on Linux with: node bug-poc-final.js
*/
const watcher = require('@parcel/watcher');
const fs = require('fs');
const path = require('path');
const WATCH_DIR = path.join(__dirname, 'test-dir');
const SUBDIR = path.join(WATCH_DIR, 'subdir');
const TEST_FILE = path.join(SUBDIR, 'test.txt');
const ROOT_FILE = path.join(WATCH_DIR, 'root.txt');
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function main() {
// Setup
fs.rmSync(WATCH_DIR, { recursive: true, force: true });
fs.mkdirSync(SUBDIR, { recursive: true });
fs.writeFileSync(TEST_FILE, 'initial');
fs.writeFileSync(ROOT_FILE, 'initial');
console.log('╔══════════════════════════════════════════════════════════════╗');
console.log('║ @parcel/watcher DirTree Cache Bug - Proof of Concept ║');
console.log('╚══════════════════════════════════════════════════════════════╝\n');
console.log(`Platform: ${process.platform}`);
console.log(`Node: ${process.version}\n`);
if (process.platform !== 'linux') {
console.log('⚠️ WARNING: This bug only affects Linux (inotify backend).');
console.log(' macOS uses FSEvents which creates a fresh DirTree per watcher.');
console.log(' Running anyway to show expected behavior on this platform.\n');
}
console.log('Directory structure:');
console.log(` ${WATCH_DIR}/`);
console.log(` ├── root.txt`);
console.log(` └── subdir/`);
console.log(` └── test.txt\n`);
// Track events for each watcher
let watcher1Events = [];
let watcher2Events = [];
// ═══════════════════════════════════════════════════════════════════════════
// STEP 1: First watcher ignores subdir
// ═══════════════════════════════════════════════════════════════════════════
console.log('─'.repeat(66));
console.log('STEP 1: Subscribe Watcher 1 (ignores "subdir")\n');
console.log(' On Linux, this:');
console.log(' 1. Calls DirTree::getCached("/test-dir") - cache miss');
console.log(' 2. Calls readTree() which uses FTS_SKIP for ignored paths');
console.log(' 3. DirTree is built WITHOUT subdir/');
console.log(' 4. Tree is cached and marked isComplete = true');
console.log(' 5. inotify_add_watch() called for /test-dir only\n');
const sub1 = await watcher.subscribe(
WATCH_DIR,
(err, events) => {
for (const e of events) {
console.log(` [W1] ${e.type}: ${path.relative(WATCH_DIR, e.path)}`);
watcher1Events.push(e);
}
},
{ ignore: ['subdir'] }
);
await sleep(500);
console.log(' ✓ Watcher 1 subscribed\n');
// ═══════════════════════════════════════════════════════════════════════════
// STEP 2: Second watcher with NO ignores
// ═══════════════════════════════════════════════════════════════════════════
console.log('─'.repeat(66));
console.log('STEP 2: Subscribe Watcher 2 (NO ignores) - WHILE Watcher 1 active\n');
console.log(' On Linux, this:');
console.log(' 1. Calls DirTree::getCached("/test-dir") - CACHE HIT!');
console.log(' 2. Returns tree built by Watcher 1 (missing subdir/)');
console.log(' 3. tree->isComplete is true, so readTree() is SKIPPED');
console.log(' 4. inotify_add_watch() called for /test-dir only');
console.log(' 5. *** NO watch created for subdir/ ***\n');
const sub2 = await watcher.subscribe(
WATCH_DIR,
(err, events) => {
for (const e of events) {
console.log(` [W2] ${e.type}: ${path.relative(WATCH_DIR, e.path)}`);
watcher2Events.push(e);
}
},
{ ignore: [] } // No ignores - should watch EVERYTHING
);
await sleep(500);
console.log(' ✓ Watcher 2 subscribed\n');
// ═══════════════════════════════════════════════════════════════════════════
// STEP 3: Unsubscribe Watcher 1
// ═══════════════════════════════════════════════════════════════════════════
console.log('─'.repeat(66));
console.log('STEP 3: Unsubscribe Watcher 1\n');
await sub1.unsubscribe();
console.log(' ✓ Watcher 1 unsubscribed');
console.log(' Watcher 2 is now the only active watcher.\n');
await sleep(500);
// ═══════════════════════════════════════════════════════════════════════════
// STEP 4: Trigger file changes
// ═══════════════════════════════════════════════════════════════════════════
console.log('─'.repeat(66));
console.log('STEP 4: Modify files\n');
// Clear events from setup
watcher2Events = [];
// Modify root file (should always work)
console.log(' Writing to root.txt...');
fs.writeFileSync(ROOT_FILE, 'modified-' + Date.now());
await sleep(1000);
// Modify subdir file (this is the bug!)
console.log(' Writing to subdir/test.txt...');
fs.writeFileSync(TEST_FILE, 'modified-' + Date.now());
console.log('\n Waiting for events...\n');
await sleep(2000);
// ═══════════════════════════════════════════════════════════════════════════
// RESULTS
// ═══════════════════════════════════════════════════════════════════════════
console.log('═'.repeat(66));
console.log('RESULTS\n');
const rootEvents = watcher2Events.filter(e => e.path.includes('root.txt'));
const subdirEvents = watcher2Events.filter(e => e.path.includes('subdir'));
console.log(` Events from root.txt: ${rootEvents.length > 0 ? '✓ YES' : '✗ NO'}`);
console.log(` Events from subdir/: ${subdirEvents.length > 0 ? '✓ YES' : '✗ NO'}`);
console.log('\n' + '─'.repeat(66));
if (subdirEvents.length === 0 && rootEvents.length > 0) {
console.log('\n 🐛 BUG CONFIRMED!\n');
console.log(' Watcher 2 (with no ignores) did NOT receive events from subdir/');
console.log(' even though it should be watching the entire directory tree.\n');
console.log(' Root cause: DirTree cache key is only the directory path.');
console.log(' Watcher 2 inherited the filtered tree from Watcher 1.\n');
} else if (subdirEvents.length > 0) {
console.log('\n ✓ Bug NOT reproduced on this platform.\n');
if (process.platform === 'darwin') {
console.log(' This is expected on macOS - FSEvents creates a fresh DirTree');
console.log(' per subscription, avoiding the caching issue.\n');
}
} else {
console.log('\n ⚠️ No events received at all - something else may be wrong.\n');
}
// Cleanup
await sub2.unsubscribe();
fs.rmSync(WATCH_DIR, { recursive: true, force: true });
}
main().catch(err => {
console.error('Error:', err);
process.exit(1);
});
FROM node:20-slim
WORKDIR /app
# Install build tools for native addon
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install
COPY bug-poc-final.js ./
CMD ["node", "bug-poc-final.js"]
{
"name": "watcher-bug-poc",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@parcel/watcher": "^2.5.6"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment