Skip to content

Commit cc351cd

Browse files
orgadsclaude
andcommitted
src: add --trace-sigterm to print a stack trace on SIGTERM
Neither `--report-on-signal` nor a `process.on('SIGTERM')` handler runs while JavaScript is stuck, which is exactly when a trace is wanted: a pod whose liveness probe stopped responding gets terminated with SIGTERM, and there is currently no way to find out where it was stuck. Print the stack trace from an interrupt instead, like `--trace-sigint` does. The signal is watched through libuv on a dedicated thread and event loop rather than through a signal handler of our own, so that it stays multiplexed with `process.on('SIGTERM')` handlers: applications that shut down gracefully are the ones most likely to have a handler installed, and disabling the trace for them would defeat the purpose. Watching the signal takes its default disposition away, so restore it and re-raise once the trace has been printed, unless the application handles SIGTERM itself, in which case libuv has already delivered the signal to its handler as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Fixes: #56879 Signed-off-by: Orgad Shaneh <orgad.shaneh@audiocodes.com>
1 parent bb76938 commit cc351cd

9 files changed

Lines changed: 205 additions & 0 deletions

File tree

doc/api/cli.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3333,6 +3333,19 @@ added:
33333333

33343334
Prints a stack trace on SIGINT.
33353335

3336+
### `--trace-sigterm`
3337+
3338+
<!-- YAML
3339+
added: REPLACEME
3340+
-->
3341+
3342+
Prints a stack trace on SIGTERM.
3343+
3344+
Unlike a `SIGTERM` handler installed with `process.on('SIGTERM')`, the trace is
3345+
also printed while JavaScript is stuck, for example in an infinite loop. If
3346+
the application does not handle `SIGTERM` itself, the process is terminated by
3347+
the signal as usual once the trace has been printed.
3348+
33363349
### `--trace-sync-io`
33373350

33383351
<!-- YAML
@@ -3969,6 +3982,7 @@ one is included in the list below.
39693982
* `--trace-exit`
39703983
* `--trace-require-module`
39713984
* `--trace-sigint`
3985+
* `--trace-sigterm`
39723986
* `--trace-sync-io`
39733987
* `--trace-tls`
39743988
* `--trace-uncaught`

