nodejs/node · #66138

src: add --process-timeout=N

jasnell · merged Sep 25, 202627 files · 1509 + / 2 −
doc/api/cli.md76 + / 0 −
@@ -2647,6 +2647,59 @@ changes:  Identical to `-e` but prints the result. +### `--process-timeout=duration`++<!-- YAML+added: REPLACEME+-->++> Stability: 1.1 - Active development++Exits the process with code `124` if it is still running after `duration`,+measured from the start of the process. `duration` is a positive integer+followed by a unit: `ms`, `s`, `m`, or `h`, for example `500ms`, `30s`, `5m`, or+`1h`. The exit code matches the one used by the `timeout(1)` command.++Before exiting, Node.js prints what the main thread was doing and which+resources were keeping the event loop alive to stderr:++```console+$ node --process-timeout=5s server.js+(node:25418) Process timed out after 5s (--process-timeout). Exiting with code 124.+Main thread was not executing JavaScript.+Resources keeping the event loop alive:+    TCPServerWrap (listening on [::]:3000, fd 20)+    Timeout x2 (next due in 2931ms)+```++If the main thread was executing JavaScript, its stack trace is printed instead.+Use [`--report-on-process-timeout`][] to also generate a [diagnostic report][].++The process exits without emitting the `'beforeExit'` and `'exit'` events, as+the JavaScript code may be what keeps the process running. Code coverage and+profiles, such as those enabled with [`NODE_V8_COVERAGE=dir`][] or+[`--cpu-prof`][], are still written.++If the main thread does not respond within two seconds, for example because it+is blocked in a synchronous operation such as [`child_process.execSync()`][],+Node.js exits immediately without printing the stack trace and resources.++This option is not allowed in [`NODE_OPTIONS`][], and it cannot be combined+with the options that enable the inspector, such as `--inspect`,+`--inspect-brk`, `--inspect-wait`, `--inspect-port`, and+`--inspect-publish-uid`, nor with `node inspect`, `--run`, or+`--build-snapshot`. While it is in effect, [`inspector.open()`][] and+[`session.connectToMainThread()`][] throw, and requests to activate the+inspector with `SIGUSR1` are ignored.++Child processes that inherit `process.execArgv`, such as those created with+[`child_process.fork()`][], apply the timeout from their own start. With+[`--watch`][], the timeout applies to each run of the application rather than+to the process that watches for changes. When+[running tests from the command line][], the test runner process applies the+timeout to the whole run, and each test file that runs in its own process+applies it as well.+ ### `--prof`  <!-- YAML@@ -2774,6 +2827,19 @@ application. Useful to inspect various diagnostic data elements such as heap, stack, event loop state, resource consumption etc. to reason about the fatal error. +### `--report-on-process-timeout`++<!-- YAML+added: REPLACEME+-->++> Stability: 1.1 - Active development++Enables the report to be generated when [`--process-timeout`][] expires, in+addition to the summary printed to stderr. Useful to inspect the JavaScript+and native stacks, the event loop state, and resource consumption to reason+about why the process did not exit. Requires [`--process-timeout`][].+ ### `--report-on-signal`  <!-- YAML@@ -4791,6 +4857,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [`--allow-worker`]: #--allow-worker [`--build-snapshot`]: #--build-snapshot [`--cpu-prof-dir`]: #--cpu-prof-dir+[`--cpu-prof`]: #--cpu-prof [`--diagnostic-dir`]: #--diagnostic-dirdirectory [`--disable-sigusr1`]: #--disable-sigusr1 [`--enable-fips`]: #--enable-fips@@ -4805,35 +4872,43 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [`--openssl-config`]: #--openssl-configfile [`--preserve-symlinks`]: #--preserve-symlinks [`--print`]: #-p---print-script+[`--process-timeout`]: #--process-timeoutduration [`--redirect-warnings`]: #--redirect-warningsfile+[`--report-on-process-timeout`]: #--report-on-process-timeout [`--require`]: #-r---require-module [`--use-env-proxy`]: #--use-env-proxy [`--use-system-ca`]: #--use-system-ca+[`--watch`]: #--watch [`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage [`Buffer`]: buffer.md#class-buffer [`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html [`ERR_INVALID_TYPESCRIPT_SYNTAX`]: errors.md#err_invalid_typescript_syntax [`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax [`NODE_OPTIONS`]: #node_optionsoptions [`NODE_USE_ENV_PROXY=1`]: #node_use_env_proxy1+[`NODE_V8_COVERAGE=dir`]: #node_v8_coveragedir [`NO_COLOR`]: https://no-color.org [`RealFSProvider`]: vfs.md#class-realfsprovider [`Web Storage`]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API [`YoungGenerationSizeFromSemiSpaceSize`]: https://chromium.googlesource.com/v8/v8.git/+/refs/tags/10.3.129/src/heap/heap.cc#328 [`ZipProvider`]: vfs.md#class-zipprovider+[`child_process.execSync()`]: child_process.md#child_processexecsynccommand-options+[`child_process.fork()`]: child_process.md#child_processforkmodulepath-args-options [`crypto.createPrivateKey()`]: crypto.md#cryptocreateprivatekeykey [`dns.lookup()`]: dns.md#dnslookuphostname-options-callback [`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorder [`dnsPromises.lookup()`]: dns.md#dnspromiseslookuphostname-options [`import.meta.url`]: esm.md#importmetaurl [`import` specifier]: esm.md#import-specifiers+[`inspector.open()`]: inspector.md#inspectoropenport-host-wait [`net.getDefaultAutoSelectFamilyAttemptTimeout()`]: net.md#netgetdefaultautoselectfamilyattempttimeout [`node:ffi`]: ffi.md [`node:sqlite`]: sqlite.md [`node:stream/iter`]: stream_iter.md [`node:vfs`]: vfs.md [`permission.drop()`]: permissions.md#permissiondropscope-reference [`process.setUncaughtExceptionCaptureCallback()`]: process.md#processsetuncaughtexceptioncapturecallbackfn+[`session.connectToMainThread()`]: inspector.md#sessionconnecttomainthread [`tls.DEFAULT_MAX_VERSION`]: tls.md#tlsdefault_max_version [`tls.DEFAULT_MIN_VERSION`]: tls.md#tlsdefault_min_version [`unhandledRejection`]: process.md#event-unhandledrejection@@ -4850,6 +4925,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [debugger]: debugger.md [debugging security implications]: https://nodejs.org/learn/getting-started/debugging#security-implications [deprecation warnings]: deprecations.md#list-of-deprecated-apis+[diagnostic report]: report.md [dtls documentation]: dtls.md [emit_warning]: process.md#processemitwarningwarning-options [environment_variables]: #environment-variables-1
doc/api/inspector.md9 + / 2 −
@@ -119,7 +119,8 @@ added: v12.11.0 -->  Connects a session to the main thread inspector back-end. An exception will-be thrown if this API was not called on a Worker thread.+be thrown if this API was not called on a Worker thread, or if+[`--process-timeout`][] is used, as the session could pause the main thread.  #### `session.disconnect()` @@ -298,7 +299,8 @@ added: v12.11.0 -->  Connects a session to the main thread inspector back-end. An exception will-be thrown if this API was not called on a Worker thread.+be thrown if this API was not called on a Worker thread, or if+[`--process-timeout`][] is used, as the session could pause the main thread.  #### `session.disconnect()` @@ -456,6 +458,9 @@ and flow control has been passed to the debugger client. See the [security warning][] regarding the `host` parameter usage. +Throws an [`ERR_INSPECTOR_NOT_AVAILABLE`][] error if [`--process-timeout`][] is+used.+ ### `inspector.url()`  * Returns: {string|undefined}@@ -802,7 +807,9 @@ connection. [Debugger]: debugger.md [Heap Profiler]: https://chromedevtools.github.io/devtools-protocol/v8/HeapProfiler [`'Debugger.paused'`]: https://chromedevtools.github.io/devtools-protocol/v8/Debugger#event-paused+[`--process-timeout`]: cli.md#--process-timeoutduration [`Debugger` domain]: https://chromedevtools.github.io/devtools-protocol/v8/Debugger+[`ERR_INSPECTOR_NOT_AVAILABLE`]: errors.md#err_inspector_not_available [`inspector.close()`]: #inspectorclose [`session.connect()`]: #sessionconnect [`session.connectToMainThread()`]: #sessionconnecttomainthread
doc/api/process.md3 + / 0 −
@@ -4601,6 +4601,8 @@ cases: * `14` **Snapshot Failure**: Node.js was started to build a V8 startup   snapshot and it failed because certain requirements of the state of   the application were not met.+* `124` **Process Timeout**: The process was still running when the duration+  set with [`--process-timeout`][] elapsed. * `>128` **Signal Exits**: If Node.js receives a fatal signal such as   `SIGKILL` or `SIGHUP`, then its exit code will be `128` plus the   value of the signal code. This is a standard POSIX practice, since@@ -4629,6 +4631,7 @@ cases: [`--no-deprecation`]: cli.md#--no-deprecation [`--permission-audit`]: cli.md#--permission-audit [`--permission`]: cli.md#--permission+[`--process-timeout`]: cli.md#--process-timeoutduration [`--unhandled-rejections`]: cli.md#--unhandled-rejectionsmode [`Buffer`]: buffer.md [`ChildProcess.disconnect()`]: child_process.md#subprocessdisconnect
doc/api/report.md4 + / 0 −
@@ -475,6 +475,10 @@ meaning of `SIGUSR2` for the said purposes.   diagnostic data elements such as heap, stack, event loop state, resource   consumption etc. to reason about the fatal error. +* `--report-on-process-timeout` Enables the report to be triggered when the+  duration set with `--process-timeout` elapses before the process exits.+  Useful to reason about why the process did not exit.+ * `--report-compact` Write reports in a compact format, single-line JSON, more   easily consumable by log processing systems than the default multi-line format   designed for human consumption.
doc/node.145 + / 0 −
@@ -1352,6 +1352,45 @@ See \fB--preserve-symlinks\fR for more information. .It Fl p , Fl -print Ar "script" Identical to \fB-e\fR but prints the result. .+.It Fl -process-timeout Ns = Ns Ar duration+Exits the process with code \fB124\fR if it is still running after \fBduration\fR,+measured from the start of the process. \fBduration\fR is a positive integer+followed by a unit: \fBms\fR, \fBs\fR, \fBm\fR, or \fBh\fR, for example \fB500ms\fR, \fB30s\fR, \fB5m\fR, or+\fB1h\fR. The exit code matches the one used by the \fBtimeout(1)\fR command.+Before exiting, Node.js prints what the main thread was doing and which+resources were keeping the event loop alive to stderr:+.Bd -literal+$ node --process-timeout=5s server.js+(node:25418) Process timed out after 5s (--process-timeout). Exiting with code 124.+Main thread was not executing JavaScript.+Resources keeping the event loop alive:+    TCPServerWrap (listening on [::]:3000, fd 20)+    Timeout x2 (next due in 2931ms)+.Ed+If the main thread was executing JavaScript, its stack trace is printed instead.+Use \fB--report-on-process-timeout\fR to also generate a diagnostic report.+The process exits without emitting the \fB'beforeExit'\fR and \fB'exit'\fR events, as+the JavaScript code may be what keeps the process running. Code coverage and+profiles, such as those enabled with \fBNODE_V8_COVERAGE=dir\fR or+\fB--cpu-prof\fR, are still written.+If the main thread does not respond within two seconds, for example because it+is blocked in a synchronous operation such as \fBchild_process.execSync()\fR,+Node.js exits immediately without printing the stack trace and resources.+This option is not allowed in \fBNODE_OPTIONS\fR, and it cannot be combined+with the options that enable the inspector, such as \fB--inspect\fR,+\fB--inspect-brk\fR, \fB--inspect-wait\fR, \fB--inspect-port\fR, and+\fB--inspect-publish-uid\fR, nor with \fBnode inspect\fR, \fB--run\fR, or+\fB--build-snapshot\fR. While it is in effect, \fBinspector.open()\fR and+\fBsession.connectToMainThread()\fR throw, and requests to activate the+inspector with \fBSIGUSR1\fR are ignored.+Child processes that inherit \fBprocess.execArgv\fR, such as those created with+\fBchild_process.fork()\fR, apply the timeout from their own start. With+\fB--watch\fR, the timeout applies to each run of the application rather than+to the process that watches for changes. When+running tests from the command line, the test runner process applies the+timeout to the whole run, and each test file that runs in its own process+applies it as well.+. .It Fl -prof Generate V8 profiler output. .@@ -1395,6 +1434,12 @@ application. Useful to inspect various diagnostic data elements such as heap, stack, event loop state, resource consumption etc. to reason about the fatal error. .+.It Fl -report-on-process-timeout+Enables the report to be generated when \fB--process-timeout\fR expires, in+addition to the summary printed to stderr. Useful to inspect the JavaScript+and native stacks, the event loop state, and resource consumption to reason+about why the process did not exit. Requires \fB--process-timeout\fR.+. .It Fl -report-on-signal Enables report to be generated upon receiving the specified (or predefined) signal to the running Node.js process. The signal to trigger the report is
src/env.h4 + / 0 −
@@ -814,6 +814,10 @@ class Environment final : public MemoryRetainer {   static inline Environment* from_immediate_check_handle(uv_check_t* handle);   inline uv_check_t* immediate_check_handle();   inline uv_idle_t* immediate_idle_handle();+  // Referenced while add_refs() holds references, e.g. for running Workers.+  uv_async_t* task_queues_async() {+    return &task_queues_async_;+  }    inline void IncreaseWaitingRequestCounter();   inline void DecreaseWaitingRequestCounter();
src/inspector_agent.cc18 + / 0 −
@@ -27,6 +27,7 @@ #include "node_options-inl.h" #include "node_process-inl.h" #include "node_url.h"+#include "node_watchdog.h" #include "permission/permission.h" #include "timer_wrap-inl.h" #include "util-inl.h"@@ -980,6 +981,14 @@ std::unique_ptr<InspectorSession> Agent::ConnectToMainThread(         parent_env_, "The parent thread's inspector is not available");     return std::unique_ptr<InspectorSession>{};   }+  // A session connected to the main thread can pause it indefinitely.+  if (ProcessTimeoutWatchdog::IsEnabled()) {+    THROW_ERR_INSPECTOR_NOT_AVAILABLE(+        parent_env_,+        "Sessions cannot be connected to the main thread when "+        "--process-timeout is used");+    return std::unique_ptr<InspectorSession>{};+  }    CHECK_NOT_NULL(client_);   auto thread_safe_delegate =@@ -1190,6 +1199,15 @@ void Agent::RequestIoThreadStart() {   if (!options().allow_attaching_debugger) {     return;   }+  // Activation via SIGUSR1 or process._debugProcess() has no caller to throw+  // to, so print a warning instead.+  if (ProcessTimeoutWatchdog::IsEnabled()) {+    FPrintF(stderr,+            "(node:%d) Warning: Ignoring the request to activate the "+            "inspector because --process-timeout is used.\n",+            uv_os_getpid());+    return;+  }   parent_env_->RequestInterrupt([this](Environment*) {     StartIoThread();   });
src/inspector_js_api.cc8 + / 0 −
@@ -4,7 +4,9 @@ #include "inspector_agent.h" #include "inspector_io.h" #include "memory_tracker-inl.h"+#include "node_errors.h" #include "node_external_reference.h"+#include "node_watchdog.h" #include "util-inl.h" #include "v8-inspector.h" #include "v8.h"@@ -378,6 +380,12 @@ void Open(const FunctionCallbackInfo<Value>& args) {   Environment* env = Environment::GetCurrent(args);   Agent* agent = env->inspector_agent(); +  if (ProcessTimeoutWatchdog::IsEnabled()) {+    return THROW_ERR_INSPECTOR_NOT_AVAILABLE(+        env,+        "The inspector cannot be activated when --process-timeout is used");+  }+   if (args.Length() > 0 && args[0]->IsUint32()) {     uint32_t port = args[0].As<Uint32>()->Value();     CHECK_LE(port, std::numeric_limits<uint16_t>::max());
src/node.cc9 + / 0 −
@@ -739,6 +739,14 @@ static void CheckGlobalBenchOptions(std::vector<std::string>* errors) {   per_process::cli_options->per_isolate->per_env->CheckBenchOptions(errors); } +// Validates the options that conflict with --process-timeout once every option+// source has been parsed. See PerProcessOptions::CheckProcessTimeoutOptions().+static void CheckGlobalProcessTimeoutOptions(+    const std::vector<std::string>& argv, std::vector<std::string>* errors) {+  Mutex::ScopedLock lock(per_process::cli_options_mutex);+  per_process::cli_options->CheckProcessTimeoutOptions(errors, argv);+}+ static ExitCode ProcessGlobalArgsInternal(std::vector<std::string>* args,                                           std::vector<std::string>* exec_args,                                           std::vector<std::string>* errors,@@ -1034,6 +1042,7 @@ static ExitCode InitializeNodeWithArgsInternal(   // Every option source has now been parsed, so cross-source option   // constraints can finally be validated.   CheckGlobalBenchOptions(errors);+  CheckGlobalProcessTimeoutOptions(*argv, errors);   if (!errors->empty()) return ExitCode::kInvalidCommandLineArgument;    // Checked here, once every source of options has been parsed, because the
src/node_exit_code.h3 + / 0 −
@@ -29,6 +29,9 @@ namespace node {   V(InvalidCommandLineArgument2, 12)                                           \   V(UnsettledTopLevelAwait, 13)                                                \   V(StartupSnapshotFailure, 14)                                                \+  /* The process was still running when --process-timeout expired. This */     \+  /* matches the exit status of timeout(1) from GNU coreutils. */              \+  V(ProcessTimeout, 124)                                                       \   /* If the process exits from unhandled signals e.g. SIGABRT, SIGTRAP, */     \   /* typically the exit codes are 128 + signal number. We also exit with */    \   /* certain error codes directly for legacy reasons. Here we define those */  \
src/node_main_instance.cc8 + / 0 −
@@ -13,6 +13,7 @@ #include "node_snapshot_builder.h" #include "node_snapshotable.h" #include "node_v8_platform-inl.h"+#include "node_watchdog.h" #include "util-inl.h" #if defined(LEAK_SANITIZER) #include <sanitizer/lsan_interface.h>@@ -91,12 +92,19 @@ ExitCode NodeMainInstance::Run() {   HandleScope handle_scope(isolate_);    ExitCode exit_code = ExitCode::kNoFailure;+  // Declared before the Environment so that --process-timeout still applies+  // while the Environment is being freed.+  std::unique_ptr<ProcessTimeoutWatchdog> process_timeout;   DeleteFnPtr<Environment, FreeEnvironment> env =       CreateMainEnvironment(&exit_code);   CHECK_NOT_NULL(env);    Context::Scope context_scope(env->context());+  if (exit_code == ExitCode::kNoFailure) {+    process_timeout = ProcessTimeoutWatchdog::MaybeStart(env.get());+  }   Run(&exit_code, env.get());+  if (process_timeout) process_timeout->OnEnvironmentStopping();   return exit_code; } 
src/node_options.cc110 + / 0 −
@@ -78,8 +78,57 @@ void DebugOptions::CheckOptions(std::vector<std::string>* errors,   } } +namespace {+// Parses a duration made of a positive integer and a unit, such as "500ms",+// "30s", "5m" or "1h", into milliseconds.+bool ParseDurationMilliseconds(std::string_view value, uint64_t* result) {+  size_t digits = 0;+  while (digits < value.size() && value[digits] >= '0' && value[digits] <= '9')+    digits++;+  if (digits == 0) return false;++  const std::string_view unit = value.substr(digits);+  uint64_t multiplier;+  if (unit == "ms") {+    multiplier = 1;+  } else if (unit == "s") {+    multiplier = 1000;+  } else if (unit == "m") {+    multiplier = 60 * 1000;+  } else if (unit == "h") {+    multiplier = 60 * 60 * 1000;+  } else {+    return false;+  }++  uint64_t amount;+  const char* end = value.data() + digits;+  const auto parsed = std::from_chars(value.data(), end, amount);+  if (parsed.ec != std::errc() || parsed.ptr != end) return false;++  // The deadline is computed in nanoseconds relative to the process start+  // time, so keep plenty of headroom in a uint64_t.+  static constexpr uint64_t kMaxMilliseconds =+      std::numeric_limits<uint64_t>::max() / 2 / 1000000;+  if (amount == 0 || amount > kMaxMilliseconds / multiplier) return false;++  *result = amount * multiplier;+  return true;+}+}  // namespace+ void PerProcessOptions::CheckOptions(std::vector<std::string>* errors,                                      std::vector<std::string>* argv) {+  if (process_timeout.empty()) {+    process_timeout_ms = 0;+  } else if (!ParseDurationMilliseconds(process_timeout, &process_timeout_ms)) {+    process_timeout_ms = 0;+    errors->push_back("invalid value for --process-timeout: '" ++                      process_timeout ++                      "'. Expected a positive integer followed by a unit "+                      "(ms, s, m or h), e.g. 30s");+  }+ #if HAVE_OPENSSL   if (use_openssl_ca && use_bundled_ca) {     errors->push_back("either --use-openssl-ca or --use-bundled-ca can be "@@ -138,6 +187,59 @@ void PerProcessOptions::CheckOptions(std::vector<std::string>* errors,   per_isolate->CheckOptions(errors, argv); } +void PerProcessOptions::CheckProcessTimeoutOptions(+    std::vector<std::string>* errors,+    const std::vector<std::string>& argv) const {+  if (process_timeout.empty()) {+    if (report_on_process_timeout) {+      errors->push_back(+          "--report-on-process-timeout must be used with --process-timeout");+    }+    return;+  }++  // A process that is being debugged can be paused indefinitely, which would+  // make the timeout fire while the debugger is in control, so the two cannot+  // be combined. Runtime activation of the inspector is rejected separately.+  const DebugOptions& debug_options = per_isolate->per_env->debug_options();+  const char* debug_option = nullptr;+  if (debug_options.break_node_first_line) {+    debug_option = "--inspect-brk-node";+  } else if (debug_options.break_first_line) {+    debug_option = "--inspect-brk";+  } else if (debug_options.inspect_wait) {+    debug_option = "--inspect-wait";+  } else if (debug_options.inspector_enabled) {+    debug_option = "--inspect";+  } else if (debug_options.host_port.host() != "127.0.0.1" ||+             debug_options.host_port.port() !=+                 DebugOptions::kDefaultInspectorPort) {+    // We can't catch the case where the value passed is the default value,+    // then the option just becomes a noop which is fine.+    debug_option = "--inspect-port";+  } else if (debug_options.inspect_publish_uid_string != "stderr,http") {+    debug_option = "--inspect-publish-uid";+  }+  if (debug_option != nullptr) {+    errors->push_back(std::string("either --process-timeout or ") ++                      debug_option + " can be used, not both");+  }++  if (argv.size() > 1 && argv[1] == "inspect") {+    errors->push_back("--process-timeout cannot be used with `node inspect`");+  }++  if (has_run) {+    errors->push_back(+        "either --process-timeout or --run can be used, not both");+  }++  if (per_isolate->build_snapshot) {+    errors->push_back(+        "either --process-timeout or --build-snapshot can be used, not both");+  }+}+ void PerIsolateOptions::HandleMaxOldSpaceSizePercentage(     std::vector<std::string>* errors,     std::string* max_old_space_size_percentage) {@@ -1577,6 +1679,9 @@ PerProcessOptionsParser::PerProcessOptionsParser(             "generate diagnostic report on fatal (internal) errors",             BOOL_FIELD(report_on_fatalerror),             kAllowedInEnvvar);+  AddOption("--report-on-process-timeout",+            "generate diagnostic report when --process-timeout expires",+            BOOL_FIELD(report_on_process_timeout));  #ifdef NODE_HAVE_I18N_SUPPORT   AddOption("--icu-data-dir",@@ -1673,6 +1778,11 @@ PerProcessOptionsParser::PerProcessOptionsParser(             BOOL_FIELD(trace_sigint),             kAllowedInEnvvar); +  AddOption("--process-timeout",+            "print why the process is still running and exit with code 124 "+            "if it has not exited after the given duration (e.g. 30s)",+            &PerProcessOptions::process_timeout);+   Insert(iop, &PerProcessOptions::get_per_isolate_options);    AddOption("--node-memory-debug",
src/node_options.h13 + / 0 −
@@ -401,6 +401,10 @@ class PerProcessOptions : public Options {   std::string report_filename;   // TODO(addaleax): Some of these could probably be per-Environment.   std::string use_largepages = "off";+  // --process-timeout, as passed on the command line (e.g. "30s").+  std::string process_timeout;+  // The parsed value of --process-timeout, or 0 if it was not passed.+  uint64_t process_timeout_ms = 0;    std::vector<std::string> security_reverts;   std::vector<std::string> cmdline;@@ -444,6 +448,7 @@ class PerProcessOptions : public Options {    DEFINE_BOOL_FIELD(disable_wasm_trap_handler) = false;   DEFINE_BOOL_FIELD(report_on_fatalerror) = false;+  DEFINE_BOOL_FIELD(report_on_process_timeout) = false;   DEFINE_BOOL_FIELD(report_compact) = false;   DEFINE_BOOL_FIELD(trace_sigint) = false;   // Tracks whether `--run` was passed, since an empty `run` is ambiguous@@ -453,6 +458,14 @@ class PerProcessOptions : public Options {   inline PerIsolateOptions* get_per_isolate_options();   void CheckOptions(std::vector<std::string>* errors,                     std::vector<std::string>* argv) override;++  // `--process-timeout` conflicts with options that can come from different+  // option sources (e.g. `--inspect` from NODE_OPTIONS), each of which is+  // parsed in its own options_parser::Parse() pass. Callers must invoke this+  // once all of their option sources have been parsed. `argv` holds the+  // remaining non-option arguments, starting with the program name.+  void CheckProcessTimeoutOptions(std::vector<std::string>* errors,+                                  const std::vector<std::string>& argv) const; };  // The actual options parser, as opposed to the structs containing them:
src/node_watchdog.cc469 + / 0 −
@@ -20,23 +20,32 @@ // USE OR OTHER DEALINGS IN THE SOFTWARE.  #include <algorithm>+#include <cstdlib>+#include <string>+#include <unordered_set>+#include <utility>  #include "async_wrap-inl.h" #include "debug_utils-inl.h" #include "env-inl.h" #include "node_errors.h" #include "node_internals.h"+#include "node_sockaddr-inl.h" #include "node_watchdog.h"+#include "node_worker.h" #include "util-inl.h"  namespace node {  using v8::Context; using v8::FunctionCallbackInfo; using v8::FunctionTemplate;+using v8::HandleScope; using v8::Isolate; using v8::Local; using v8::Object;+using v8::StackFrame;+using v8::StackTrace; using v8::Value;  Watchdog::Watchdog(v8::Isolate* isolate, uint64_t ms, bool* timed_out)@@ -99,6 +108,466 @@ void Watchdog::Timer(uv_timer_t* timer) {   uv_stop(&w->loop_); } +namespace {++constexpr uint64_t kNanosecondsPerMillisecond = 1000 * 1000;+// How long the main thread has to respond to the timeout. If it does not, it+// is most likely blocked in a synchronous native call.+constexpr uint64_t kProcessTimeoutResponseGraceMs = 2000;+// How long printing the diagnostics, writing the report and exiting may take.+constexpr uint64_t kProcessTimeoutExitGraceMs = 5000;++std::string FormatProcessTimeoutHeader(const std::string& duration) {+  return SPrintF("(node:%d) Process timed out after %s (--process-timeout). "+                 "Exiting with code %d.\n",+                 uv_os_getpid(),+                 duration,+                 static_cast<int>(ExitCode::kProcessTimeout));+}++// Called from the watchdog thread when the main thread cannot be trusted to+// exit on its own. It must not touch the Environment or V8.+//+// std::_Exit() does not run atexit handlers or static destructors, which is+// where code coverage data is written, so coverage is never recorded for a+// process that exits through here. The calls to this function are excluded+// from coverage reports for the same reason.+// LCOV_EXCL_START+[[noreturn]] void ForceProcessTimeoutExit(const std::string& message) {+  FPrintF(stderr, "%s", message);+  fflush(stderr);+  ResetStdio();+  std::_Exit(static_cast<int>(ExitCode::kProcessTimeout));+}+// LCOV_EXCL_STOP++std::string FormatJavaScriptStack(Isolate* isolate, Local<StackTrace> stack) {+  std::string result;+  for (int i = 0; i < stack->GetFrameCount(); i++) {+    Local<StackFrame> frame = stack->GetFrame(isolate, i);+    Utf8Value function_name(isolate, frame->GetFunctionName());+    Utf8Value script_name(isolate, frame->GetScriptName());+    const int line = frame->GetLineNumber();+    const int column = frame->GetColumn();+    if (function_name.length() == 0) {+      result += SPrintF("    at %s:%d:%d\n", script_name, line, column);+    } else {+      result += SPrintF(+          "    at %s (%s:%d:%d)\n", function_name, script_name, line, column);+    }+  }+  return result;+}++// Describes the details of a libuv handle that help identify where it comes+// from, e.g. the address a server listens on or the pid of a child process.+std::string DescribeHandle(uv_handle_t* handle, std::string_view name) {+  std::string description;+  switch (handle->type) {+    case UV_TCP: {+      const uv_tcp_t& tcp = *reinterpret_cast<uv_tcp_t*>(handle);+      const std::string local = SocketAddress::FromSockName(tcp).ToString();+      const std::string remote = SocketAddress::FromPeerName(tcp).ToString();+      if (!remote.empty()) {+        description = local + " -> " + remote;+      } else if (!local.empty()) {+        description =+            (name == "TCPServerWrap" ? "listening on " : "local ") + local;+      }+      break;+    }+    case UV_UDP: {+      const uv_udp_t& udp = *reinterpret_cast<uv_udp_t*>(handle);+      const std::string local = SocketAddress::FromSockName(udp).ToString();+      if (!local.empty()) description = "bound to " + local;+      break;+    }+    case UV_PROCESS:+      description =+          SPrintF("pid %d",+                  uv_process_get_pid(reinterpret_cast<uv_process_t*>(handle)));+      break;+    case UV_FS_EVENT:+    case UV_FS_POLL: {+      char path[PATH_MAX_BYTES];+      size_t size = sizeof(path);+      const int rc =+          handle->type == UV_FS_EVENT+              ? uv_fs_event_getpath(+                    reinterpret_cast<uv_fs_event_t*>(handle), path, &size)+              : uv_fs_poll_getpath(+                    reinterpret_cast<uv_fs_poll_t*>(handle), path, &size);+      if (rc == 0) description = "watching " + std::string(path, size);+      break;+    }+    default:+      break;+  }++#ifndef _WIN32+  uv_os_fd_t fd;+  if (handle->type != UV_PROCESS && uv_fileno(handle, &fd) == 0) {+    description += (description.empty() ? "" : ", ") + SPrintF("fd %d", fd);+  }+#endif++  return description;+}++// Collects resources in the order in which they are found, merging duplicates.+class ResourceList {+ public:+  void Add(std::string name, std::string details = "", size_t count = 1) {+    for (Entry& entry : entries_) {+      if (entry.name == name && entry.details == details) {+        entry.count += count;+        return;+      }+    }+    entries_.push_back({std::move(name), std::move(details), count});+  }++  std::string ToString() const {+    std::string result;+    for (const Entry& entry : entries_) {+      result += "    " + entry.name;+      if (entry.count > 1) result += SPrintF(" x%d", entry.count);+      if (!entry.details.empty()) result += " (" + entry.details + ")";+      result += "\n";+    }+    return result;+  }++  bool empty() const { return entries_.empty(); }++ private:+  struct Entry {+    std::string name;+    std::string details;+    size_t count;+  };+  std::vector<Entry> entries_;+};++// Describes what the main thread was doing and what keeps the event loop+// alive. This must not call into JavaScript, as it may run from a V8 interrupt.+std::string FormatProcessTimeoutDiagnostics(Environment* env) {+  Isolate* isolate = env->isolate();+  std::string result;++  Local<StackTrace> stack;+  if (GetCurrentStackTrace(isolate, static_cast<int>(env->stack_trace_limit()))+          .ToLocal(&stack) &&+      stack->GetFrameCount() > 0) {+    result += "Main thread was executing JavaScript:\n";+    result += FormatJavaScriptStack(isolate, stack);+  } else {+    result += "Main thread was not executing JavaScript.\n";+  }++  ResourceList resources;++  for (ReqWrapBase* req_wrap : *env->req_wrap_queue()) {+    AsyncWrap* wrap = req_wrap->GetAsyncWrap();+    if (wrap->persistent().IsEmpty()) continue;+    resources.Add(wrap->MemoryInfoName());+  }++  std::unordered_set<const uv_handle_t*> handle_wraps;+  for (HandleWrap* wrap : *env->handle_wrap_queue()) {+    uv_handle_t* handle = wrap->GetHandle();+    handle_wraps.insert(handle);+    if (wrap->persistent().IsEmpty() || !HandleWrap::HasRef(wrap) ||+        !uv_is_active(handle)) {+      continue;+    }+    const std::string name = wrap->MemoryInfoName();+    resources.Add(name, DescribeHandle(handle, name));+  }++  // Timers and immediates are tracked in JavaScript and share one libuv handle+  // per kind, so report how many of them keep the event loop alive.+  const int32_t timeouts = env->timeout_info()[0];+  if (timeouts > 0) {+    // The timer handle is inactive while the timers are being processed.+    uv_timer_t* timer_handle = env->timer_handle();+    resources.Add(+        "Timeout",+        uv_is_active(reinterpret_cast<uv_handle_t*>(timer_handle))+            ? SPrintF("next due in %dms", uv_timer_get_due_in(timer_handle))+            : "",+        timeouts);+  }+  const uint32_t immediates = env->immediate_info()->ref_count();+  if (immediates > 0) resources.Add("Immediate", "", immediates);++  // Running Workers keep the event loop alive through the Environment's own+  // task queue handle rather than through a HandleWrap.+  env->ForEachWorker([&](worker::Worker* worker) {+    if (worker->is_internal() || !worker->has_ref() || worker->is_stopped()) {+      return;+    }+    std::string details = SPrintF("thread %d", worker->thread_id());+    if (!worker->name().empty()) {+      details += SPrintF(", name '%s'", worker->name());+    }+    resources.Add("Worker", std::move(details));+  });++  // Anything else, e.g. handles created by native addons.+  struct WalkData {+    Environment* env;+    const std::unordered_set<const uv_handle_t*>* handle_wraps;+    ResourceList* resources;+  } walk_data{env, &handle_wraps, &resources};+  uv_walk(+      env->event_loop(),+      [](uv_handle_t* handle, void* arg) {+        WalkData* data = static_cast<WalkData*>(arg);+        Environment* env = data->env;+        if (!uv_is_active(handle) || !uv_has_ref(handle) ||+            data->handle_wraps->contains(handle) ||+            handle == reinterpret_cast<uv_handle_t*>(env->timer_handle()) ||+            handle ==+                reinterpret_cast<uv_handle_t*>(env->immediate_idle_handle()) ||+            handle ==+                reinterpret_cast<uv_handle_t*>(env->task_queues_async())) {+          return;+        }+        data->resources->Add("libuv handle", uv_handle_type_name(handle->type));+      },+      &walk_data);++  if (resources.empty()) {+    result += "No resources keeping the event loop alive were found.\n";+  } else {+    result += "Resources keeping the event loop alive:\n";+    result += resources.ToString();+  }+  return result;+}++}  // namespace++struct ProcessTimeoutWatchdog::State {+  enum class Phase {+    // Waiting for the deadline.+    kArmed,+    // The deadline was reached and the main thread was interrupted.+    kFired,+    // The main thread is printing diagnostics and exiting.+    kHandling,+    // The event loop has stopped and the Environment is being torn down.+    kStopping,+    // The process is exiting normally.+    kDisarmed,+  };++  State(uint64_t deadline, std::string duration, bool report)+      : deadline(deadline), duration(std::move(duration)), report(report) {+    CHECK_EQ(uv_mutex_init(&mutex), 0);+    CHECK_EQ(uv_cond_init(&cond), 0);+  }++  ~State() {+    uv_cond_destroy(&cond);+    uv_mutex_destroy(&mutex);+  }++  State(const State&) = delete;+  State& operator=(const State&) = delete;++  // With `mutex` held, waits while the phase is `current`, but at most until+  // uv_hrtime() reaches `until`.+  void WaitWhile(Phase current, uint64_t until) {+    while (phase == current) {+      const uint64_t now = uv_hrtime();+      if (now >= until) return;+      uv_cond_timedwait(&cond, &mutex, until - now);+    }+  }++  uv_mutex_t mutex;+  uv_cond_t cond;+  Phase phase = Phase::kArmed;+  // In uv_hrtime() nanoseconds.+  const uint64_t deadline;+  // As passed to --process-timeout, e.g. "30s".+  const std::string duration;+  // --report-on-process-timeout+  const bool report;+};++bool ProcessTimeoutWatchdog::IsEnabled() {+  Mutex::ScopedLock lock(per_process::cli_options_mutex);+  return per_process::cli_options->process_timeout_ms != 0;+}++std::unique_ptr<ProcessTimeoutWatchdog> ProcessTimeoutWatchdog::MaybeStart(+    Environment* env) {+  CHECK(env->is_main_thread());+  uint64_t timeout_ms;+  std::string duration;+  bool report;+  {+    Mutex::ScopedLock lock(per_process::cli_options_mutex);+    timeout_ms = per_process::cli_options->process_timeout_ms;+    duration = per_process::cli_options->process_timeout;+    report = per_process::cli_options->report_on_process_timeout;+  }+  if (timeout_ms == 0) return nullptr;++  // In watch mode, this process only restarts the application in child+  // processes, which inherit --process-timeout and apply it to each run.+  if (env->options()->watch_mode) return nullptr;++  const uint64_t deadline =+      per_process::node_start_time + timeout_ms * kNanosecondsPerMillisecond;+  auto state = std::make_shared<State>(deadline, std::move(duration), report);+  return std::unique_ptr<ProcessTimeoutWatchdog>(+      new ProcessTimeoutWatchdog(env, std::move(state)));+}++ProcessTimeoutWatchdog::ProcessTimeoutWatchdog(Environment* env,+                                               std::shared_ptr<State> state)+    : env_(env), state_(std::move(state)) {+  CHECK_EQ(uv_thread_create(&thread_, Run, this), 0);+}++ProcessTimeoutWatchdog::~ProcessTimeoutWatchdog() {+  uv_mutex_lock(&state_->mutex);+  state_->phase = State::Phase::kDisarmed;+  uv_cond_signal(&state_->cond);+  uv_mutex_unlock(&state_->mutex);+  CHECK_EQ(uv_thread_join(&thread_), 0);+}++void ProcessTimeoutWatchdog::OnEnvironmentStopping() {+  uv_mutex_lock(&state_->mutex);+  // The phase is only kFired here if the event loop stopped right as the+  // deadline was reached, a race that tests cannot reproduce reliably.+  if (state_->phase == State::Phase::kArmed ||+      state_->phase == State::Phase::kFired) {  // LCOV_EXCL_LINE+    state_->phase = State::Phase::kStopping;+    uv_cond_signal(&state_->cond);+  }+  uv_mutex_unlock(&state_->mutex);+}++void ProcessTimeoutWatchdog::Run(void* arg) {+  uv_thread_setname("ProcessTimeout");+  using Phase = State::Phase;+  ProcessTimeoutWatchdog* self = static_cast<ProcessTimeoutWatchdog*>(arg);+  std::shared_ptr<State> state = self->state_;++  uv_mutex_lock(&state->mutex);+  state->WaitWhile(Phase::kArmed, state->deadline);++  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+    // from the main thread while this thread is still executing this block.+    // clang's --coverage instrumentation reports a block that a thread has not+    // finished executing when the process exits as not executed, so coverage+    // cannot be collected for this block in either case.+    // LCOV_EXCL_START+    state->phase = Phase::kFired;+    // The Environment is only freed after OnEnvironmentStopping(), which+    // cannot happen while we hold the mutex. The callback runs from a V8+    // interrupt if JavaScript is executing, or from the event loop otherwise.+    self->env_->RequestInterrupt(+        [state](Environment* env) { OnTimeout(env, state); });++    state->WaitWhile(Phase::kFired,+                     uv_hrtime() + kProcessTimeoutResponseGraceMs *+                                       kNanosecondsPerMillisecond);+    if (state->phase == Phase::kFired) {+      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));+    }++    if (state->phase == Phase::kHandling) {+      state->WaitWhile(Phase::kHandling,+                       uv_hrtime() + kProcessTimeoutExitGraceMs *+                                         kNanosecondsPerMillisecond);+      if (state->phase == Phase::kHandling) {+        ForceProcessTimeoutExit(+            SPrintF("(node:%d) The process did not finish exiting within %dms "+                    "after --process-timeout expired. Forcing exit.\n",+                    uv_os_getpid(),+                    kProcessTimeoutExitGraceMs));+      }+    }+  }+  // LCOV_EXCL_STOP++  if (state->phase == Phase::kStopping) {+    // 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.+    const uint64_t now = uv_hrtime();+    // LCOV_EXCL_START+    const uint64_t until =+        now < state->deadline+            ? state->deadline+            : now + kProcessTimeoutResponseGraceMs * kNanosecondsPerMillisecond;+    // LCOV_EXCL_STOP+    state->WaitWhile(Phase::kStopping, until);+    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");+      // LCOV_EXCL_STOP+    }+  }++  uv_mutex_unlock(&state->mutex);+}++void ProcessTimeoutWatchdog::OnTimeout(Environment* env,+                                       const std::shared_ptr<State>& state) {+  uv_mutex_lock(&state->mutex);+  const bool should_handle = state->phase == State::Phase::kFired;+  if (should_handle) {+    state->phase = State::Phase::kHandling;+    uv_cond_signal(&state->cond);+  }+  uv_mutex_unlock(&state->mutex);+  if (!should_handle) return;++  Isolate* isolate = env->isolate();+  HandleScope handle_scope(isolate);+  {+    Isolate::DisallowJavascriptExecutionScope disallow_js(+        isolate, Isolate::DisallowJavascriptExecutionScope::CRASH_ON_FAILURE);+    FPrintF(stderr,+            "%s%s",+            FormatProcessTimeoutHeader(state->duration),+            FormatProcessTimeoutDiagnostics(env));+    fflush(stderr);++    if (state->report) {+      TriggerNodeReport(env,+                        "Process timed out (--process-timeout)",+                        "ProcessTimeout",+                        "",+                        Local<Value>());+    }+  }++  // Like process.reallyExit(): flush coverage and profiles, but do not emit+  // 'exit', as the JavaScript code may be what keeps the process running.+  RunAtExit(env);+  env->Exit(ExitCode::kProcessTimeout);+}  SigintWatchdog::SigintWatchdog(   v8::Isolate* isolate, bool* received_signal)
src/node_watchdog.h48 + / 0 −
@@ -24,6 +24,7 @@  #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#include <memory> #include <vector> #include "handle_wrap.h" #include "memory_tracker-inl.h"@@ -67,6 +68,53 @@ class Watchdog {   bool* timed_out_; }; +// Implements --process-timeout.+//+// A dedicated thread waits until the configured duration has elapsed since the+// process started. It then interrupts the main thread, which works both while+// JavaScript is running and while the event loop is waiting for events, to+// print why the process was still running and to exit with+// ExitCode::kProcessTimeout. If the main thread does not respond, for example+// because it is blocked in a synchronous native call, or does not finish+// exiting in time, the watchdog thread forces the process to exit on its own.+class ProcessTimeoutWatchdog {+ public:+  // Starts the watchdog for the main thread's Environment. Returns nullptr if+  // --process-timeout was not passed, or if this process only supervises the+  // runs of the application (--watch), in which case each run applies the+  // timeout on its own.+  static std::unique_ptr<ProcessTimeoutWatchdog> MaybeStart(Environment* env);++  // Whether --process-timeout is in effect for this process. Thread-safe.+  static bool IsEnabled();++  // Must be called once the event loop has stopped and before the Environment+  // is freed. From here on the watchdog no longer interrupts the main thread,+  // but still forces the process to exit if it is alive past the deadline.+  void OnEnvironmentStopping();++  // Disarms the watchdog and joins its thread.+  ~ProcessTimeoutWatchdog();++  ProcessTimeoutWatchdog(const ProcessTimeoutWatchdog&) = delete;+  ProcessTimeoutWatchdog& operator=(const ProcessTimeoutWatchdog&) = delete;+  ProcessTimeoutWatchdog(ProcessTimeoutWatchdog&&) = delete;+  ProcessTimeoutWatchdog& operator=(ProcessTimeoutWatchdog&&) = delete;++ private:+  struct State;++  ProcessTimeoutWatchdog(Environment* env, std::shared_ptr<State> state);++  static void Run(void* arg);+  static void OnTimeout(Environment* env, const std::shared_ptr<State>& state);++  Environment* env_;+  // Shared with the interrupt callback, which may outlive this object.+  std::shared_ptr<State> state_;+  uv_thread_t thread_;+};+ class SigintWatchdogBase {  public:   virtual ~SigintWatchdogBase() = default;
src/node_worker.h4 + / 0 −
@@ -66,6 +66,10 @@ class Worker : public AsyncWrap {   const SnapshotData* snapshot_data() const { return snapshot_data_; }   bool is_internal() const { return is_internal_; }   std::string_view name() const { return name_; }+  uint64_t thread_id() const { return thread_id_.id; }+  // Whether the Worker keeps the parent's event loop alive, see+  // `worker.ref()` and `worker.unref()`. Only valid on the parent thread.+  bool has_ref() const { return has_ref_; }    static void New(const v8::FunctionCallbackInfo<v8::Value>& args);   static void StartThread(const v8::FunctionCallbackInfo<v8::Value>& args);
test/fixtures/process-timeout/blocked-main-thread.jsadded13 + / 0 −
@@ -0,0 +1,13 @@+'use strict';++// Blocks the main thread in a synchronous native call, which cannot be+// interrupted. The first argument is a path at which a file is created once the+// main thread is blocked.+const { execFileSync } = require('child_process');+const path = require('path');++execFileSync(+  process.execPath,+  [path.join(__dirname, 'wait-for-parent.js'), String(process.pid), process.argv[2]],+  { stdio: 'ignore' },+);
test/fixtures/process-timeout/blocked-worker-at-exit.jsadded30 + / 0 −
@@ -0,0 +1,30 @@+'use strict';++// Lets the event loop stop 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 event loop has stopped.+const fs = require('fs');+const path = require('path');+const { Worker } = require('worker_threads');++const marker = process.argv[2];+const childMarker = `${marker}.child`;++const script = path.join(__dirname, 'wait-for-parent.js');+const args = [script, String(process.pid), childMarker];+const worker = 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)) {+    clearInterval(interval);+    worker.unref();+  }+}, 10);++process.on('exit', () => fs.writeFileSync(marker, ''));
test/fixtures/process-timeout/blocked-worker.jsadded15 + / 0 −
@@ -0,0 +1,15 @@+'use strict';++// Keeps the event loop alive with a Worker that 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 Worker is blocked.+const path = require('path');+const { Worker } = require('worker_threads');++const script = path.join(__dirname, 'wait-for-parent.js');+const args = [script, String(process.pid), process.argv[2]];+new Worker(`+  require('child_process').execFileSync(process.execPath, ${JSON.stringify(args)}, {+    stdio: 'ignore',+  });+`, { eval: true, name: 'blocked' });
test/fixtures/process-timeout/wait-for-parent.jsadded28 + / 0 −
@@ -0,0 +1,28 @@+'use strict';++// Stays alive until the process whose pid is passed as the first argument has+// exited, so that it does not outlive the test that started it. If a second+// argument is passed, creates a file at that path once it has started.+const fs = require('fs');++const [, , parent, marker] = process.argv;+if (marker) fs.writeFileSync(marker, '');++function parentExited() {+  // On POSIX, the process is reparented as soon as its parent exits, which+  // cannot be confused by the parent's pid being reused.+  if (process.platform !== 'win32') return process.ppid !== Number(parent);+  try {+    process.kill(Number(parent), 0);+    return false;+  } catch {+    return true;+  }+}++setInterval(() => {+  if (parentExited()) process.exit();+}, 50);++// Unblock the parent eventually, even if it is never forced to exit.+setTimeout(() => process.exit(), 60_000);
test/parallel/test-process-timeout-blocked.jsadded71 + / 0 −
@@ -0,0 +1,71 @@+'use strict';++// Tests that --process-timeout makes the process exit even if the main thread+// cannot be interrupted.++const common = require('../common');+const assert = require('assert');+const fs = require('fs');+const { spawnSync } = require('child_process');+const fixtures = require('../common/fixtures');+const tmpdir = require('../common/tmpdir');++tmpdir.refresh();++// The deadline is measured from the start of the process, so on a slow machine+// 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) {+  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 start = process.hrtime.bigint();+    const child = spawnSync(process.execPath, [+      `--process-timeout=${timeout}ms`,+      fixtures.path('process-timeout', fixture),+      marker,+    ], { encoding: 'utf8' });+    const elapsed = Number(process.hrtime.bigint() - start) / 1e6;++    if (!expected.test(child.stderr) && !fs.existsSync(marker) &&+        timeout < common.platformTimeout(16000)) {+      continue;+    }++    assert.strictEqual(child.signal, null);+    assert.strictEqual(child.status, 124, child.stderr);+    assert.ok(elapsed >= timeout, `Exited after ${elapsed}ms, before ${timeout}ms`);+    assert.match(child.stderr, new RegExp(+      `^\\(node:\\d+\\) Process timed out after ${timeout}ms \\(--process-timeout\\)\\. ` ++      'Exiting with code 124\\.$', 'm'));+    assert.match(child.stderr, expected);+    return child.stderr;+  }+}++{+  // The main thread is blocked in a synchronous native call.+  const stderr = runUntilReady(+    'blocked-main-thread.js',+    /^The main thread did not respond within 2000ms\. It is likely blocked/m);+  assert.doesNotMatch(stderr, /Main thread was/);+}++{+  // The main thread responds, but exiting waits for a Worker thread that is+  // blocked in a synchronous native call.+  const stderr = runUntilReady(+    'blocked-worker.js',+    /^\(node:\d+\) The process did not finish exiting within 5000ms after --process-timeout expired\. Forcing exit\.$/m);+  assert.match(stderr, /^ {4}Worker \(thread 1, name 'blocked'\)$/m);+}++{+  // The event loop has stopped, but exiting waits for a Worker thread that is+  // blocked in a synchronous native call.+  runUntilReady(+    'blocked-worker-at-exit.js',+    /^The process did not finish exiting after the event loop had stopped\.$/m);+}
test/parallel/test-process-timeout-coverage.jsadded31 + / 0 −
@@ -0,0 +1,31 @@+'use strict';++// Tests that code coverage is written when --process-timeout expires while the+// main thread is executing JavaScript.++const common = require('../common');+common.skipIfInspectorDisabled();++const assert = require('assert');+const fs = require('fs');+const tmpdir = require('../common/tmpdir');+const { spawnSyncAndExit } = require('../common/child_process');++tmpdir.refresh();++spawnSyncAndExit(process.execPath, [+  `--process-timeout=${common.platformTimeout(1000)}ms`,+  '-e',+  'function spin() { for (;;); } spin();',+], {+  env: { ...process.env, NODE_V8_COVERAGE: tmpdir.path },+}, {+  status: 124,+  signal: null,+});++const files = fs.readdirSync(tmpdir.path);+assert.strictEqual(files.length, 1);+assert.match(files[0], /^coverage-\d+-\d+-\d+\.json$/);+const coverage = JSON.parse(fs.readFileSync(tmpdir.resolve(files[0]), 'utf8'));+assert.ok(coverage.result.length > 0);
test/parallel/test-process-timeout-inspector.jsadded82 + / 0 −
@@ -0,0 +1,82 @@+'use strict';++// Tests that the inspector cannot be activated, and that sessions that could+// pause the main thread cannot be connected, while --process-timeout is used.++const common = require('../common');+common.skipIfInspectorDisabled();++const assert = require('assert');+const { spawn } = require('child_process');+const { once } = require('events');+const inspector = require('inspector');+const readline = require('readline');+const { Worker } = require('worker_threads');+const { spawnSyncAndAssert } = require('../common/child_process');++async function testActivationRequestIsIgnored() {+  // Activation via SIGUSR1 (or its Windows equivalent) is ignored. The warning+  // is printed from another thread, so wait for it before checking whether the+  // inspector was activated.+  const child = spawn(process.execPath, [+    '--process-timeout=10m',+    '-e',+    `process._debugProcess(process.pid);+     process.stdin.once('data', () => {+       process.stdout.write(String(require('inspector').url()));+       process.stdin.destroy();+     });`,+  ]);+  let stdout = '';+  child.stdout.setEncoding('utf8');+  child.stdout.on('data', (data) => { stdout += data; });+  const lines = readline.createInterface({ input: child.stderr });++  const [warning] = await once(lines, 'line');+  assert.match(warning,+               /^\(node:\d+\) Warning: Ignoring the request to activate the inspector because --process-timeout is used\.$/);+  child.stdin.end('check');++  const [code, signal] = await once(child, 'close');+  assert.strictEqual(signal, null);+  assert.strictEqual(code, 0);+  assert.strictEqual(stdout, 'undefined');+}++if (process.argv[2] !== 'child') {+  spawnSyncAndAssert(process.execPath, [+    '--process-timeout=10m',+    __filename,+    'child',+  ], {+    stderr: '',+  });++  testActivationRequestIsIgnored().then(common.mustCall());+  return;+}++assert.throws(() => inspector.open(0), {+  code: 'ERR_INSPECTOR_NOT_AVAILABLE',+  message: 'The inspector cannot be activated when --process-timeout is used',+});+assert.strictEqual(inspector.url(), undefined);++// Sessions on the same thread cannot keep the thread paused, so they work.+const session = new inspector.Session();+session.connect();+session.post('Runtime.evaluate', { expression: '1 + 2' }, common.mustSucceed(({ result }) => {+  assert.strictEqual(result.value, 3);+  session.disconnect();+}));++const worker = new Worker(`+  const assert = require('assert');+  const { Session } = require('inspector');+  assert.throws(() => new Session().connectToMainThread(), {+    code: 'ERR_INSPECTOR_NOT_AVAILABLE',+    message: 'Sessions cannot be connected to the main thread when ' ++             '--process-timeout is used',+  });+`, { eval: true });+worker.on('exit', common.mustCall((code) => assert.strictEqual(code, 0)));
test/parallel/test-process-timeout-options.jsadded118 + / 0 −
@@ -0,0 +1,118 @@+'use strict';++// Tests the validation of --process-timeout and of the options that cannot be+// combined with it.++const common = require('../common');+const assert = require('assert');+const { Worker } = require('worker_threads');+const {+  spawnSyncAndAssert,+  spawnSyncAndExit,+} = require('../common/child_process');++// Long enough for the process to exit on its own.+for (const args of [+  ['--process-timeout=600000ms'],+  ['--process-timeout=30s'],+  ['--process-timeout=5m'],+  ['--process-timeout', '1h'],+]) {+  spawnSyncAndAssert(process.execPath, [...args, '-e', ''], {+    stdout: '',+    stderr: '',+  });+}++for (const value of [+  '30', '0s', '0ms', '1.5s', '-1s', '1d', 's', 'abc', ' 1s', '1s ',+  '99999999999999999999h',+]) {+  spawnSyncAndExit(process.execPath, [`--process-timeout=${value}`, '-e', ''], {+    status: 9,+    signal: null,+    trim: true,+    stderr: `${process.execPath}: invalid value for --process-timeout: ` ++            `'${value}'. Expected a positive integer followed by a unit ` ++            '(ms, s, m or h), e.g. 30s',+  });+}++spawnSyncAndExit(process.execPath, ['--report-on-process-timeout', '-e', ''], {+  status: 9,+  signal: null,+  trim: true,+  stderr: `${process.execPath}: --report-on-process-timeout must be used with ` ++          '--process-timeout',+});++if (!process.config.variables.node_without_node_options) {+  for (const option of ['--process-timeout=1s', '--report-on-process-timeout']) {+    spawnSyncAndExit(process.execPath, ['-e', ''], {+      env: { ...process.env, NODE_OPTIONS: option },+    }, {+      status: 9,+      signal: null,+      trim: true,+      stderr: `${process.execPath}: ${option.replace(/=.*/, '=')} is not ` ++              'allowed in NODE_OPTIONS',+    });+  }++  // Conflicts are detected across option sources. The inspector options are+  // the only conflicting options allowed in NODE_OPTIONS, and they only exist+  // when Node.js is built with the inspector.+  if (common.hasInspector) {+    spawnSyncAndExit(process.execPath, ['--process-timeout=1s', '-e', ''], {+      env: { ...process.env, NODE_OPTIONS: '--inspect=0' },+    }, {+      status: 9,+      signal: null,+      trim: true,+      stderr: `${process.execPath}: either --process-timeout or --inspect can be ` ++              'used, not both',+    });+  }+}++const conflicts = [+  [['--run', 'test'], '--run'],+  [['--build-snapshot', 'entry.js'], '--build-snapshot'],+];+// Without the inspector, these options are rejected as bad options instead.+if (common.hasInspector) {+  conflicts.push(+    [['--inspect=0'], '--inspect'],+    [['--inspect-brk=0'], '--inspect-brk'],+    [['--inspect-brk-node=0'], '--inspect-brk-node'],+    [['--inspect-wait=0'], '--inspect-wait'],+    [['--inspect-port=0'], '--inspect-port'],+    [['--debug-port=0'], '--inspect-port'],+    [['--inspect-publish-uid=http'], '--inspect-publish-uid'],+  );+}++for (const [args, conflict] of conflicts) {+  spawnSyncAndExit(process.execPath, ['--process-timeout=1s', ...args], {+    status: 9,+    signal: null,+    trim: true,+    stderr: `${process.execPath}: either --process-timeout or ${conflict} can ` ++            'be used, not both',+  });+}++spawnSyncAndExit(process.execPath, ['--process-timeout=1s', 'inspect', 'app.js'], {+  status: 9,+  signal: null,+  trim: true,+  stderr: `${process.execPath}: --process-timeout cannot be used with ` ++          '`node inspect`',+});++// --process-timeout applies to the whole process, so Workers do not accept it.+assert.throws(() => {+  new Worker('', { eval: true, execArgv: ['--process-timeout=1s'] });+}, {+  code: 'ERR_WORKER_INVALID_EXEC_ARGV',+});
test/parallel/test-process-timeout.jsadded197 + / 0 −
@@ -0,0 +1,197 @@+'use strict';++// Tests that --process-timeout prints what kept the process running and exits+// with code 124.++const common = require('../common');+const assert = require('assert');+const { spawnSync } = require('child_process');+const fixtures = require('../common/fixtures');+const {+  spawnSyncAndAssert,+  spawnSyncAndExit,+} = require('../common/child_process');++// The deadline is measured from the start of the process, so on a slow machine+// it can expire before the script has set up the state under test. The scripts+// write 'ready' to stdout once they have, and run again with a longer timeout+// if they did not get that far.+function runUntilReady(script) {+  for (let timeout = common.platformTimeout(1000); ; timeout *= 2) {+    const start = process.hrtime.bigint();+    const child = spawnSync(process.execPath, [+      `--process-timeout=${timeout}ms`,+      '-e',+      `const { writeSync } = require('fs');\n${script}`,+    ], { encoding: 'utf8' });+    const elapsed = Number(process.hrtime.bigint() - start) / 1e6;++    if (!child.stdout.startsWith('ready\n') &&+        timeout < common.platformTimeout(16000)) {+      continue;+    }++    assert.strictEqual(child.signal, null);+    assert.strictEqual(child.status, 124, child.stderr);+    // Neither 'beforeExit' nor 'exit' is emitted.+    assert.strictEqual(child.stdout, 'ready\n');+    assert.ok(elapsed >= timeout, `Exited after ${elapsed}ms, before ${timeout}ms`);+    assert.match(child.stderr, new RegExp(+      `^\\(node:\\d+\\) Process timed out after ${timeout}ms \\(--process-timeout\\)\\. ` ++      'Exiting with code 124\\.$', 'm'));+    return child.stderr;+  }+}++{+  // The event loop is kept alive by a server and a timer.+  const stderr = runUntilReady(`+    require('net').createServer().listen(0);+    setInterval(() => {}, 60_000);+    process.on('beforeExit', () => writeSync(1, 'beforeExit\\n'));+    process.on('exit', () => writeSync(1, 'exit\\n'));+    setImmediate(() => writeSync(1, 'ready\\n'));+  `);+  assert.match(stderr, /^Main thread was not executing JavaScript\.$/m);+  assert.match(stderr, /^Resources keeping the event loop alive:$/m);+  assert.match(stderr, /^ {4}TCPServerWrap \(listening on \S+:\d+[,)]/m);+  assert.match(stderr, /^ {4}Timeout \(next due in \d+ms\)$/m);+}++{+  // The event loop is kept alive by various kinds of handles.+  const file = JSON.stringify(fixtures.path('process-timeout', 'wait-for-parent.js'));+  const stderr = runUntilReady(`+    const { spawn } = require('child_process');+    const dgram = require('dgram');+    const { Resolver } = require('dns');+    const { once } = require('events');+    const fs = require('fs');+    const net = require('net');++    (async () => {+      const server = net.createServer().listen(0, '127.0.0.1');+      await once(server, 'listening');+      await once(net.connect(server.address().port, '127.0.0.1'), 'connect');++      // The DNS server never responds, so the query stays pending.+      const udp = dgram.createSocket('udp4').bind(0, '127.0.0.1');+      await once(udp, 'listening');+      const resolver = new Resolver({ timeout: 60_000, tries: 1 });+      resolver.setServers(['127.0.0.1:' + udp.address().port]);+      resolver.resolve4('example.org', () => {});++      const child = spawn(process.execPath, [${file}, String(process.pid)], {+        stdio: 'ignore',+      });+      await once(child, 'spawn');++      if (${!common.isIBMi}) fs.watch(${file});+      fs.watchFile(${file}, () => {});+      new MessageChannel().port1.on('message', () => {});+      writeSync(1, 'ready\\n');+    })();+  `);+  assert.match(stderr, /^Main thread was not executing JavaScript\.$/m);+  assert.match(stderr,+               /^ {4}TCPSocketWrap \(127\.0\.0\.1:\d+ -> 127\.0\.0\.1:\d+[,)]/m);+  assert.match(stderr, /^ {4}UDPWrap \(bound to 127\.0\.0\.1:\d+[,)]/m);+  // The DNS query keeps the event loop alive with handles of its own.+  assert.match(stderr, /^ {4}libuv handle(?: x\d+)? \(poll\)$/m);+  assert.match(stderr, /^ {4}ProcessWrap \(pid \d+\)$/m);+  if (!common.isIBMi) {+    assert.match(stderr, /^ {4}FSEventWrap \(watching .*wait-for-parent\.js\)$/m);+  }+  assert.match(stderr, /^ {4}StatWatcher \(watching .*wait-for-parent\.js\)$/m);+  assert.match(stderr, /^ {4}MessagePort$/m);+}++{+  // The main thread is executing JavaScript.+  const stderr = runUntilReady(`+    function spin() { for (;;); }+    writeSync(1, 'ready\\n');+    spin();+  `);+  // Native code writes to stderr in text mode, which ends lines with '\r\n' on+  // Windows.+  assert.match(stderr,+               /^Main thread was executing JavaScript:\r?\n {4}at spin \(\[eval\]:\d+:\d+\)$/m);+  assert.match(stderr,+               /^No resources keeping the event loop alive were found\.$/m);+}++{+  // Requests cannot complete while the main thread is executing JavaScript.+  // Resources that are indistinguishable from each other are counted.+  const stderr = runUntilReady(`+    const { stat } = require('fs');+    function spin() { for (;;); }+    stat(process.execPath, () => {});+    stat(process.execPath, () => {});+    writeSync(1, 'ready\\n');+    spin();+  `);+  assert.match(stderr,+               /^Main thread was executing JavaScript:\r?\n {4}at spin \(\[eval\]:\d+:\d+\)$/m);+  assert.match(stderr,+               /^Resources keeping the event loop alive:\r?\n {4}FSReqCallback x2$/m);+}++{+  // The main thread is executing a timer callback. The timer handle is inactive+  // while timers are being processed, so no due time is printed.+  const stderr = runUntilReady(`+    setTimeout(function spin() {+      writeSync(1, 'ready\\n');+      for (;;);+    }, 1);+  `);+  assert.match(stderr,+               /^Main thread was executing JavaScript:\r?\n {4}at spin \(\[eval\]:\d+:\d+\)$/m);+  assert.match(stderr, /^ {4}Timeout$/m);+}++{+  // The main thread is blocked in Atomics.wait(), which can be interrupted.+  const stderr = runUntilReady(`+    writeSync(1, 'ready\\n');+    Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);+  `);+  assert.match(stderr, /^Main thread was executing JavaScript:\r?\n {4}at \[eval\]:\d+:\d+$/m);+}++{+  // A Worker thread keeps the event loop alive.+  const stderr = runUntilReady(`+    const { Worker } = require('worker_threads');+    new Worker('setInterval(() => {}, 60_000)', { eval: true, name: 'poller' });+    new Worker('setInterval(() => {}, 60_000)', { eval: true }).unref();+    writeSync(1, 'ready\\n');+  `);+  assert.match(stderr, /^ {4}Worker \(thread 1, name 'poller'\)$/m);+  // Workers that do not keep the event loop alive are not listed.+  assert.doesNotMatch(stderr, /^ {4}Worker \(thread 2\b/m);+}++{+  // Processes that exit on their own are not affected.+  spawnSyncAndAssert(process.execPath, [+    '--process-timeout=10m',+    '-e',+    'setTimeout(() => console.log("done"), 1)',+  ], {+    stdout: 'done\n',+    stderr: '',+  });++  spawnSyncAndExit(process.execPath, [+    '--process-timeout=10m',+    '-e',+    'setTimeout(() => process.exit(3), 1)',+  ], {+    status: 3,+    signal: null,+    stderr: '',+  });+}
test/report/test-report-process-timeout.jsadded47 + / 0 −
@@ -0,0 +1,47 @@+'use strict';++// Tests --report-on-process-timeout.++const common = require('../common');+const assert = require('assert');+const fs = require('fs');+const { spawnSync } = require('child_process');+const helper = require('../common/report');+const tmpdir = require('../common/tmpdir');++tmpdir.refresh();++// The deadline is measured from the start of the process, so on a slow machine+// it can expire before the script starts spinning. The script writes 'ready'+// to stdout before it does, and runs again with a longer timeout if it did not+// get that far.+let child;+for (let timeout = common.platformTimeout(1000); ; timeout *= 2) {+  child = spawnSync(process.execPath, [+    `--process-timeout=${timeout}ms`,+    '--report-on-process-timeout',+    '-e',+    `function spin() { for (;;); }+     require('fs').writeSync(1, 'ready\\n');+     spin();`,+  ], { cwd: tmpdir.path, encoding: 'utf8' });+  if (child.stdout === 'ready\n' || timeout >= common.platformTimeout(16000)) {+    break;+  }+}++assert.strictEqual(child.signal, null);+assert.strictEqual(child.status, 124, child.stderr);+assert.strictEqual(child.stdout, 'ready\n');+assert.match(child.stderr,+             /Main thread was executing JavaScript:[\s\S]*Writing Node\.js report to file: report\./);++const reports = helper.findReports(child.pid, tmpdir.path);+assert.strictEqual(reports.length, 1);+helper.validate(reports[0], [+  ['header.event', 'Process timed out (--process-timeout)'],+  ['header.trigger', 'ProcessTimeout'],+]);++const report = JSON.parse(fs.readFileSync(reports[0], 'utf8'));+assert.match(report.javascriptStack.stack[0], /^at spin \(\[eval\]:1:\d+\)$/);
test/sequential/test-watch-mode-process-timeout.mjsadded46 + / 0 −
@@ -0,0 +1,46 @@+// Tests that in watch mode --process-timeout applies to each run of the+// application, but not to the process that watches for changes.++import * as common from '../common/index.mjs';+import tmpdir from '../common/tmpdir.js';+import assert from 'node:assert';+import { spawn } from 'node:child_process';+import { once } from 'node:events';+import { writeFileSync } from 'node:fs';+import { setTimeout } from 'node:timers/promises';+import { createInterface } from 'node:readline';++if (common.isIBMi)+  common.skip('IBMi does not support `fs.watch()`');++tmpdir.refresh();+const file = tmpdir.resolve('hang.js');+writeFileSync(file, 'setInterval(() => {}, 60_000);');++const timeout = common.platformTimeout(500);+const child = spawn(process.execPath, [+  '--watch',+  '--no-warnings',+  `--process-timeout=${timeout}ms`,+  file,+], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });++let stderr = '';+child.stderr.setEncoding('utf8');+child.stderr.on('data', (data) => { stderr += data; });++for await (const line of createInterface({ input: child.stdout })) {+  if (line.startsWith('Failed running')) break;+}++// The watcher is still running well after its own deadline would have passed.+await setTimeout(timeout);+assert.strictEqual(child.exitCode, null);++child.kill();+await once(child, 'close');+// What the run was doing depends on how quickly it started, so only check+// that it timed out.+assert.match(stderr, new RegExp(+  `^\\(node:\\d+\\) Process timed out after ${timeout}ms \\(--process-timeout\\)\\. ` ++  'Exiting with code 124\\.$', 'm'));