watch: missing --env-file-if-exists path aborts during signal shutdown on Linux
Chưa có ai nhận issue này.
- Ngôn ngữ chính
- JavaScript
- Star
- 122k
- Fork
- 37.3k
- Merge trung bình
- 4 ngày 2 giờ
- Pull request đã merge (30 ngày)
- 283
Mô tả
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.
Hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Hướng nghiên cứu
Bắt đầu bằng cách chạy bản tái hiện delayed-close của fs.watch(), sau đó đọc lib/internal/main/watch_mode.js, lib/internal/watch_mode/files_watcher.js và fs_event_wrap.cc quanh FSEventWrap::GetInitialized(). So sánh vòng đời với test-fs-watch-enoent.js và xác minh rằng các watcher của những đường dẫn không tồn tại được tắt một cách sạch sẽ mà không gây ra assertion.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- javascript, linux, node.js
- Lĩnh vực
- operating-systems
- Loại issue
- Lỗi
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức độ hoạt động
- Ít trao đổi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức phù hợp với người mới
- 35/100