Commit e21f81228d9 for nodejs
commit e21f81228d9597d3592c34d3b21a5bd456a3e940
Author: Trivikram Kamat <trivikr.dev@gmail.com>
Date: Sat Oct 3 02:51:01 2026 -0700
src: fix --process-timeout message when exit waits
process.exit(), uncaught exceptions and unhandled rejections exit
through Environment::Exit(), which never calls OnEnvironmentStopping().
If a Worker blocked in a synchronous native call held up that exit past
the deadline, the watchdog blamed the main thread.
Check Environment::is_stopping() and report that the process did not
finish exiting instead.
Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com>
Assisted-by: claude:opus-5.5
PR-URL: https://github.com/nodejs/node/pull/66361
Fixes: https://github.com/nodejs/node/issues/66360
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
diff --git a/src/node_watchdog.cc b/src/node_watchdog.cc
index cb6d79b71f9..c403f8cb071 100644
--- a/src/node_watchdog.cc
+++ b/src/node_watchdog.cc
@@ -113,6 +113,10 @@ namespace {
constexpr uint64_t kNanosecondsPerMillisecond = 1000 * 1000;
// How long printing the diagnostics, writing the report and exiting may take.
constexpr uint64_t kProcessTimeoutExitGraceMs = 5000;
+// Printed when the process is still exiting, e.g. joining Worker threads, once
+// --process-timeout has expired.
+constexpr char kProcessNotFinishedExitingMessage[] =
+ "The process did not finish exiting after the event loop had stopped.\n";
std::string FormatProcessTimeoutHeader(const std::string& duration) {
return SPrintF("(node:%d) Process timed out after %s (--process-timeout). "
@@ -460,6 +464,15 @@ void ProcessTimeoutWatchdog::Run(void* arg) {
uv_mutex_lock(&state->mutex);
state->WaitWhile(Phase::kArmed, state->deadline);
+ // Environment::Exit(), e.g. from process.exit() or for an uncaught
+ // exception, exits without returning to NodeMainInstance::Run(), so
+ // OnEnvironmentStopping() is not called while it joins Worker threads. The
+ // Environment is only freed after OnEnvironmentStopping(), so it is safe to
+ // read while the phase is still kArmed.
+ if (state->phase == Phase::kArmed && self->env_->is_stopping()) {
+ state->phase = Phase::kStopping;
+ }
+
if (state->phase == Phase::kArmed) {
// Barring the race described below, the process exits once the timeout
// has fired, either through ForceProcessTimeoutExit() on this thread or
@@ -479,13 +492,18 @@ void ProcessTimeoutWatchdog::Run(void* arg) {
uv_hrtime() + kProcessTimeoutResponseGraceMs *
kNanosecondsPerMillisecond);
if (state->phase == Phase::kFired) {
+ // If Environment::Exit() started before the interrupt could run, the
+ // main thread is not blocked by the application but by exiting.
ForceProcessTimeoutExit(
FormatProcessTimeoutHeader(state->duration) +
- SPrintF("The main thread did not respond within %dms. It is likely "
- "blocked in a synchronous native operation, e.g. "
- "child_process.execSync() or a native addon, so no "
- "JavaScript stack or resource information is available.\n",
- kProcessTimeoutResponseGraceMs));
+ (self->env_->is_stopping()
+ ? std::string(kProcessNotFinishedExitingMessage)
+ : SPrintF("The main thread did not respond within %dms. It is "
+ "likely blocked in a synchronous native operation, "
+ "e.g. child_process.execSync() or a native addon, so "
+ "no JavaScript stack or resource information is "
+ "available.\n",
+ kProcessTimeoutResponseGraceMs)));
}
if (state->phase == Phase::kHandling) {
@@ -507,8 +525,8 @@ void ProcessTimeoutWatchdog::Run(void* arg) {
// The event loop has stopped, but tearing down the Environment, e.g.
// joining Worker threads, can still take arbitrarily long. If the deadline
// has already passed, give the process a moment to finish exiting. That
- // only happens if the event loop stopped right as the deadline was reached,
- // a race that tests cannot reproduce reliably.
+ // happens if Environment::Exit() was still in progress at the deadline, or
+ // if the event loop stopped right as the deadline was reached.
const uint64_t now = uv_hrtime();
// LCOV_EXCL_START
const uint64_t until =
@@ -520,8 +538,7 @@ void ProcessTimeoutWatchdog::Run(void* arg) {
if (state->phase == Phase::kStopping) {
// LCOV_EXCL_START
ForceProcessTimeoutExit(FormatProcessTimeoutHeader(state->duration) +
- "The process did not finish exiting after the "
- "event loop had stopped.\n");
+ kProcessNotFinishedExitingMessage);
// LCOV_EXCL_STOP
}
}
diff --git a/test/fixtures/process-timeout/blocked-worker-at-process-exit.js b/test/fixtures/process-timeout/blocked-worker-at-process-exit.js
new file mode 100644
index 00000000000..b7e853e62f6
--- /dev/null
+++ b/test/fixtures/process-timeout/blocked-worker-at-process-exit.js
@@ -0,0 +1,31 @@
+'use strict';
+// Exits the process while a Worker is blocked in a synchronous native call, so
+// that exiting the process waits for the Worker. The first argument is a path
+// at which a file is created once the process is exiting. The second argument
+// is how the process exits: 'exit' calls process.exit(), 'throw' throws an
+// uncaught exception, and 'reject' leaves a promise rejection unhandled.
+const fs = require('fs');
+const path = require('path');
+const { Worker } = require('worker_threads');
+
+const [, , marker, mode] = process.argv;
+const childMarker = `${marker}.child`;
+const script = path.join(__dirname, 'wait-for-parent.js');
+const args = [script, String(process.pid), childMarker];
+new Worker(`
+ require('child_process').execFileSync(process.execPath, ${JSON.stringify(args)}, {
+ stdio: 'ignore',
+ });
+`, { eval: true });
+
+// Once the child process has created its file, the Worker is blocked in
+// execFileSync() and can no longer be terminated.
+const interval = setInterval(() => {
+ if (!fs.existsSync(childMarker)) return;
+ clearInterval(interval);
+ if (mode === 'throw') throw new Error('boom');
+ if (mode === 'reject') return Promise.reject(new Error('boom'));
+ process.exit(0);
+}, 10);
+
+process.on('exit', () => fs.writeFileSync(marker, ''));
diff --git a/test/parallel/test-process-timeout-blocked.js b/test/parallel/test-process-timeout-blocked.js
index 3e360b0ab1a..db3553e11d5 100644
--- a/test/parallel/test-process-timeout-blocked.js
+++ b/test/parallel/test-process-timeout-blocked.js
@@ -16,16 +16,17 @@ tmpdir.refresh();
// it can expire before the fixture has reached the state under test, which the
// fixture signals by creating a file. If it did not get that far and did not
// print the expected message, run it again with a longer timeout.
-function runUntilReady(fixture, expected) {
+function runUntilReady(fixture, expected, args = []) {
for (let timeout = common.platformTimeout(1000); ; timeout *= 2) {
// Child processes of a previous attempt may still be running, so use a
// different file for each attempt.
- const marker = tmpdir.resolve(`${fixture}.${timeout}.ready`);
+ const marker = tmpdir.resolve([fixture, ...args, timeout, 'ready'].join('.'));
const start = process.hrtime.bigint();
const child = spawnSync(process.execPath, [
`--process-timeout=${timeout}ms`,
fixtures.path('process-timeout', fixture),
marker,
+ ...args,
], { encoding: 'utf8' });
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
@@ -69,3 +70,14 @@ function runUntilReady(fixture, expected) {
'blocked-worker-at-exit.js',
/^The process did not finish exiting after the event loop had stopped\.$/m);
}
+
+for (const mode of ['exit', 'throw', 'reject']) {
+ // process.exit(), an uncaught exception, or an unhandled rejection waits for
+ // a Worker thread that is blocked in a synchronous native call. The main thread is exiting, so it
+ // must not be reported as blocked by the application.
+ const stderr = runUntilReady(
+ 'blocked-worker-at-process-exit.js',
+ /^The process did not finish exiting after the event loop had stopped\.$/m,
+ [mode]);
+ assert.doesNotMatch(stderr, /The main thread did not respond/);
+}