Skip to content

Commit b2936fe

Browse files
committed
switched back to disabled cpp exceptions
1 parent 058e03f commit b2936fe

9 files changed

Lines changed: 197 additions & 71 deletions

File tree

binding.gyp

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"include_dirs": [
1717
"<!@(node -p \"require('node-addon-api').include\")"],
1818
"dependencies": [
19-
"<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except"
19+
"<!(node -p \"require('node-addon-api').targets\"):node_addon_api"
2020
],
2121
"conditions": [
2222
["sqlite != 'internal'", {
@@ -55,7 +55,6 @@
5555
["OS=='win'", {
5656
"msvs_settings": {
5757
"VCCLCompilerTool": {
58-
"ExceptionHandling": 1,
5958
"BufferSecurityCheck": "true",
6059
"ControlFlowGuard": "Guard"
6160
},

memory-bank/decisionLog.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,17 +214,30 @@ if (locked && pending == before_pending) {
214214
215215
---
216216
217-
### 2026-03-29: NAPI Exception Handling
217+
### 2026-04-25: NAPI Exception Handling — Switched to NAPI_DISABLE_CPP_EXCEPTIONS
218218
219-
**Decision**: Use `node_addon_api_except` instead of `NAPI_DISABLE_CPP_EXCEPTIONS=1`
219+
**Decision**: Use `node_addon_api` (NAPI_DISABLE_CPP_EXCEPTIONS mode) instead of `node_addon_api_except`
220220
221221
**Rationale**:
222-
- Commit 48e95e8a0d32277449c269b41fba6419acb21418 changed the build configuration
223-
- Using `node_addon_api_except` from node-addon-api provides proper exception handling support
224-
- This is the recommended approach for modern node-addon-api versions
222+
- The previous decision (2026-03-29) to use `node_addon_api_except` caused uncatchable C++ exceptions that crashed the process with SIGABRT
223+
- `TRY_CATCH_CALL`'s `try { callback.Call() } catch (Napi::Error& e) { throw; }` re-threw C++ exceptions from within async `Work_After*` callbacks where there was no C++ catch handler on the stack → `std::terminate()` → `abort()`
224+
- With `NAPI_DISABLE_CPP_EXCEPTIONS=1`, `Napi::Error` is never thrown as a C++ exception — it's just a JavaScript value, so all errors are catchable from JS
225+
- The `node_addon_api` target includes `noexcept.gypi` which defines `NODE_ADDON_API_DISABLE_CPP_EXCEPTIONS` and `-fno-exceptions`
226+
- No explicit `NAPI_DISABLE_CPP_EXCEPTIONS=1` define needed in `binding.gyp` — the dependency handles it
227+
228+
**Additional fixes**:
229+
- Removed dead code: `TRY_CATCH_CALL` try/catch block and `throw;`, `g_env_shutting_down` mechanism
230+
- Initialized `retryErrors` in C++ `Backup::Backup()` constructor with `[SQLITE_BUSY, SQLITE_LOCKED]` to prevent `FATAL ERROR: Error::New napi_get_last_error_info` when `retryErrors.Value()` is called on an empty `Napi::Reference<Array>`
225231
226232
**Files Changed**:
227-
- `binding.gyp`: Changed from `node_addon_api` to `node_addon_api_except` dependency
233+
- `binding.gyp`: Changed dependency from `node_addon_api_except` to `node_addon_api`
234+
- `src/macros.h`: Removed `#include <atomic>`, `g_env_shutting_down`, and try/catch from `TRY_CATCH_CALL`
235+
- `src/node_sqlite3.cc`: Removed `g_env_shutting_down`, `EnvCleanupHook`, `napi_add_env_cleanup_hook`
236+
- `src/backup.cc`: Initialize `retryErrors` in constructor
237+
- `test/uncatchable-exceptions.test.js`: Integration tests for both fixed scenarios
238+
- `test/uncatchable-scenarios/`: Child process crash reproduction scripts
239+
240+
**Supersedes**: 2026-03-29 decision to use `node_addon_api_except`
228241
229242
---
230243

memory-bank/issues.md

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,49 @@
11
# issues
22

3-
## Uncatchable Native Addon Exceptions (C++ → Napi::Error)
3+
## Uncatchable Native Addon Exceptions (C++ → Napi::Error) — FIXED
44

5-
Several operations on the native addon throw `Napi::Error` instances from C++ code that **cannot be caught by JavaScript `try/catch` or `Promise.catch()`**. These exceptions propagate as C++ exceptions through the N-API layer and terminate the process with an abort signal.
5+
**Status**: Fixed as of 2026-04-25. Switched from `node_addon_api_except` to `node_addon_api` (NAPI_DISABLE_CPP_EXCEPTIONS mode), removed dead `TRY_CATCH_CALL` try/catch and `g_env_shutting_down`, and initialized `retryErrors` in the C++ Backup constructor.
66

7-
### Known uncatchable scenarios
7+
### Previously uncatchable scenarios (now catchable)
88

9-
#### 1. `Backup.step()` after `Backup.finish()`
9+
#### 1. `Backup.step()` after `Backup.finish()` — FIXED
1010

11-
Calling `step()` on a backup handle that has already been finished throws:
12-
```
13-
terminate called after throwing an instance of 'Napi::Error'
14-
what(): SQLITE_MISUSE: Backup is already finished
15-
Aborted (core dumped)
16-
```
11+
Previously crashed with `FATAL ERROR: Error::New napi_get_last_error_info` in `GetRetryErrors()` because `retryErrors` (`Napi::Reference<Array>`) was never initialized in the C++ constructor — it was only set by the JS wrapper in `Database.prototype.backup()`. Calling `retryErrors.Value()` on an empty reference caused a N-API fatal error.
1712

18-
**Affected code paths**:
19-
- `lib/promise/backup.js` line 97-99: The `if (!this._backup)` guard only checks for `null`, but after `finish()` the native handle is in a "finished" state that still exists but is invalid
20-
- The callback API (`lib/sqlite3-callback.js`) has the same issue — `backup.step()` after `backup.finish()` is uncatchable
13+
**Fix**: Initialize `retryErrors` in the C++ `Backup::Backup()` constructor with default values `[SQLITE_BUSY, SQLITE_LOCKED]`, matching what the JS wrapper sets. Now `Backup.step()` after `Backup.finish()` returns a normal JS error: `SQLITE_MISUSE: Backup is already finished`.
2114

22-
**Workaround in promise wrapper**: The `SqliteBackup.step()` method checks `if (!this._backup)` before calling the native `step()`, but this only catches the case where `_backup` is explicitly set to `null`. After `finish()`, the `_backup` reference is still non-null but the native handle is invalid. Setting `_backup = null` in `finish()` would prevent the crash but would change the semantics of the `idle`/`completed`/`failed` getters after finish.
15+
**Test**: `test/uncatchable-scenarios/backup-step-after-finish.js` — exit code 0 with "OK: got expected error".
2316

24-
**Test impact**: Cannot write integration tests for `step()` after `finish()` — the process aborts before Mocha can report the failure. Unit tests with mocks are used instead (see `test/promise.unit.test.js`).
17+
#### 2. JS callback throw inside Work_After* async callback — FIXED
2518

26-
#### 2. Statement operations after `Database.close()`
19+
Previously, `TRY_CATCH_CALL`'s `try { callback.Call() } catch (Napi::Error& e) { throw; }` re-threw C++ exceptions from within async `Work_After*` callbacks where there was no C++ catch handler on the stack, causing `std::terminate()``abort()`.
2720

28-
Calling methods on a `Statement` after its parent `Database` has been closed throws uncatchable Napi::Error:
29-
```
30-
terminate called after throwing an instance of 'Napi::Error'
31-
what(): The expression evaluated to a falsy value: assert(err)
32-
Aborted (core dumped)
33-
```
21+
**Fix**: Switched to `NAPI_DISABLE_CPP_EXCEPTIONS=1` (via `node_addon_api` dependency). With exceptions disabled, `Napi::Error` is never thrown as a C++ exception — it's just a JavaScript value. Also removed the dead `try/catch` and `throw;` from `TRY_CATCH_CALL` macro.
3422

35-
**Affected operations**: `Statement.map()`, `Statement.all()`, `Statement.get()`, `Statement.run()`, `Statement.each()`, `Statement.finalize()`, `Statement.reset()`, `Statement.bind()`
23+
**Test**: `test/uncatchable-scenarios/stmt-callback-throws.js` — exit code 1 (normal JS uncaught exception) instead of 134 (SIGABRT).
3624

37-
**Workaround**: Always finalize all statements before closing the database. The callback API's `Database.close()` returns `SQLITE_BUSY` error if unfinalized statements exist, but if you force-close and then use a statement, the crash is uncatchable.
25+
#### 3. Statement operations after `Database.close()` — Already catchable
3826

39-
**Test impact**: Cannot test `Statement.prototype.map` error path by closing the database first. Unit tests with stubs are used instead (see `test/callback-branches.test.js`).
27+
`Statement::Schedule()` calls `CleanQueue()` synchronously on the main JS thread, where `InstanceMethodCallbackWrapper` catches the `Napi::Error` and converts it to a JS exception. This scenario returns a normal JS error: `SQLITE_MISUSE: Statement is already finalized`.
4028

41-
#### 3. `verbose.test.js` assertion failure after `verbose()` called
29+
#### 4. `verbose.test.js` assertion failure after `verbose()` called
4230

43-
When `sqlite3.verbose()` has been called globally (setting `isVerbose = true`), the test "Should not add trace info to error when verbose is not called" in `verbose.test.js` will fail because the error stack DOES contain trace info. The `resetVerbose()` function restores original methods but does NOT reset the `isVerbose` flag. The assertion `err.stack.indexOf(invalid_sql) === -1` fails, and the Napi::Error from the assertion propagates as an uncatchable C++ exception.
31+
When `sqlite3.verbose()` has been called globally (setting `isVerbose = true`), the test "Should not add trace info to error when verbose is not called" in `verbose.test.js` will fail because the error stack DOES contain trace info. The `resetVerbose()` function restores original methods but does NOT reset the `isVerbose` flag.
4432

4533
**Workaround**: The `verbose()` idempotency test was placed as the LAST test in `verbose.test.js` to avoid contaminating the "not called" test. Test ordering matters for this file.
4634

47-
### Root cause
35+
### Root cause (historical)
4836

49-
These are C++ exceptions thrown via `Napi::Error` that propagate through the N-API boundary. JavaScript `try/catch` only catches JavaScript exceptions. When a C++ exception reaches the N-API boundary without being caught by a `Napi::TryCatch` block on the C++ side, it triggers `std::terminate()` which calls `abort()`.
37+
With `NAPI_CPP_EXCEPTIONS` enabled, `Napi::Error` was thrown as a C++ exception. The `TRY_CATCH_CALL` macro caught it and re-threw with `throw;`, but from within async `Work_After*` callbacks where there was no C++ catch handler on the stack → `std::terminate()` `abort()`. Additionally, `Backup::GetRetryErrors()` crashed on an uninitialized `Napi::Reference<Array>`.
5038

51-
### Design implications
39+
### Changes made
5240

53-
1. **Promise wrappers must guard against invalid states** before calling native methods, since native errors cannot be caught and converted to rejected promises
54-
2. **Test coverage for error paths** in native code must use mocks/stubs rather than trying to trigger actual native errors
55-
3. **Global state changes** (like `verbose()`) must be carefully managed in test suites to avoid cross-test contamination
41+
1. **`binding.gyp`**: Changed dependency from `node_addon_api_except` to `node_addon_api` (includes `noexcept.gypi` which defines `NODE_ADDON_API_DISABLE_CPP_EXCEPTIONS` and `-fno-exceptions`)
42+
2. **`src/macros.h`**: Removed `#include <atomic>`, `extern std::atomic<bool> g_env_shutting_down`, and the `try/catch` + `throw;` from `TRY_CATCH_CALL`
43+
3. **`src/node_sqlite3.cc`**: Removed `#include <atomic>`, `g_env_shutting_down` variable, `EnvCleanupHook`, and `napi_add_env_cleanup_hook` call
44+
4. **`src/backup.cc`**: Initialize `retryErrors` in `Backup::Backup()` constructor with `[SQLITE_BUSY, SQLITE_LOCKED]`
45+
5. **`test/uncatchable-exceptions.test.js`**: Integration tests that verify both scenarios now produce catchable JS errors
46+
6. **`test/uncatchable-scenarios/`**: Child process scripts for crash reproduction
5647

5748
---
5849

src/backup.cc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ Backup::Backup(const Napi::CallbackInfo& info) : Napi::ObjectWrap<Backup>(info)
173173
baton->filenameIsDest = filenameIsDest.Value();
174174

175175
this->db->Schedule(Work_BeginInitialize, baton);
176+
177+
// Initialize retryErrors with default values so GetRetryErrors() never
178+
// crashes on an empty Napi::Reference, even if the JS wrapper that sets
179+
// backup.retryErrors is bypassed (i.e. direct new sqlite3.Backup(...)).
180+
Napi::Array defaultRetryErrors = Napi::Array::New(env, 2);
181+
defaultRetryErrors.Set(static_cast<uint32_t>(0), Napi::Number::New(env, SQLITE_BUSY));
182+
defaultRetryErrors.Set(static_cast<uint32_t>(1), Napi::Number::New(env, SQLITE_LOCKED));
183+
retryErrors.Reset(defaultRetryErrors, 1);
176184
}
177185

178186
void Backup::Work_BeginInitialize(Database::Baton* baton) {

src/macros.h

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,10 @@
44
const char* sqlite_code_string(int code);
55
const char* sqlite_authorizer_string(int type);
66
#include <vector>
7-
#include <atomic>
87

98
// TODO: better way to work around StringConcat?
109
#include <napi.h>
1110

12-
// Forward declaration of shutdown flag from node_sqlite3.cc
13-
extern std::atomic<bool> g_env_shutting_down;
14-
1511
inline Napi::String StringConcat(Napi::Value str1, Napi::Value str2) {
1612
return Napi::String::New(str1.Env(), str1.As<Napi::String>().Utf8Value() +
1713
str2.As<Napi::String>().Utf8Value() );
@@ -133,21 +129,8 @@ inline bool OtherIsInt(Napi::Number source) {
133129
if ((argc != 0) && (passed_argv != NULL)) {\
134130
args.assign(passed_argv, passed_argv + argc);\
135131
}\
136-
try {\
137-
Napi::Value res = (callback).Call(Napi::Value(context), args);\
138-
if (res.IsEmpty()) return __VA_ARGS__;\
139-
} catch (const Napi::Error&) {\
140-
/* During Node.js/Electron shutdown, when using NAPI_CPP_EXCEPTIONS,*/\
141-
/* we must take care to not throw any exceptions. */\
142-
/* But when napi_call_function returns napi_cannot_run_js */\
143-
/* this throws a C++ exception, when NAPI_CPP_EXCEPTIONS is enabled. */\
144-
if (g_env_shutting_down.load()) {\
145-
/* We need to silently ignore exceptions during shutdown. */\
146-
return __VA_ARGS__;\
147-
}\
148-
/* Real rror - re-throw to propagate it */\
149-
throw;\
150-
}
132+
Napi::Value res = (callback).Call(Napi::Value(context), args);\
133+
if (res.IsEmpty()) return __VA_ARGS__;
151134

152135
#define WORK_DEFINITION(name) \
153136
Napi::Value name(const Napi::CallbackInfo& info); \

src/node_sqlite3.cc

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
#include <sstream>
33
#include <cstring>
44
#include <string>
5-
#include <atomic>
65
#include <sqlite3.h>
76

87
#include "macros.h"
@@ -12,21 +11,11 @@
1211

1312
using namespace node_sqlite3;
1413

15-
// Global flag set when the environment is shutting down.
16-
std::atomic<bool> g_env_shutting_down{false};
17-
18-
static void EnvCleanupHook(void* /*data*/) {
19-
g_env_shutting_down.store(true);
20-
}
21-
2214
namespace {
2315

2416
Napi::Object RegisterModule(Napi::Env env, Napi::Object exports) {
2517
Napi::HandleScope scope(env);
2618

27-
// Register cleanup hook to detect shutdown
28-
napi_add_env_cleanup_hook(env, EnvCleanupHook, nullptr);
29-
3019
Database::Init(env, exports);
3120
Statement::Init(env, exports);
3221
Backup::Init(env, exports);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Integration tests for uncatchable native addon exceptions.
3+
*
4+
* These scenarios previously caused process aborts (SIGABRT) due to Napi::Error
5+
* C++ exceptions escaping from async Work_After* callbacks. With
6+
* NAPI_DISABLE_CPP_EXCEPTIONS=1, these are now catchable JS errors and the
7+
* child processes exit normally.
8+
*
9+
* Each test runs the dangerous operation in a child process to isolate any
10+
* potential crash from the test runner.
11+
*/
12+
13+
'use strict';
14+
15+
const assert = require('assert');
16+
const { execFile } = require('child_process');
17+
const path = require('path');
18+
19+
const CHILD_TIMEOUT = 30000;
20+
21+
function runChild(scriptPath) {
22+
return new Promise((resolve) => {
23+
execFile(
24+
process.execPath,
25+
[scriptPath],
26+
{ timeout: CHILD_TIMEOUT, maxBuffer: 1024 * 1024 },
27+
(error, stdout, stderr) => {
28+
resolve({
29+
exitCode: error ? (error.code || 1) : 0,
30+
signal: error ? (error.killed ? 'SIGKILL' : (error.signal || null)) : null,
31+
stdout: stdout || '',
32+
stderr: stderr || ''
33+
});
34+
}
35+
);
36+
});
37+
}
38+
39+
describe('Uncatchable native addon exceptions', function() {
40+
this.timeout(60000);
41+
42+
it('Backup.step() after Backup.finish() returns a catchable JS error', async function() {
43+
const script = path.join(__dirname, 'uncatchable-scenarios', 'backup-step-after-finish.js');
44+
const result = await runChild(script);
45+
46+
// Process should exit normally (exit code 0), not crash with SIGABRT
47+
assert.strictEqual(result.signal, null, 'Process should not be killed by signal');
48+
assert.strictEqual(result.exitCode, 0, 'Process should exit with code 0');
49+
assert.ok(result.stdout.includes('OK: got expected error'),
50+
`Expected success message in stdout, got: ${result.stdout}`);
51+
});
52+
53+
it('JS callback throw inside Work_After* async callback is a normal JS exception', async function() {
54+
const script = path.join(__dirname, 'uncatchable-scenarios', 'stmt-callback-throws.js');
55+
const result = await runChild(script);
56+
57+
// Process should exit with code 1 (uncaught JS exception), not SIGABRT (code 134)
58+
assert.strictEqual(result.signal, null, 'Process should not be killed by signal');
59+
assert.strictEqual(result.exitCode, 1, 'Process should exit with code 1 (uncaught exception)');
60+
assert.ok(result.stderr.includes('intentional throw from async callback'),
61+
`Expected error message in stderr, got: ${result.stderr}`);
62+
});
63+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Crash scenario: Backup.step() after Backup.finish()
3+
* With NAPI_CPP_EXCEPTIONS this causes std::terminate() → abort()
4+
*/
5+
'use strict';
6+
7+
const sqlite3 = require('../..');
8+
const path = require('path');
9+
const fs = require('fs');
10+
11+
// Ensure tmp dir exists
12+
const tmpDir = path.join(__dirname, '..', 'tmp');
13+
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
14+
15+
const db = new sqlite3.Database(':memory:', function(err) {
16+
if (err) { console.error('open error:', err.message); process.exit(1); }
17+
18+
db.run('CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)', function(err) {
19+
if (err) { console.error('create error:', err.message); process.exit(1); }
20+
21+
db.run("INSERT INTO test (value) VALUES ('hello')", function(err) {
22+
if (err) { console.error('insert error:', err.message); process.exit(1); }
23+
24+
const backup = new sqlite3.Backup(db, path.join(tmpDir, 'crash_backup.db'), 'main', 'main', true, function(err) {
25+
if (err) { console.error('backup init error:', err.message); process.exit(1); }
26+
27+
backup.step(-1, function(err) {
28+
if (err) { console.error('step error:', err.message); process.exit(1); }
29+
30+
backup.finish(function() {
31+
// Use setTimeout to call step outside the async callback context
32+
setTimeout(function() {
33+
try {
34+
backup.step(-1, function(err) {
35+
if (err) {
36+
console.log('OK: got expected error:', err.message);
37+
process.exit(0);
38+
} else {
39+
console.log('ERROR: should have errored');
40+
process.exit(1);
41+
}
42+
});
43+
} catch (e) {
44+
console.log('OK: caught synchronous error:', e.message);
45+
process.exit(0);
46+
}
47+
}, 100);
48+
});
49+
});
50+
});
51+
});
52+
});
53+
});

0 commit comments

Comments
 (0)