watch: missing --env-file-if-exists path aborts during signal shutdown on Linux
まだ誰も着手していません。
- 主要言語
- JavaScript
- スター
- 122k
- フォーク
- 37.4k
- 平均マージ
- 4日 3時間
- マージ済み PR(30日)
- 272
説明
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.
コントリビューションガイド
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
調査の方向性
まず fs.watch() の delayed-close の再現を実行し、次に lib/internal/main/watch_mode.js、lib/internal/watch_mode/files_watcher.js、および FSEventWrap::GetInitialized() 周辺の fs_event_wrap.cc を読んでください。ライフサイクルを test-fs-watch-enoent.js と比較し、存在しないパスの watcher が assertion を発生させずに正常に終了することを確認してください。
索引モデルが issue の本文から書いたものです。
評価
- 技術スタック
- javascript, linux, node.js
- 領域
- operating-systems
- issue の種類
- バグ
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 活発さ
- 静か
- 明瞭さ
- 明確に書かれている
- 初心者へのやさしさ
- 35/100