Summary
On Windows, a Node process that has used a file::memory:?cache=shared libsql database intermittently dies with an access violation during teardown — after all JavaScript work has completed and after process.on("exit") handlers have already run. The process reports exit code 3221225477 (0xC0000005, STATUS_ACCESS_VIOLATION); spawned from a POSIX shell the same crash surfaces as SIGSEGV / exit 139.
Because it happens after JS shutdown, nothing observable from JavaScript sees it: no unhandledRejection, no uncaughtException, and an exit handler installed in the same process reports code 0 immediately before the crash.
It is concurrency-sensitive: it grows more frequent with more simultaneous processes and does not disappear at low concurrency.
Environment
|
|
| OS |
Windows 11 Pro, 10.0.26200.9168, x64 (32 logical cores) |
| Node |
v26.7.0 |
libsql |
0.5.29 |
@libsql/win32-x64-msvc |
0.5.29 |
@libsql/client |
0.15.15 |
drizzle-orm |
0.45.2 |
Not a recent regression. Reproduced on @libsql/client 0.15.15, 0.15.4, and 0.14.0 at comparable rates, so it spans at least the 0.14 → 0.15 range.
How it shows up
The original symptom was a test suite (node --test, --test-concurrency=4, ~160 files) going red roughly 10–15% of runs, on a different file each time. In every case the file's own assertions all passed; only the file-level result failed. Node's TAP reporter carries the distinguishing detail:
not ok 76 - tests\rank-truth\write-table.test.ts
---
duration_ms: 522.4409
type: 'test'
failureType: 'testCodeFailure'
exitCode: 3221225477
signal: ~
error: 'test failed'
code: 'ERR_TEST_FAILURE'
...
A genuine assertion failure carries code: 'ERR_ASSERTION' and no exitCode, so the two are cleanly distinguishable.
Reproduction
Reduced to this, which crashes roughly 1 run in 48 with 4 processes at a time:
// child.mjs — run several of these concurrently, repeatedly
const { ensureDb, getDb } = await import("./db.js"); // creates the schema, wraps the client in drizzle
await ensureDb();
const db = getDb();
await db.select().from(someTable).limit(5);
// client deliberately not closed — matches a worker process exiting
for r in $(seq 1 12); do
for c in 1 2 3 4; do node child.mjs & done
wait # any nonzero exit here is the crash
done
db.js is a thin module that creates one client with createClient({ url: "file::memory:?cache=shared" }), caches it on globalThis, executes a multi-statement schema (~34 CREATE TABLE IF NOT EXISTS plus indexes) once, and wraps it with drizzle().
What I ruled out, with measurements
These are recorded because each one narrows where the fault is not:
- Escaping promise rejection.
unhandledRejection and uncaughtException handlers armed across ~50 full runs: zero fired.
- The client never being closed. Closing the cached client explicitly on
beforeExit — verified to actually execute, by confirming the client is present at exit without the change and absent with it — did not change the rate (2 crashed runs / 16 vs 2 / 14 baseline).
- A version regression. See the version list above; 0.14.0 crashed on its first run.
- Pure
@libsql/client with a comparable workload. A script that creates the same file::memory:?cache=shared client, issues ~34 CREATE TABLE + 68 CREATE INDEX statements via executeMultiple, runs a write transaction and a read, and exits without closing did not crash in 60 runs at the same concurrency. A simpler version (50 inserts + one transaction) did not crash in 160 runs.
Point 4 is the interesting one: the crash needs something beyond the raw client usage. In my reproduction the additional ingredients are drizzle-orm wrapping the client and a schema-creation path that runs before it. I was not able to reduce it further, so the pure-client negative result may just mean I have not found the right shape rather than that the client is uninvolved.
Why I think it is native teardown
- All JavaScript completes successfully; the process's own
exit handler observes code 0.
- The failing exit code is only visible to the parent process.
- No JS-level handler can intercept it.
- It is timing/contention sensitive rather than deterministic.
That pattern points at a native destructor or N-API finalizer running after JS shutdown, likely racing on the shared-cache in-memory database, but I have not captured a native stack trace and cannot name the frame.
What would help
If there is a supported way to deterministically release the native handle before process exit (beyond client.close(), which did not change the rate), that would be a usable workaround. Happy to run instrumented builds or capture a crash dump on this machine if that is useful.
Summary
On Windows, a Node process that has used a
file::memory:?cache=sharedlibsql database intermittently dies with an access violation during teardown — after all JavaScript work has completed and afterprocess.on("exit")handlers have already run. The process reports exit code3221225477(0xC0000005,STATUS_ACCESS_VIOLATION); spawned from a POSIX shell the same crash surfaces asSIGSEGV/ exit 139.Because it happens after JS shutdown, nothing observable from JavaScript sees it: no
unhandledRejection, nouncaughtException, and anexithandler installed in the same process reports code0immediately before the crash.It is concurrency-sensitive: it grows more frequent with more simultaneous processes and does not disappear at low concurrency.
Environment
libsql@libsql/win32-x64-msvc@libsql/clientdrizzle-ormNot a recent regression. Reproduced on
@libsql/client0.15.15, 0.15.4, and 0.14.0 at comparable rates, so it spans at least the 0.14 → 0.15 range.How it shows up
The original symptom was a test suite (
node --test,--test-concurrency=4, ~160 files) going red roughly 10–15% of runs, on a different file each time. In every case the file's own assertions all passed; only the file-level result failed. Node's TAP reporter carries the distinguishing detail:A genuine assertion failure carries
code: 'ERR_ASSERTION'and noexitCode, so the two are cleanly distinguishable.Reproduction
Reduced to this, which crashes roughly 1 run in 48 with 4 processes at a time:
db.jsis a thin module that creates one client withcreateClient({ url: "file::memory:?cache=shared" }), caches it onglobalThis, executes a multi-statement schema (~34CREATE TABLE IF NOT EXISTSplus indexes) once, and wraps it withdrizzle().What I ruled out, with measurements
These are recorded because each one narrows where the fault is not:
unhandledRejectionanduncaughtExceptionhandlers armed across ~50 full runs: zero fired.beforeExit— verified to actually execute, by confirming the client is present atexitwithout the change and absent with it — did not change the rate (2 crashed runs / 16 vs 2 / 14 baseline).@libsql/clientwith a comparable workload. A script that creates the samefile::memory:?cache=sharedclient, issues ~34CREATE TABLE+ 68CREATE INDEXstatements viaexecuteMultiple, runs a write transaction and a read, and exits without closing did not crash in 60 runs at the same concurrency. A simpler version (50 inserts + one transaction) did not crash in 160 runs.Point 4 is the interesting one: the crash needs something beyond the raw client usage. In my reproduction the additional ingredients are
drizzle-ormwrapping the client and a schema-creation path that runs before it. I was not able to reduce it further, so the pure-client negative result may just mean I have not found the right shape rather than that the client is uninvolved.Why I think it is native teardown
exithandler observes code0.That pattern points at a native destructor or N-API finalizer running after JS shutdown, likely racing on the shared-cache in-memory database, but I have not captured a native stack trace and cannot name the frame.
What would help
If there is a supported way to deterministically release the native handle before process exit (beyond
client.close(), which did not change the rate), that would be a usable workaround. Happy to run instrumented builds or capture a crash dump on this machine if that is useful.