Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit ddadc31

Browse filesBrowse files
mcollinamarco-ippolito
authored andcommitted
src: rethrow stack overflow exceptions in async_hooks
When a stack overflow exception occurs during async_hooks callbacks (which use TryCatchScope::kFatal), detect the specific "Maximum call stack size exceeded" RangeError and re-throw it instead of immediately calling FatalException. This allows user code to catch the exception with try-catch blocks instead of requiring uncaughtException handlers. The implementation adds IsStackOverflowError() helper to detect stack overflow RangeErrors and re-throws them in TryCatchScope destructor instead of calling FatalException. This fixes the issue where async_hooks would cause stack overflow exceptions to exit with code 7 (kExceptionInFatalExceptionHandler) instead of being catchable. Fixes: #37989 Ref: https://hackerone.com/reports/3456295 PR-URL: nodejs-private/node-private#773 Refs: https://hackerone.com/reports/3456295 Reviewed-By: Robert Nagy <ronagy@icloud.com> Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com> Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com> Reviewed-By: Anna Henningsen <anna@addaleax.net> CVE-ID: CVE-2025-59466
1 parent 6b48495 commit ddadc31
Copy full SHA for ddadc31
Expand file treeCollapse file tree

10 files changed

+306
-14
lines changed
Open diff view settings
Collapse file

‎src/async_wrap.cc‎