doc/node-config-schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,10 @@
716716
"type": "boolean",
717717
"description": "enable printing JavaScript stacktrace on SIGINT"
718718
},
719+
"trace-sigterm": {
720+
"type": "boolean",
721+
"description": "enable printing JavaScript stacktrace on SIGTERM"
722+
},
719723
"trace-sync-io": {
720724
"type": "boolean",
721725
"description": "show stack trace when use of sync IO is detected after the first tick"

doc/node.1

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1609,6 +1609,13 @@ from the \fBnode_modules\fR folder is excluded.
16091609
.It Fl -trace-sigint
16101610
Prints a stack trace on SIGINT.
16111611
.
1612+
.It Fl -trace-sigterm
1613+
Prints a stack trace on SIGTERM.
1614+
Unlike a \fBSIGTERM\fR handler installed with \fBprocess.on('SIGTERM')\fR, the trace is
1615+
also printed while JavaScript is stuck, for example in an infinite loop. If
1616+
the application does not handle \fBSIGTERM\fR itself, the process is terminated by
1617+
the signal as usual once the trace has been printed.
1618+
.
16121619
.It Fl -trace-sync-io
16131620
Prints a stack trace whenever synchronous I/O is detected after the first turn
16141621
of the event loop.
@@ -2211,6 +2218,8 @@ one is included in the list below.
22112218
.It
22122219
\fB--trace-sigint\fR
22132220
.It
2221+
\fB--trace-sigterm\fR
2222+
.It
22142223
\fB--trace-sync-io\fR
22152224
.It
22162225
\fB--trace-tls\fR

src/node.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
#include "node_snapshot_builder.h"
4747
#include "node_v8_platform-inl.h"
4848
#include "node_version.h"
49+
#include "node_watchdog.h"
4950

5051
#if HAVE_OPENSSL
5152
#include "ncrypto.h"
@@ -248,6 +249,9 @@ void Environment::InitializeDiagnostics() {
248249
if (options_->trace_promises) {
249250
isolate_->SetPromiseHook(TracePromises);
250251
}
252+
if (is_main_thread() && per_process::cli_options->trace_sigterm) {
253+
TraceSigtermWatchdog::Enable(this);
254+
}
251255
}
252256

253257
static

src/node_options.cc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,11 @@ PerProcessOptionsParser::PerProcessOptionsParser(
15201520
&PerProcessOptions::trace_sigint,
15211521
kAllowedInEnvvar);
15221522

1523+
AddOption("--trace-sigterm",
1524+
"enable printing JavaScript stacktrace on SIGTERM",
1525+
&PerProcessOptions::trace_sigterm,
1526+
kAllowedInEnvvar);
1527+
15231528
Insert(iop, &PerProcessOptions::get_per_isolate_options);
15241529

15251530
AddOption("--node-memory-debug",

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ class PerProcessOptions : public Options {
395395
// TODO(addaleax): Some of these could probably be per-Environment.
396396
std::string use_largepages = "off";
397397
bool trace_sigint = false;
398+
bool trace_sigterm = false;
398399
std::vector<std::string> cmdline;
399400

400401
inline PerIsolateOptions* get_per_isolate_options();

src/node_watchdog.cc

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
// USE OR OTHER DEALINGS IN THE SOFTWARE.
2121

2222
#include <algorithm>
23+
#include <csignal>
2324

2425
#include "async_wrap-inl.h"
2526
#include "debug_utils-inl.h"
@@ -34,6 +35,7 @@ namespace node {
3435
using v8::Context;
3536
using v8::FunctionCallbackInfo;
3637
using v8::FunctionTemplate;
38+
using v8::HandleScope;
3739
using v8::Isolate;
3840
using v8::Local;
3941
using v8::Object;
@@ -228,6 +230,88 @@ void TraceSigintWatchdog::HandleInterrupt() {
228230
raise(SIGINT);
229231
}
230232

233+
// Whether the main event loop watches `SIGTERM`, i.e. whether the application
234+
// installed its own handler through `process.on('SIGTERM')`.
235+
static bool HasSigtermListener(uv_loop_t* loop) {
236+
bool found = false;
237+
uv_walk(
238+
loop,
239+
[](uv_handle_t* handle, void* arg) {
240+
if (handle->type == UV_SIGNAL && uv_is_active(handle) &&
241+
reinterpret_cast<uv_signal_t*>(handle)->signum == SIGTERM) {
242+
*static_cast<bool*>(arg) = true;
243+
}
244+
},
245+
&found);
246+
return found;
247+
}
248+
249+
void TraceSigtermWatchdog::Enable(Environment* env) {
250+
env->AddCleanupHook(
251+
[](void* arg) { delete static_cast<TraceSigtermWatchdog*>(arg); },
252+
new TraceSigtermWatchdog(env));
253+
}
254+
255+
TraceSigtermWatchdog::TraceSigtermWatchdog(Environment* env) : env_(env) {
256+
CHECK_EQ(uv_loop_init(&loop_), 0);
257+
CHECK_EQ(uv_signal_init(&loop_, &signal_), 0);
258+
signal_.data = this;
259+
CHECK_EQ(uv_signal_start(
260+
&signal_,
261+
[](uv_signal_t* handle, int) {
262+
static_cast<TraceSigtermWatchdog*>(handle->data)->OnSignal();
263+
},
264+
SIGTERM),
265+
0);
266+
CHECK_EQ(
267+
uv_async_init(
268+
&loop_, &stop_, [](uv_async_t* handle) { uv_stop(handle->loop); }),
269+
0);
270+
CHECK_EQ(uv_thread_create(&thread_, Run, this), 0);
271+
}
272+
273+
TraceSigtermWatchdog::~TraceSigtermWatchdog() {
274+
CHECK_EQ(uv_async_send(&stop_), 0);
275+
CHECK_EQ(uv_thread_join(&thread_), 0);
276+
277+
uv_close(reinterpret_cast<uv_handle_t*>(&signal_), nullptr);
278+
uv_close(reinterpret_cast<uv_handle_t*>(&stop_), nullptr);
279+
280+
// UV_RUN_DEFAULT so that libuv has a chance to clean up.
281+
uv_run(&loop_, UV_RUN_DEFAULT);
282+
283+
CheckedUvLoopClose(&loop_);
284+
}
285+
286+
void TraceSigtermWatchdog::Run(void* arg) {
287+
uv_thread_setname("SigtermWatchdog");
288+
TraceSigtermWatchdog* wd = static_cast<TraceSigtermWatchdog*>(arg);
289+
290+
// The loop is stopped by the async handle.
291+
uv_run(&wd->loop_, UV_RUN_DEFAULT);
292+
}
293+
294+
void TraceSigtermWatchdog::OnSignal() {
295+
// The callback runs on the main thread, either from a V8 interrupt (while
296+
// JavaScript is running, which is where the stack trace comes from) or from
297+
// the event loop, whichever gets there first.
298+
env_->RequestInterrupt([](Environment* env) {
299+
HandleScope handle_scope(env->isolate());
300+
FPrintF(stderr,
301+
"TERMINATE: Script execution was interrupted by `SIGTERM`\n");
302+
PrintCurrentStackTrace(env->isolate());
303+
304+
// Watching the signal took the default disposition away, so restore it
305+
// here. Applications that handle `SIGTERM` themselves are not affected:
306+
// libuv has already delivered this signal to their handler as well.
307+
if (!HasSigtermListener(env->event_loop())) {
308+
ResetStdio();
309+
signal(SIGTERM, SIG_DFL);
310+
raise(SIGTERM);
311+
}
312+
});
313+
}
314+
231315
#ifdef __POSIX__
232316
void* SigintWatchdogHelper::RunSigintWatchdog(void* arg) {
233317
uv_thread_setname("SigintWatchdog");

src/node_watchdog.h

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,34 @@ class TraceSigintWatchdog : public HandleWrap, public SigintWatchdogBase {
117117
SignalFlags signal_flag_ = SignalFlags::None;
118118
};
119119

120+
// Prints the JavaScript stack trace of the main thread when `SIGTERM` is
121+
// received, then lets `SIGTERM` take its usual course. It runs its own event
122+
// loop on a dedicated thread, because the main event loop is not reached while
123+
// JavaScript is stuck (which is exactly when the trace is interesting).
124+
// Watching the signal through libuv (rather than sigaction()) keeps it
125+
// multiplexed with `process.on('SIGTERM')` handlers.
126+
class TraceSigtermWatchdog {
127+
public:
128+
// Enables the watchdog for the lifetime of `env`.
129+
static void Enable(Environment* env);
130+
131+
private:
132+
explicit TraceSigtermWatchdog(Environment* env);
133+
~TraceSigtermWatchdog();
134+
135+
TraceSigtermWatchdog(const TraceSigtermWatchdog&) = delete;
136+
TraceSigtermWatchdog& operator=(const TraceSigtermWatchdog&) = delete;
137+
138+
static void Run(void* arg);
139+
void OnSignal();
140+
141+
Environment* env_;
142+
uv_loop_t loop_;
143+
uv_signal_t signal_;
144+
uv_async_t stop_;
145+
uv_thread_t thread_;
146+
};
147+
120148
class SigintWatchdogHelper {
121149
public:
122150
static SigintWatchdogHelper* GetInstance() { return &instance; }
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use strict';
2+
3+
// Verifies that `--trace-sigterm` prints the JavaScript stack trace of a
4+
// process that is stuck in an infinite loop, both when the process handles
5+
// `SIGTERM` itself and when it does not.
6+
7+
const common = require('../common');
8+
9+
if (common.isWindows)
10+
common.skip('SIGTERM is not sent to processes on Windows');
11+
12+
const assert = require('assert');
13+
const { spawn } = require('child_process');
14+
15+
const kMessage = 'TERMINATE: Script execution was interrupted by `SIGTERM`';
16+
// The frames are written separately from the message above.
17+
const kStackFrame = /^ {4}at .*test-trace-sigterm\.js:\d+/m;
18+
19+
if (process.argv[2] === 'child') {
20+
if (process.argv[3] === 'handled')
21+
process.on('SIGTERM', () => {});
22+
process.kill(process.pid, 'SIGTERM');
23+
while (true) {
24+
// Stuck, so that the trace has to come from an interrupt.
25+
}
26+
}
27+
28+
function run(mode, expectedSignal) {
29+
const child = spawn(
30+
process.execPath,
31+
['--trace-sigterm', __filename, 'child', mode],
32+
{ stdio: ['ignore', 'ignore', 'pipe'] });
33+
34+
let stderr = '';
35+
child.stderr.setEncoding('utf8');
36+
child.stderr.on('data', (chunk) => {
37+
stderr += chunk;
38+
// The handled case stays stuck, because its handler never gets to run.
39+
if (mode === 'handled' && kStackFrame.test(stderr))
40+
child.kill('SIGKILL');
41+
});
42+
43+
child.on('exit', common.mustCall((code, signal) => {
44+
assert.ok(stderr.includes(kMessage), stderr);
45+
assert.match(stderr, kStackFrame);
46+
assert.strictEqual(code, null);
47+
assert.strictEqual(signal, expectedSignal);
48+
}));
49+
}
50+
51+
// Without a handler, the signal terminates the process as usual.
52+
run('unhandled', 'SIGTERM');
53+
54+
// With a handler, `--trace-sigterm` does not terminate the process, so it is
55+
// killed above once the trace has been printed.
56+
run('handled', 'SIGKILL');

0 commit comments

Comments
 (0)