`oxnode watch` on macOS leaks native memory each time it reopens a folder watcher

Open
#906 1 comment 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
53/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
node.js, typescript
Domain
backend, devtools

Research direction

Start with packages/cli/src/watch/dirs.ts, especially #sync, #drop, and #open, to understand how directory watchers are managed when folder inodes change. Reproduce the memory growth on macOS using the provided 400-folder setup and compare watcher behavior before and after any change. Done means folder replacements no longer cause the reported native memory growth while the watcher still detects changes to wanted files.

Written by the indexing model from the issue text.

Description

AI Disclosure: The investigation was done by Claude Code using Opus 5.5. I (a human) have reviewed its findings and this report for correctness.

On macOS, the oxnode watch process grows by tens of MB each time a folder of watched files is replaced, and the memory is never freed. On a monorepo dev server (about 480 watched folders per watcher) we saw two idle watchers at 16–17 GB each, almost all of it MALLOC_SMALL. Their app processes were normal at about 500 MB each.

The leak is in Node.js fs.watch on macOS: when a folder handle is closed and opened again, about 0.19 KB is lost for each fs.watch handle the process has open. oxnode watch makes this costly because it keeps one non-recursive fs.watch per folder that holds a wanted file (packages/cli/src/watch/dirs.ts). It closes and reopens a folder's handle when the folder's inode changes (#sync: idOf(h.dir) !== h.id → #drop, then #open). So the loss per reopen grows with the number of watched folders, and a batch of folder replacements reopens hundreds of handles.

Reproduction

400 folders, each with one module that the entry imports:

mkdir repro && cd repro
npm init -y >/dev/null && npm pkg set type=module && npm install @oxc-node/[email protected]
for i in $(seq 0 399); do mkdir -p m/$i; echo "export const v$i = $i;" > m/$i/index.ts; done
{ for i in $(seq 0 399); do echo "import { v$i } from './m/$i/index.ts';"; done
  printf 'console.log("loaded", [%s].length);\nsetInterval(() => {}, 1 << 30);\n' "$(seq -s, -f 'v%g' 0 399)"; } > main.ts
OXNODE_WATCH_DEBUG=1 npx oxnode watch main.ts > log 2>&1 &

Replace every folder with a new one holding the same file (as git checkout and some build tools do), then read the watcher's footprint:

P=$(pgrep -f "watch main.ts" | head -1)
for r in 1 2 3 4 5; do
  for i in $(seq 0 399); do mv m/$i m/$i.old && mkdir m/$i && mv m/$i.old/index.ts m/$i/ && rmdir m/$i.old; done
  sleep 4
  echo "round $r: $(vmmap --summary $P | awk '/Physical footprint:/{print $3}'), handles opened: $(grep -c '^ready ' log)"
done
start:   25.4M, handles opened: 401
round 1: 85.1M, handles opened: 1035
round 2: 145.1M, handles opened: 1675
round 3: 210.9M, handles opened: 2397
round 4: 268.3M, handles opened: 3035
round 5: 288.9M, handles opened: 3120

After 20 s idle the watcher is still at 288.9M, at 0% CPU, with MALLOC_SMALL at 264 MB. Restarting the app (touch main.ts) opens no new handles and adds only 1–2 MB.

The same leak in plain Node.js, without oxnode: keep N directory watchers open, then close and reopen one of them 1000 times:

// fsw.mjs: node fsw.mjs <empty dir> <N>
import { watch, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const [base, n] = [process.argv[2], +process.argv[3]];
const dirs = Array.from({ length: n }, (_, i) => `${base}/d${i}`);
for (const d of dirs) mkdirSync(d, { recursive: true });
const kb = () => { const m = execFileSync('vmmap', ['--summary', String(process.pid)]).toString().match(/Physical footprint:\s+([\d.]+)([KMG])/); return +m[1] * { K: 1, M: 1024, G: 1048576 }[m[2]]; };
const ws = dirs.map((d) => watch(d, () => {}));
await new Promise((r) => setTimeout(r, 500));
const before = kb();
for (let i = 0; i < 1000; i++) { ws[0].close(); ws[0] = watch(dirs[0], () => {}); if (i % 20 === 0) await new Promise((r) => setImmediate(r)); }
await new Promise((r) => setTimeout(r, 500));
console.log(`N=${n}: ${((kb() - before) / 1000).toFixed(1)} KB per reopen`);
process.exit(0);
N=10: 5.4 KB per reopen
N=100: 22.1 KB per reopen
N=400: 77.8 KB per reopen
N=1000: 193.2 KB per reopen

The loss probably comes from libuv rebuilding its shared FSEvents stream with every watched path on each start and stop. I can report that part to Node.js/libuv separately.

Possible fix

On macOS, fs.watch(root, { recursive: true }) uses one FSEvents stream for the whole tree. One recursive handle per watch root (for example, the common ancestor of the wanted folders), filtered against the wanted files in JavaScript, would never reopen handles when folders are replaced. It would also remove the cost per reopen that grows with the number of folders.

Versions
  • @oxc-node/cli 1.0.1 (packages/cli/src/watch/dirs.ts on main has the same logic)
  • Node.js 24.22.1 (libuv 1.52.1)
  • macOS 26.7.1, arm64
Dominant language
TypeScript
Stars
275
Forks
10
Avg merge
5h 31m
Merged PRs (30d)
68

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from oxc-project/oxc-node

All issues in oxc-project/oxc-node

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.