Copy file name to clipboardExpand all lines: src/async_wrap.cc
+6-3Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ static const char* const provider_names[] = {
6868
void AsyncWrap::DestroyAsyncIdsCallback(Environment* env) {
6969
Local<Function> fn = env->async_hooks_destroy_function();
7070

71-
TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
71+
TryCatchScope try_catch(env,
72+
TryCatchScope::CatchMode::kFatalRethrowStackOverflow);
7273

7374
do {
7475
std::vector<double> destroy_async_id_list;
@@ -97,7 +98,8 @@ void Emit(Environment* env, double async_id, AsyncHooks::Fields type,
9798

9899
HandleScope handle_scope(env->isolate());
99100
Local<Value> async_id_value = Number::New(env->isolate(), async_id);
100-
TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
101+
TryCatchScope try_catch(env,
102+
TryCatchScope::CatchMode::kFatalRethrowStackOverflow);
101103
USE(fn->Call(env->context(), Undefined(env->isolate()), 1, &async_id_value));
102104
}
103105

@@ -646,7 +648,8 @@ void AsyncWrap::EmitAsyncInit(Environment* env,
646648
object,
647649
};
648650

649-
TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
651+
TryCatchScope try_catch(env,
652+
TryCatchScope::CatchMode::kFatalRethrowStackOverflow);
650653
USE(init_fn->Call(env->context(), object, arraysize(argv), argv));
651654
}
652655

Collapse file

‎src/debug_utils.cc‎

Copy file name to clipboardExpand all lines: src/debug_utils.cc
+2-1Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,8 @@ void DumpJavaScriptBacktrace(FILE* fp) {
333333
}
334334

335335
Local<StackTrace> stack;
336-
if (!GetCurrentStackTrace(isolate).ToLocal(&stack)) {
336+
if (!GetCurrentStackTrace(isolate).ToLocal(&stack) ||
337+
stack->GetFrameCount() == 0) {
337338
return;
338339
}
339340

Collapse file

‎src/node_errors.cc‎

Copy file name to clipboardExpand all lines: src/node_errors.cc
+63-8Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ static std::string GetErrorSource(Isolate* isolate,
194194
}
195195

196196
static std::atomic<bool> is_in_oom{false};
197-
static std::atomic<bool> is_retrieving_js_stacktrace{false};
197+
static thread_local std::atomic<bool> is_retrieving_js_stacktrace{false};
198198
MaybeLocal<StackTrace> GetCurrentStackTrace(Isolate* isolate, int frame_count) {
199199
if (isolate == nullptr) {
200200
return MaybeLocal<StackTrace>();
@@ -222,9 +222,6 @@ MaybeLocal<StackTrace> GetCurrentStackTrace(Isolate* isolate, int frame_count) {
222222
StackTrace::CurrentStackTrace(isolate, frame_count, options);
223223

224224
is_retrieving_js_stacktrace.store(false);
225-
if (stack->GetFrameCount() == 0) {
226-
return MaybeLocal<StackTrace>();
227-
}
228225

229226
return scope.Escape(stack);
230227
}
@@ -299,7 +296,8 @@ void PrintStackTrace(Isolate* isolate,
299296

300297
void PrintCurrentStackTrace(Isolate* isolate, StackTracePrefix prefix) {
301298
Local<StackTrace> stack;
302-
if (GetCurrentStackTrace(isolate).ToLocal(&stack)) {
299+
if (GetCurrentStackTrace(isolate).ToLocal(&stack) &&
300+
stack->GetFrameCount() > 0) {
303301
PrintStackTrace(isolate, stack, prefix);
304302
}
305303
}
@@ -671,13 +669,52 @@ v8::ModifyCodeGenerationFromStringsResult ModifyCodeGenerationFromStrings(
671669
};
672670
}
673671

672+
// Check if an exception is a stack overflow error (RangeError with
673+
// "Maximum call stack size exceeded" message). This is used to handle
674+
// stack overflow specially in TryCatchScope - instead of immediately
675+
// exiting, we can use the red zone to re-throw to user code.
676+
static bool IsStackOverflowError(Isolate* isolate, Local<Value> exception) {
677+
if (!exception->IsNativeError()) return false;
678+
679+
Local<Object> err_obj = exception.As<Object>();
680+
Local<String> constructor_name = err_obj->GetConstructorName();
681+
682+
// Must be a RangeError
683+
Utf8Value name(isolate, constructor_name);
684+
if (name.ToStringView() != "RangeError") return false;
685+
686+
// Check for the specific stack overflow message
687+
Local<Context> context = isolate->GetCurrentContext();
688+
Local<Value> message_val;
689+
if (!err_obj->Get(context, String::NewFromUtf8Literal(isolate, "message"))
690+
.ToLocal(&message_val)) {
691+
return false;
692+
}
693+
694+
if (!message_val->IsString()) return false;
695+
696+
Utf8Value message(isolate, message_val.As<String>());
697+
return message.ToStringView() == "Maximum call stack size exceeded";
698+
}
699+
674700
namespace errors {
675701

676702
TryCatchScope::~TryCatchScope() {
677-
if (HasCaught() && !HasTerminated() && mode_ == CatchMode::kFatal) {
703+
if (HasCaught() && !HasTerminated() && mode_ != CatchMode::kNormal) {
678704
HandleScope scope(env_->isolate());
679705
Local<v8::Value> exception = Exception();
680706
Local<v8::Message> message = Message();
707+
708+
// Special handling for stack overflow errors in async_hooks: instead of
709+
// immediately exiting, re-throw the exception. This allows the exception
710+
// to propagate to user code's try-catch blocks.
711+
if (mode_ == CatchMode::kFatalRethrowStackOverflow &&
712+
IsStackOverflowError(env_->isolate(), exception)) {
713+
ReThrow();
714+
Reset();
715+
return;
716+
}
717+
681718
EnhanceFatalException enhance = CanContinue() ?
682719
EnhanceFatalException::kEnhance : EnhanceFatalException::kDontEnhance;
683720
if (message.IsEmpty())
@@ -1278,8 +1315,26 @@ void TriggerUncaughtException(Isolate* isolate,
12781315
if (env->can_call_into_js()) {
12791316
// We do not expect the global uncaught exception itself to throw any more
12801317
// exceptions. If it does, exit the current Node.js instance.
1281-
errors::TryCatchScope try_catch(env,
1282-
errors::TryCatchScope::CatchMode::kFatal);
1318+
// Special case: if the original error was a stack overflow and calling
1319+
// _fatalException causes another stack overflow, rethrow it to allow
1320+
// user code's try-catch blocks to potentially catch it.
1321+
auto is_stack_overflow = [&] {
1322+
return IsStackOverflowError(env->isolate(), error);
1323+
};
1324+
// Without a JS stack, rethrowing may or may not do anything.
1325+
// TODO(addaleax): In V8, expose a way to check whether there is a JS stack
1326+
// or TryCatch that would capture the rethrown exception.
1327+
auto has_js_stack = [&] {
1328+
HandleScope handle_scope(env->isolate());
1329+
Local<StackTrace> stack;
1330+
return GetCurrentStackTrace(env->isolate(), 1).ToLocal(&stack) &&
1331+
stack->GetFrameCount() > 0;
1332+
};
1333+
errors::TryCatchScope::CatchMode mode =
1334+
is_stack_overflow() && has_js_stack()
1335+
? errors::TryCatchScope::CatchMode::kFatalRethrowStackOverflow
1336+
: errors::TryCatchScope::CatchMode::kFatal;
1337+
errors::TryCatchScope try_catch(env, mode);
12831338
// Explicitly disable verbose exception reporting -
12841339
// if process._fatalException() throws an error, we don't want it to
12851340
// trigger the per-isolate message listener which will call this
Collapse file

‎src/node_errors.h‎

Copy file name to clipboardExpand all lines: src/node_errors.h
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ namespace errors {
312312

313313
class TryCatchScope : public v8::TryCatch {
314314
public:
315-
enum class CatchMode { kNormal, kFatal };
315+
enum class CatchMode { kNormal, kFatal, kFatalRethrowStackOverflow };
316316

317317
explicit TryCatchScope(Environment* env, CatchMode mode = CatchMode::kNormal)
318318
: v8::TryCatch(env->isolate()), env_(env), mode_(mode) {}
Collapse file

‎src/node_report.cc‎

Copy file name to clipboardExpand all lines: src/node_report.cc
+2-1Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,8 @@ static void PrintJavaScriptStack(JSONWriter* writer,
470470
const char* trigger) {
471471
HandleScope scope(isolate);
472472
Local<v8::StackTrace> stack;
473-
if (!GetCurrentStackTrace(isolate, MAX_FRAME_COUNT).ToLocal(&stack)) {
473+
if (!GetCurrentStackTrace(isolate, MAX_FRAME_COUNT).ToLocal(&stack) ||
474+
stack->GetFrameCount() == 0) {
474475
PrintEmptyJavaScriptStack(writer);
475476
return;
476477
}
Collapse file
+80Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
'use strict';
2+
3+
// This test verifies that stack overflow during deeply nested async operations
4+
// with async_hooks enabled can be caught by try-catch. This simulates real-world
5+
// scenarios like processing deeply nested JSON structures where each level
6+
// creates async operations (e.g., database calls, API requests).
7+
8+
require('../common');
9+
const assert = require('assert');
10+
const { spawnSync } = require('child_process');
11+
12+
if (process.argv[2] === 'child') {
13+
const { createHook } = require('async_hooks');
14+
15+
// Enable async_hooks with all callbacks (simulates APM tools)
16+
createHook({
17+
init() {},
18+
before() {},
19+
after() {},
20+
destroy() {},
21+
promiseResolve() {},
22+
}).enable();
23+
24+
// Simulate an async operation (like a database call or API request)
25+
async function fetchThing(id) {
26+
return { id, data: `data-${id}` };
27+
}
28+
29+
// Recursively process deeply nested data structure
30+
// This will cause stack overflow when the nesting is deep enough
31+
function processData(data, depth = 0) {
32+
if (Array.isArray(data)) {
33+
for (const item of data) {
34+
// Create a promise to trigger async_hooks init callback
35+
fetchThing(depth);
36+
processData(item, depth + 1);
37+
}
38+
}
39+
}
40+
41+
// Create deeply nested array structure iteratively (to avoid stack overflow
42+
// during creation)
43+
function createNestedArray(depth) {
44+
let result = 'leaf';
45+
for (let i = 0; i < depth; i++) {
46+
result = [result];
47+
}
48+
return result;
49+
}
50+
51+
// Create a very deep nesting that will cause stack overflow during processing
52+
const deeplyNested = createNestedArray(50000);
53+
54+
try {
55+
processData(deeplyNested);
56+
// Should not complete successfully - the nesting is too deep
57+
console.log('UNEXPECTED: Processing completed without error');
58+
process.exit(1);
59+
} catch (err) {
60+
assert.strictEqual(err.name, 'RangeError');
61+
assert.match(err.message, /Maximum call stack size exceeded/);
62+
console.log('SUCCESS: try-catch caught the stack overflow in nested async');
63+
process.exit(0);
64+
}
65+
} else {
66+
// Parent process - spawn the child and check exit code
67+
const result = spawnSync(
68+
process.execPath,
69+
[__filename, 'child'],
70+
{ encoding: 'utf8', timeout: 30000 }
71+
);
72+
73+
// Should exit successfully (try-catch worked)
74+
assert.strictEqual(result.status, 0,
75+
`Expected exit code 0, got ${result.status}.\n` +
76+
`stdout: ${result.stdout}\n` +
77+
`stderr: ${result.stderr}`);
78+
// Verify the error was handled by try-catch
79+
assert.match(result.stdout, /SUCCESS: try-catch caught the stack overflow/);
80+
}
Collapse file
+47Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use strict';
2+
3+
// This test verifies that when a stack overflow occurs with async_hooks
4+
// enabled, the exception can be caught by try-catch blocks in user code.
5+
6+
require('../common');
7+
const assert = require('assert');
8+
const { spawnSync } = require('child_process');
9+
10+
if (process.argv[2] === 'child') {
11+
const { createHook } = require('async_hooks');
12+
13+
createHook({ init() {} }).enable();
14+
15+
function recursive(depth = 0) {
16+
// Create a promise to trigger async_hooks init callback
17+
new Promise(() => {});
18+
return recursive(depth + 1);
19+
}
20+
21+
try {
22+
recursive();
23+
// Should not reach here
24+
process.exit(1);
25+
} catch (err) {
26+
assert.strictEqual(err.name, 'RangeError');
27+
assert.match(err.message, /Maximum call stack size exceeded/);
28+
console.log('SUCCESS: try-catch caught the stack overflow');
29+
process.exit(0);
30+
}
31+
32+
// Should not reach here
33+
process.exit(2);
34+
} else {
35+
// Parent process - spawn the child and check exit code
36+
const result = spawnSync(
37+
process.execPath,
38+
[__filename, 'child'],
39+
{ encoding: 'utf8', timeout: 30000 }
40+
);
41+
42+
assert.strictEqual(result.status, 0,
43+
`Expected exit code 0 (try-catch worked), got ${result.status}.\n` +
44+
`stdout: ${result.stdout}\n` +
45+
`stderr: ${result.stderr}`);
46+
assert.match(result.stdout, /SUCCESS: try-catch caught the stack overflow/);
47+
}
Collapse file
+47Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use strict';
2+
3+
// This test verifies that when a stack overflow occurs with async_hooks
4+
// enabled, the uncaughtException handler is still called instead of the
5+
// process crashing with exit code 7.
6+
7+
const common = require('../common');
8+
const assert = require('assert');
9+
const { spawnSync } = require('child_process');
10+
11+
if (process.argv[2] === 'child') {
12+
const { createHook } = require('async_hooks');
13+
14+
let handlerCalled = false;
15+
16+
function recursive() {
17+
// Create a promise to trigger async_hooks init callback
18+
new Promise(() => {});
19+
return recursive();
20+
}
21+
22+
createHook({ init() {} }).enable();
23+
24+
process.on('uncaughtException', common.mustCall((err) => {
25+
assert.strictEqual(err.name, 'RangeError');
26+
assert.match(err.message, /Maximum call stack size exceeded/);
27+
// Ensure handler is only called once
28+
assert.strictEqual(handlerCalled, false);
29+
handlerCalled = true;
30+
}));
31+
32+
setImmediate(recursive);
33+
} else {
34+
// Parent process - spawn the child and check exit code
35+
const result = spawnSync(
36+
process.execPath,
37+
[__filename, 'child'],
38+
{ encoding: 'utf8', timeout: 30000 }
39+
);
40+
41+
// Should exit with code 0 (handler was called and handled the exception)
42+
// Previously would exit with code 7 (kExceptionInFatalExceptionHandler)
43+
assert.strictEqual(result.status, 0,
44+
`Expected exit code 0, got ${result.status}.\n` +
45+
`stdout: ${result.stdout}\n` +
46+
`stderr: ${result.stderr}`);
47+
}
Collapse file
+29Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use strict';
2+
3+
// This test verifies that when the uncaughtException handler itself causes
4+
// a stack overflow, the process exits with a non-zero exit code.
5+
// This is important to ensure we don't silently swallow errors.
6+
7+
require('../common');
8+
const assert = require('assert');
9+
const { spawnSync } = require('child_process');
10+
11+
if (process.argv[2] === 'child') {
12+
function f() { f(); }
13+
process.on('uncaughtException', f);
14+
f();
15+
} else {
16+
// Parent process - spawn the child and check exit code
17+
const result = spawnSync(
18+
process.execPath,
19+
[__filename, 'child'],
20+
{ encoding: 'utf8', timeout: 30000 }
21+
);
22+
23+
// Should exit with non-zero exit code since the uncaughtException handler
24+
// itself caused a stack overflow.
25+
assert.notStrictEqual(result.status, 0,
26+
`Expected non-zero exit code, got ${result.status}.\n` +
27+
`stdout: ${result.stdout}\n` +
28+
`stderr: ${result.stderr}`);
29+
}

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.