`oxnode watch` on macOS leaks native memory each time it reopens a folder watcher
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
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/cli1.0.1 (packages/cli/src/watch/dirs.tsonmainhas 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from oxc-project/oxc-node
-
Clarify module resolution with .ts files using import and package.json type commonjsPossibly taken @darcyrush claimed this 1 day ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
oxc-project/oxc-node#905 ·
Maintainers usually reply within 1 day
-
.ts files whose only imports and exports are type-only run as ES modules, without require or modulePossibly taken @darcyrush claimed this 1 day ago. Open
Difficulty 4/5 3-5 days Newbie friendliness 35/100
oxc-project/oxc-node#909 ·
Maintainers usually reply within 1 day
-
.ts files run as ES modules under tsconfig module: node16/node18/nodenext, ignoring package.json "type"Possibly taken @darcyrush claimed this 1 day ago. Open
Difficulty 3/5 1-2 days Newbie friendliness 25/100
oxc-project/oxc-node#907 ·
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
oxc-project/oxc-node#891 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 45/100
oxc-project/oxc-node#449 · 1 comment ·
Maintainers usually reply within 1 day
All issues in oxc-project/oxc-node
Similar issues
-
content triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
tensorflow/tfjs#8754 ·
-
tool-submission
Difficulty 1/5 Under an hour Newbie friendliness 83/100
BraveOPotato/NoSignups#2348 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
code-yeongyu/oh-my-openagent#12784 ·
Maintainers usually reply within 1 day
-
external-issue to-triage
Difficulty 2/5 1-3 hours Newbie friendliness 71/100
Maintainers usually reply within 1 day