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:
-
lib/internal/main/watch_mode.js registers
--env-file-if-exists paths with { allowMissing: true }.
-
lib/internal/watch_mode/files_watcher.js calls:
fs.watch(path, {
recursive,
signal,
throwIfNoEntry: !allowMissing,
});
-
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.
-
The watch-mode FilesWatcher stores that watcher. On SIGINT or SIGTERM,
watch_mode.js calls watcher.clear(), and #unwatch() calls
FSWatcher.close().
-
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.
Version
v26.5.0 Also reproduced on v24.16.0, v24.17.0, and v24.18.0.
Platform
Subsystem
watch, fs
What steps will reproduce the bug?
On Linux:
Wait until this line is printed:
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:
The watch-mode reproduction requires all of the following:
--watch;--env-file-if-existspointing to a path that does not exist; andControl cases on the same v26.5.0 binary:
--watch --env-file-if-exists=<missing>--watch --env-file-if-exists=<existing>--watchwithout an env-file flag--env-file-if-exists=<missing>without--watchWhat 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-existssays 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.Representative native and JavaScript stack from v26.5.0
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()reproductionThe underlying delayed-close problem can be reproduced without watch mode or an env-file flag:
This reaches the same
FSEventWrap::GetInitialized()assertion on v26.5.0.Closing the returned watcher synchronously does not crash:
This timing difference may explain why the regression test added by #61870 did not catch the shutdown
crash:
test-fs-watch-enoent.jscloses the returned watcher immediately.Source path
The current v26.5.0 source takes the following path:
lib/internal/main/watch_mode.jsregisters--env-file-if-existspaths with{ allowMissing: true }.lib/internal/watch_mode/files_watcher.jscalls:On Linux,
uv_fs_event_start()returnsUV_ENOENTfor the missing path.FSEventWrap::Start()closes the native handle on that error, whileFSWatcher.prototype[kFSWatchStart]()tolerates the error becausethrowIfNoEntryis false and returns the JavaScriptFSWatcher.The watch-mode
FilesWatcherstores that watcher. On SIGINT or SIGTERM,watch_mode.jscallswatcher.clear(), and#unwatch()callsFSWatcher.close().FSWatcher.close()readsthis._handle.initialized. By the time thedelayed close performs that read, the native wrapper can already have been
destroyed. In that case,
FSEventWrap::GetInitialized()receives a nullnative 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
ENOENTthrowENOENTthrowENOENTthrowThe 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:
c58fe38211
457fb55193
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-existssignal-shutdown crash as of 2026-07-29.Possible fix directions, deferring to maintainers on the preferred layer:
_handlewhenUV_ENOENTis tolerated;FSWatcher.close()/ the native initialized-state lookup safe after thenative wrap has already been destroyed; or
I have a candidate fix and will open a pull request linked to this issue.