watch: missing --env-file-if-exists path aborts during signal shutdown on Linux
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 122k
- Forks
- 37.3k
- Avg merge
- 4d 2h
- Merged PRs (30d)
- 283
Description
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-existspointing 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.jsregisters
--env-file-if-existspaths with{ allowMissing: true }. -
lib/internal/watch_mode/files_watcher.jscalls:fs.watch(path, { recursive, signal, throwIfNoEntry: !allowMissing, }); -
On Linux,
uv_fs_event_start()returnsUV_ENOENTfor the missing path.
FSEventWrap::Start()closes the native handle on that error, while
FSWatcher.prototype[kFSWatchStart]()tolerates the error because
throwIfNoEntryis false and returns the JavaScriptFSWatcher. -
The watch-mode
FilesWatcherstores that watcher. On SIGINT or SIGTERM,
watch_mode.jscallswatcher.clear(), and#unwatch()calls
FSWatcher.close(). -
FSWatcher.close()readsthis._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 hitsCHECK_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:
- main/v24 commit:
c58fe38211 - v26.x backport:
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-exists signal-shutdown crash as of 2026-07-29.
Possible fix directions, deferring to maintainers on the preferred layer:
- clear or invalidate the JavaScript
_handlewhenUV_ENOENTis 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.
Contributor guide
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.
Research direction
Start by running the delayed-close fs.watch() reproduction, then read lib/internal/main/watch_mode.js, lib/internal/watch_mode/files_watcher.js, and fs_event_wrap.cc around FSEventWrap::GetInitialized(). Compare the lifecycle with test-fs-watch-enoent.js and verify that missing-path watchers shut down cleanly without the assertion.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, linux, node.js
- Domain
- operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100