nodejs / nodejs/node

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

未关闭
#64,819 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

主要语言
JavaScript
星标
122k
派生
37.4k
平均合并
4 天 3 小时
30 天内合并 PR
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-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.

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 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

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。