Skip to content

Commit 028107f

Browse files
rudolftammhdawson
rudolftam
authored andcommitted
src: fix Error::ThrowAsJavaScriptException crash
When terminating an environment (e.g., by calling worker.terminate), napi_throw, which is called by Error::ThrowAsJavaScriptException, returns napi_pending_exception, which is then incorrectly treated as a fatal error resulting in a crash. PR-URL: #975 Reviewed-By: Nicola Del Gobbo <[email protected]> Reviewed-By: Gabriel Schulhof <[email protected]> Reviewed-By: Chengzhong Wu <[email protected]> Reviewed-By: Michael Dawson <[email protected]>
1 parent fed1353 commit 028107f

7 files changed

+228
-6
lines changed

doc/setup.md

+7
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,10 @@ targeted node version *does not* have Node-API built-in.
8181

8282
The preprocessor directive `NODE_ADDON_API_DISABLE_DEPRECATED` can be defined at
8383
compile time before including `napi.h` to skip the definition of deprecated APIs.
84+
85+
By default, throwing an exception on a terminating environment (eg. worker
86+
threads) will cause a fatal exception, terminating the Node process. This is to
87+
provide feedback to the user of the runtime error, as it is impossible to pass
88+
the error to JavaScript when the environment is terminating. In order to bypass
89+
this behavior such that the Node process will not terminate, define the
90+
preprocessor directive `NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS`.

napi-inl.h

+27-1
Original file line numberDiff line numberDiff line change
@@ -2397,12 +2397,38 @@ inline const std::string& Error::Message() const NAPI_NOEXCEPT {
23972397
inline void Error::ThrowAsJavaScriptException() const {
23982398
HandleScope scope(_env);
23992399
if (!IsEmpty()) {
2400-
2400+
#ifdef NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS
2401+
bool pendingException = false;
2402+
2403+
// check if there is already a pending exception. If so don't try to throw a
2404+
// new one as that is not allowed/possible
2405+
napi_status status = napi_is_exception_pending(_env, &pendingException);
2406+
2407+
if ((status != napi_ok) ||
2408+
((status == napi_ok) && (pendingException == false))) {
2409+
// We intentionally don't use `NAPI_THROW_*` macros here to ensure
2410+
// that there is no possible recursion as `ThrowAsJavaScriptException`
2411+
// is part of `NAPI_THROW_*` macro definition for noexcept.
2412+
2413+
status = napi_throw(_env, Value());
2414+
2415+
if (status == napi_pending_exception) {
2416+
// The environment must be terminating as we checked earlier and there
2417+
// was no pending exception. In this case continuing will result
2418+
// in a fatal error and there is nothing the author has done incorrectly
2419+
// in their code that is worth flagging through a fatal error
2420+
return;
2421+
}
2422+
} else {
2423+
status = napi_pending_exception;
2424+
}
2425+
#else
24012426
// We intentionally don't use `NAPI_THROW_*` macros here to ensure
24022427
// that there is no possible recursion as `ThrowAsJavaScriptException`
24032428
// is part of `NAPI_THROW_*` macro definition for noexcept.
24042429

24052430
napi_status status = napi_throw(_env, Value());
2431+
#endif
24062432

24072433
#ifdef NAPI_CPP_EXCEPTIONS
24082434
if (status != napi_ok) {

test/binding-swallowexcept.cc

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#include "napi.h"
2+
3+
using namespace Napi;
4+
5+
Object InitError(Env env);
6+
7+
Object Init(Env env, Object exports) {
8+
exports.Set("error", InitError(env));
9+
return exports;
10+
}
11+
12+
NODE_API_MODULE(addon, Init)

test/binding.gyp

+25-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
'target_defaults': {
33
'includes': ['../common.gypi'],
4-
'sources': [
4+
'variables': {
5+
'build_sources': [
56
'addon.cc',
67
'addon_data.cc',
78
'arraybuffer.cc',
@@ -67,20 +68,39 @@
6768
'version_management.cc',
6869
'thunking_manual.cc',
6970
],
71+
'build_sources_swallowexcept': [
72+
'binding-swallowexcept.cc',
73+
'error.cc',
74+
],
7075
'conditions': [
7176
['disable_deprecated!="true"', {
72-
'sources': ['object/object_deprecated.cc']
77+
'build_sources': ['object/object_deprecated.cc']
7378
}]
74-
],
79+
]
80+
},
7581
},
7682
'targets': [
7783
{
7884
'target_name': 'binding',
79-
'includes': ['../except.gypi']
85+
'includes': ['../except.gypi'],
86+
'sources': ['>@(build_sources)']
8087
},
8188
{
8289
'target_name': 'binding_noexcept',
83-
'includes': ['../noexcept.gypi']
90+
'includes': ['../noexcept.gypi'],
91+
'sources': ['>@(build_sources)']
92+
},
93+
{
94+
'target_name': 'binding_swallowexcept',
95+
'includes': ['../except.gypi'],
96+
'sources': [ '>@(build_sources_swallowexcept)'],
97+
'defines': ['NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS']
98+
},
99+
{
100+
'target_name': 'binding_swallowexcept_noexcept',
101+
'includes': ['../noexcept.gypi'],
102+
'sources': ['>@(build_sources_swallowexcept)'],
103+
'defines': ['NODE_API_SWALLOW_UNTHROWABLE_EXCEPTIONS']
84104
},
85105
],
86106
}

test/error.cc

+62
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,54 @@
1+
#include <future>
12
#include "napi.h"
23

34
using namespace Napi;
45

56
namespace {
67

8+
std::promise<void> promise_for_child_process_;
9+
std::promise<void> promise_for_worker_thread_;
10+
11+
void ResetPromises(const CallbackInfo&) {
12+
promise_for_child_process_ = std::promise<void>();
13+
promise_for_worker_thread_ = std::promise<void>();
14+
}
15+
16+
void WaitForWorkerThread(const CallbackInfo&) {
17+
std::future<void> future = promise_for_worker_thread_.get_future();
18+
19+
std::future_status status = future.wait_for(std::chrono::seconds(5));
20+
21+
if (status != std::future_status::ready) {
22+
Error::Fatal("WaitForWorkerThread", "status != std::future_status::ready");
23+
}
24+
}
25+
26+
void ReleaseAndWaitForChildProcess(const CallbackInfo& info,
27+
const uint32_t index) {
28+
if (info.Length() < index + 1) {
29+
return;
30+
}
31+
32+
if (!info[index].As<Boolean>().Value()) {
33+
return;
34+
}
35+
36+
promise_for_worker_thread_.set_value();
37+
38+
std::future<void> future = promise_for_child_process_.get_future();
39+
40+
std::future_status status = future.wait_for(std::chrono::seconds(5));
41+
42+
if (status != std::future_status::ready) {
43+
Error::Fatal("ReleaseAndWaitForChildProcess",
44+
"status != std::future_status::ready");
45+
}
46+
}
47+
48+
void ReleaseWorkerThread(const CallbackInfo&) {
49+
promise_for_child_process_.set_value();
50+
}
51+
752
void DoNotCatch(const CallbackInfo& info) {
853
Function thrower = info[0].As<Function>();
954
thrower({});
@@ -18,16 +63,22 @@ void ThrowApiError(const CallbackInfo& info) {
1863

1964
void ThrowJSError(const CallbackInfo& info) {
2065
std::string message = info[0].As<String>().Utf8Value();
66+
67+
ReleaseAndWaitForChildProcess(info, 1);
2168
throw Error::New(info.Env(), message);
2269
}
2370

2471
void ThrowTypeError(const CallbackInfo& info) {
2572
std::string message = info[0].As<String>().Utf8Value();
73+
74+
ReleaseAndWaitForChildProcess(info, 1);
2675
throw TypeError::New(info.Env(), message);
2776
}
2877

2978
void ThrowRangeError(const CallbackInfo& info) {
3079
std::string message = info[0].As<String>().Utf8Value();
80+
81+
ReleaseAndWaitForChildProcess(info, 1);
3182
throw RangeError::New(info.Env(), message);
3283
}
3384

@@ -83,16 +134,22 @@ void CatchAndRethrowErrorThatEscapesScope(const CallbackInfo& info) {
83134

84135
void ThrowJSError(const CallbackInfo& info) {
85136
std::string message = info[0].As<String>().Utf8Value();
137+
138+
ReleaseAndWaitForChildProcess(info, 1);
86139
Error::New(info.Env(), message).ThrowAsJavaScriptException();
87140
}
88141

89142
void ThrowTypeError(const CallbackInfo& info) {
90143
std::string message = info[0].As<String>().Utf8Value();
144+
145+
ReleaseAndWaitForChildProcess(info, 1);
91146
TypeError::New(info.Env(), message).ThrowAsJavaScriptException();
92147
}
93148

94149
void ThrowRangeError(const CallbackInfo& info) {
95150
std::string message = info[0].As<String>().Utf8Value();
151+
152+
ReleaseAndWaitForChildProcess(info, 1);
96153
RangeError::New(info.Env(), message).ThrowAsJavaScriptException();
97154
}
98155

@@ -187,6 +244,8 @@ void ThrowDefaultError(const CallbackInfo& info) {
187244
Error::Fatal("ThrowDefaultError", "napi_get_named_property");
188245
}
189246

247+
ReleaseAndWaitForChildProcess(info, 1);
248+
190249
// The macro creates a `Napi::Error` using the factory that takes only the
191250
// env, however, it heeds the exception mechanism to be used.
192251
NAPI_THROW_IF_FAILED_VOID(env, status);
@@ -209,5 +268,8 @@ Object InitError(Env env) {
209268
Function::New(env, CatchAndRethrowErrorThatEscapesScope);
210269
exports["throwFatalError"] = Function::New(env, ThrowFatalError);
211270
exports["throwDefaultError"] = Function::New(env, ThrowDefaultError);
271+
exports["resetPromises"] = Function::New(env, ResetPromises);
272+
exports["waitForWorkerThread"] = Function::New(env, WaitForWorkerThread);
273+
exports["releaseWorkerThread"] = Function::New(env, ReleaseWorkerThread);
212274
return exports;
213275
}

test/error_terminating_environment.js

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use strict';
2+
const buildType = process.config.target_defaults.default_configuration;
3+
const assert = require('assert');
4+
5+
// These tests ensure that Error types can be used in a terminating
6+
// environment without triggering any fatal errors.
7+
8+
if (process.argv[2] === 'runInChildProcess') {
9+
const binding_path = process.argv[3];
10+
const index_for_test_case = Number(process.argv[4]);
11+
12+
const binding = require(binding_path);
13+
14+
// Use C++ promises to ensure the worker thread is terminated right
15+
// before running the testable code in the binding.
16+
17+
binding.error.resetPromises()
18+
19+
const { Worker } = require('worker_threads');
20+
21+
const worker = new Worker(
22+
__filename,
23+
{
24+
argv: [
25+
'runInWorkerThread',
26+
binding_path,
27+
index_for_test_case,
28+
]
29+
}
30+
);
31+
32+
binding.error.waitForWorkerThread()
33+
34+
worker.terminate();
35+
36+
binding.error.releaseWorkerThread()
37+
38+
return;
39+
}
40+
41+
if (process.argv[2] === 'runInWorkerThread') {
42+
const binding_path = process.argv[3];
43+
const index_for_test_case = Number(process.argv[4]);
44+
45+
const binding = require(binding_path);
46+
47+
switch (index_for_test_case) {
48+
case 0:
49+
binding.error.throwJSError('test', true);
50+
break;
51+
case 1:
52+
binding.error.throwTypeError('test', true);
53+
break;
54+
case 2:
55+
binding.error.throwRangeError('test', true);
56+
break;
57+
case 3:
58+
binding.error.throwDefaultError(false, true);
59+
break;
60+
case 4:
61+
binding.error.throwDefaultError(true, true);
62+
break;
63+
default: assert.fail('Invalid index');
64+
}
65+
66+
assert.fail('This should not be reachable');
67+
}
68+
69+
test(`./build/${buildType}/binding.node`, true);
70+
test(`./build/${buildType}/binding_noexcept.node`, true);
71+
test(`./build/${buildType}/binding_swallowexcept.node`, false);
72+
test(`./build/${buildType}/binding_swallowexcept_noexcept.node`, false);
73+
74+
function test(bindingPath, process_should_abort) {
75+
const number_of_test_cases = 5;
76+
77+
for (let i = 0; i < number_of_test_cases; ++i) {
78+
const child_process = require('./napi_child').spawnSync(
79+
process.execPath,
80+
[
81+
__filename,
82+
'runInChildProcess',
83+
bindingPath,
84+
i,
85+
]
86+
);
87+
88+
if (process_should_abort) {
89+
assert(child_process.status !== 0, `Test case ${bindingPath} ${i} failed: Process exited with status code 0.`);
90+
} else {
91+
assert(child_process.status === 0, `Test case ${bindingPath} ${i} failed: Process status ${child_process.status} is non-zero`);
92+
}
93+
}
94+
}

test/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ if (napiVersion < 6) {
110110

111111
if (majorNodeVersion < 12) {
112112
testModules.splice(testModules.indexOf('objectwrap_worker_thread'), 1);
113+
testModules.splice(testModules.indexOf('error_terminating_environment'), 1);
113114
}
114115

115116
if (napiVersion < 8) {

0 commit comments

Comments
 (0)