nodejs/node · #66138
src: add --process-timeout=N
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-1doc/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()`]: #sessionconnecttomainthreaddoc/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#subprocessdisconnectdoc/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 issrc/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 thesrc/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'));