Skip to content

fs: fs.watch on MacOS leaks native memory each time a directory watcher is closed and reopened #67284

Open

Description

@howie-sola

Version

v26.0.0 (also v24.22.1)

Platform

Darwin [Redacted] 25.6.0 Darwin Kernel Version 25.6.0: Tue Aug 18 17:51:47 PDT 2026; root:xnu-12377.161.15.700.19~2/RELEASE_ARM64_T6050 arm64

Subsystem

fs

What steps will reproduce the bug?

On MacOS, run this script using node. It closes and reopens N directory watchers and prints the mem growth as reported by vmap:

    import { watch, mkdtempSync, mkdirSync } from 'node:fs';
    import { tmpdir } from 'node:os';
    import { join } from 'node:path';
    import { execFileSync } from 'node:child_process';

    const footprintKB = () => {
      const out = execFileSync('vmmap', ['--summary', String(process.pid)]).
  toString();
      const [, n, unit] = out.match(/Physical footprint:\s+([\d.]+)([KMG])/);
      return Number(n) * { K: 1, M: 1024, G: 1024 * 1024 }[unit];
    };
    const tick = () => new Promise((r) => setImmediate(r));
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

    for (const n of [10, 100, 400, 1000]) {
      const base = mkdtempSync(join(tmpdir(), 'fsw-'));
      const dirs = Array.from({ length: n }, (_, i) => join(base, `d${i}`));
      for (const d of dirs) mkdirSync(d);
      const watchers = dirs.map((d) => watch(d, () => {}));
      await sleep(500);
      const before = footprintKB();
      for (let i = 0; i < 1000; i++) {
        watchers[0].close();
        watchers[0] = watch(dirs[0], () => {});
        if (i % 20 === 0) await tick();
      }
      await sleep(500);
      const growth = footprintKB() - before;
      console.log(`${n} watchers open: +${(growth / 1024).toFixed(1)} MB after
  1000 reopens (${(growth / 1000).toFixed(1)} KB per reopen)`);
      for (const w of watchers) w.close();
      await sleep(500);
    }
    process.exit(0);

How often does it reproduce? Is there a required condition?

Every run on MacOS

What is the expected behavior? Why is that the expected behavior?

I would expect physical memory usage to stay about flat.

What do you see instead?

 10 watchers open: +4.4 MB after 1000 reopens (4.5 KB per reopen)
 100 watchers open: +11.8 MB after 1000 reopens (12.1 KB per reopen)
 400 watchers open: +48.8 MB after 1000 reopens (50.0 KB per reopen)
 1000 watchers open: +114.8 MB after 1000 reopens (117.6 KB per reopen)

Additional information

This is fixed in libuv by libuv/libuv#5283 ("darwin: release fsevents path allocations"). uv__fsevents_reschedule rebuilds the shared FSEventStream from every watched path each time a watcher starts or stops, and did not release the path array and strings after a successful rebuild.

That commit is not in a libuv release yet. It merged after v1.53.0, so #66282 (update to libuv 1.53.0) does not include it. Could it be cherry-picked into  deps/uv , as was done for aabb7651de in #62561, and backported to v24.x?

Downstream report: oxc-project/oxc-node#906

I used an AI coding assistant to help find the cause and draft this report. I ran the reproduction above myself on both versions listed and checked the libuv change against  src/unix/fsevents.c .

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions