Skip to content

watch: missing --env-file-if-exists path aborts during signal shutdown on Linux #64819

Description

@LLagoon3

Version

v26.5.0 Also reproduced on v24.16.0, v24.17.0, and v24.18.0.

Platform

Linux <redacted-hostname> 7.0.0-22-generic #22-Ubuntu SMP PREEMPT_DYNAMIC Mon May 25 15:54:34 UTC 2026 x86_64 GNU/Linux

Subsystem

watch, fs

What steps will reproduce the bug?

On Linux:

mkdir -p /tmp/repro
cd /tmp/repro
printf '%s\n' 'console.log("started");' > probe.mjs
rm -f missing.env

node --watch --env-file-if-exists=/tmp/repro/missing.env probe.mjs

Wait until this line is printed:

Completed running '/tmp/repro/probe.mjs'. Waiting for file changes before restarting...

Then press Ctrl+C, or send SIGTERM to the watch-mode parent process.

No third-party packages or userland modules are involved.

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

It reproduced on every tested run on Linux:

  • SIGINT: 5/5 runs on v26.5.0
  • SIGTERM: 5/5 runs on v26.5.0

The watch-mode reproduction requires all of the following:

  • Linux (confirmed on linux/x64);
  • --watch;
  • --env-file-if-exists pointing to a path that does not exist; and
  • signal-based shutdown after watch mode has started.

Control cases on the same v26.5.0 binary:

Command Result
--watch --env-file-if-exists=<missing> assertion on signal shutdown
--watch --env-file-if-exists=<existing> clean shutdown
--watch without an env-file flag clean shutdown
--env-file-if-exists=<missing> without --watch clean exit

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

Terminating a watch-mode process should exit cleanly, as it does when the optional env file exists.

The documentation for --env-file-if-exists says that it behaves like --env-file, except that an error is not thrown when the file does not exist.
The absence of that optional file should therefore not cause a native assertion during shutdown.

What do you see instead?

The script starts and watch mode runs normally. On SIGINT or SIGTERM, the watch-mode parent aborts in FSEventWrap::GetInitialized(). Depending on the system's core-dump configuration, this may also write a core dump.

/tmp/repro/missing.env not found. Continuing without it.
/tmp/repro/missing.env not found. Continuing without it.
started
Completed running '/tmp/repro/probe.mjs'. Waiting for file changes before restarting...

  #  node[...]: static void node::(anonymous namespace)::FSEventWrap::GetInitialized(const FunctionCallbackInfo<Value> &) at ../src/fs_event_wrap.cc:92
  #  Assertion failed: (wrap) != nullptr

----- JavaScript stack trace -----

1: FSWatcher.close (node:internal/fs/watchers:348:21)
2: #unwatch (node:internal/watch_mode/files_watcher:89:20)
3: clear (node:internal/watch_mode/files_watcher:214:20)
4: node:internal/main/watch_mode:208:13
5: emit (node:events:509:20)
Representative native and JavaScript stack from v26.5.0
  #  node[...]: static void node::(anonymous namespace)::FSEventWrap::GetInitialized(const FunctionCallbackInfo<Value> &) at ../src/fs_event_wrap.cc:92
  #  Assertion failed: (wrap) != nullptr

----- Native stack trace -----

 1: 0x81d228 node::Assert(node::AssertionInfo const&) [node]
 2: 0x92ee24  [node]
 3: 0x... 

----- JavaScript stack trace -----

1: FSWatcher.close (node:internal/fs/watchers:348:21)
2: #unwatch (node:internal/watch_mode/files_watcher:89:20)
3: clear (node:internal/watch_mode/files_watcher:214:20)
4: node:internal/main/watch_mode:208:13
5: emit (node:events:509:20)

The native addresses and PID have been shortened because they vary between runs and are not needed to identify the assertion.

Additional information

Smaller fs.watch() reproduction

The underlying delayed-close problem can be reproduced without watch mode or an env-file flag:

rm -f /tmp/node-watch-close-missing

node -e "
  const fs = require('node:fs');
  const watcher = fs.watch(
    '/tmp/node-watch-close-missing',
    { throwIfNoEntry: false },
  );
  setTimeout(() => watcher.close(), 0);
"

This reaches the same FSEventWrap::GetInitialized() assertion on v26.5.0.
Closing the returned watcher synchronously does not crash:

node -e "
  const fs = require('node:fs');
  const watcher = fs.watch(
    '/tmp/node-watch-close-missing',
    { throwIfNoEntry: false },
  );
  watcher.close();
  console.log('closed');
"

This timing difference may explain why the regression test added by #61870 did not catch the shutdown
crash: test-fs-watch-enoent.js closes the returned watcher immediately.

Source path

The current v26.5.0 source takes the following path:

  1. lib/internal/main/watch_mode.js registers
    --env-file-if-exists paths with { allowMissing: true }.

  2. lib/internal/watch_mode/files_watcher.js calls:

    fs.watch(path, {
      recursive,
      signal,
      throwIfNoEntry: !allowMissing,
    });
  3. On Linux, uv_fs_event_start() returns UV_ENOENT for the missing path.
    FSEventWrap::Start() closes the native handle on that error, while
    FSWatcher.prototype[kFSWatchStart]() tolerates the error because
    throwIfNoEntry is false and returns the JavaScript FSWatcher.

  4. The watch-mode FilesWatcher stores that watcher. On SIGINT or SIGTERM,
    watch_mode.js calls watcher.clear(), and #unwatch() calls
    FSWatcher.close().

  5. FSWatcher.close() reads this._handle.initialized. By the time the
    delayed close performs that read, the native wrapper can already have been
    destroyed. In that case, FSEventWrap::GetInitialized() receives a null
    native wrap and hits CHECK_NOT_NULL(wrap).

The immediate-close and delayed-close behaviors above are consistent with this lifecycle explanation.

Affected releases and regression relationship

Version matrix
Version Startup with missing optional env file Signal shutdown
v22.22.0 ENOENT throw n/a
v24.12.0 ENOENT throw n/a
v24.16.0 starts assertion
v24.17.0 starts assertion
v24.18.0 starts assertion
v25.6.1 (EOL; historical control) ENOENT throw n/a
v26.5.0 starts assertion

The version matrix and source history are consistent with the shutdown assertion becoming reachable after the change from #61870. A commit-level bisect has not been performed.

The relevant commits are:

That PR fixed #57040, the LTS recurrence of the startup problem previously reported in #56887. It makes the missing-file path non-throwing at startup, but the now-reachable delayed-close path aborts during signal shutdown.

This also resembles the lifecycle failure discussed in #20297 and fixed by c09bfd81b7, which made operations on closed or destroyed file watchers no-ops instead of allowing a native assertion.

I could not find an existing issue or pull request for this exact
--env-file-if-exists signal-shutdown crash as of 2026-07-29.

Possible fix directions, deferring to maintainers on the preferred layer:

  • clear or invalidate the JavaScript _handle when UV_ENOENT is tolerated;
  • make FSWatcher.close() / the native initialized-state lookup safe after the
    native wrap has already been destroyed; or
  • avoid retaining an unusable watcher in the watch-mode watcher map.

I have a candidate fix and will open a pull request linked to this issue.

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