Repository navigation
Intermittent segfault (0xC0000005) during process teardown on Windows with file::memory:?cache=shared under concurrent processes #231
Description
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 exitingfor 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
donedb.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.
unhandledRejectionanduncaughtExceptionhandlers 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 atexitwithout 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/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-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
exithandler observes code0. - 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.
Activity
lucaspaniago commentedon Sep 21, 2026
Same crash here, and I can add a few data points that may narrow it down: it is not specific to
file::memory:?cache=shared. It reproduces with a plain file-backed database, with no ORM, and callingclient.close()does not prevent it.Environment
libsql@libsql/win32-x64-msvc@libsql/clientNot reproducible on macOS (arm64) with the same code.
Minimal reproduction (file-backed, client closed)
Run it 1000 times, 2 processes at a time, and count exit codes. Every crashing run prints
REPRO_OKfirst, has an empty stderr, and then exits with3221225477(0xC0000005).Results (1000 runs per variant, 2 concurrent processes)
0xC0000005)@libsql/clientonly (script above)node:sqliteonly (control)@libsql/client+node:sqliteon the same fileclose()So the crash follows the libsql native binding;
node:sqlitedoing the same work never crashed, and closing or not closing the client makes no measurable difference.In a real test suite (each file opens ~18 temporary databases) the per-file rate is higher, 2–5 %, which matches the "different file goes red each time" pattern described above.
Two things I tried that did not fix it
--expose-gc, threegc()+setImmediaterounds in a rootafter()hook): made it worse in my measurements — 16/300 crashes vs 6/300 without it, same direction in a second smaller sample (7/100 vs 2/100). That suggests the native finalizers themselves are involved, rather than merely running too late.process.reallyExit(code)from anexitlistener): inconclusive — 18/300 vs 14/300 baseline on one test file, but 1/300 vs 6/300 on another.Note for anyone measuring this
node --test <file>runs the file in a subprocess, so the parent exits with code1and the native crash is only visible asexitCode: 3221225477inside the TAP output. Running the file directly (node file.test.js) exposes the real exit code.Happy to run further experiments on this Windows machine (e.g. a debug build, or collecting a crash dump) if that helps.
ci: run a package's tests again when libsql crashes its worker on Win…
maritonx commentedon Oct 17, 2026
Another data point from CI, in case it helps narrow this down.
windows-latest, Node 22 and 24,libsql0.5.29,@libsql/client0.18.0, no ORM in the affected packages' tests.forkspool, so each test file runs in a child process that exits when the file is done.Worker exited unexpectedly … during started state) in a file whose tests had all passed. It hit three different packages, and never macOS or Linux.client.close()) before the file ends.We now rerun a package once when its worker exits with that code: https://github.com/maritonx/easy-cms/blob/main/.github/scripts/test-retry-crashes.mjs
hyunjun6928-netizen commentedon Oct 19, 2026
Same crash here, reduced to plain
libsqlwith nothing else: in-memory databases that are never closed. No@libsql/client, ORM, shared cache, file or SQL statement is needed. The process exits with3221225477(0xC0000005) and an empty stderr when those databases are finalized, after a garbage collection or at exit.This matches the double
sqlite3_close_v2in tursodatabase/libsql#2251 ("root cause 2" in #235). Its fix, tursodatabase/libsql#2261, is in thelibsqlcrate 0.10.0 (2026-10-13), but no libsql-js release has it yet. The crate paths embedded in the Windows binaries show@libsql/win32-x64-msvc0.5.29 built with libsql 0.9.30 (Neon 1.0.0) and 0.6.0-pre.43 with libsql 0.10.0-pre.4 (napi 2.16.17), and both crash below. I have no Rust toolchain on this machine, so I could not build against 0.10.0 to confirm the fix.Environment
libsql@libsql/win32-x64-msvc0.5.29, and 0.6.0-pre.43 (install note at the end)@libsql/clientSmallest repro
min.mjs:A single run usually exits 0, so run it many times and count the exit codes:
node count.mjs 1000 16 min.mjs(count.mjsis at the end). Crashed runs in two batches of 1000, 16 at a time (every other run exited 0):Variants
repro.mjsopens N in-memory databases (12 by default), then either drops them and runsgc(), or keeps them until a natural exit. It writes marks to stderr synchronously, so a crashed run shows where it died: in thegc()loop (afteropened, beforecollected), or after the'exit'event, in Node's environment teardown.Crashed runs, 16 processes at a time. Every failed run exited with
0xC0000005, and its stderr held only the marks; "gc" and "exit" say where it died. Rows withoutREPRO_KEEP=1run asnode --expose-gc repro.mjs.gc()(defaults)REPRO_KEEP=1gc():REPRO_SQL=1REPRO_SQL=1 REPRO_KEEP=1gc():REPRO_DBS=2gc():REPRO_DBS=50gc():REPRO_DBS=200REPRO_DBS=200 REPRO_KEEP=1gc():REPRO_DBS=1REPRO_DBS=1 REPRO_KEEP=1REPRO_CLOSE=1REPRO_SQL=1 REPRO_CLOSE=1REPRO_DBS=200 REPRO_CLOSE=1REPRO_DBS=200 REPRO_SQL=1 REPRO_CLOSE=1reallyExitin'exit':REPRO_DBS=200 REPRO_KEEP=1 REPRO_REALLY_EXIT=1gc(),reallyExitin'exit':REPRO_DBS=200 REPRO_REALLY_EXIT=1More databases means more crashes, and a single database never crashed. Closing each database before it is dropped avoided the crash in every setting, with or without SQL.
process.reallyExit(0)inside'exit', which skips Node's environment teardown, removed the crashes at exit (153 to 0 of 1000 with 200 databases kept) but not the ones ingc()(181 of 1000 left), so it happens wherever the finalizers of unclosed databases run. One difference between the versions: 12 databases kept to a natural exit never crashed on 0.5.29 but did on 0.6.0-pre.43.Through
@libsql/client0.18.012
createClient({url: ':memory:'}), each with a table, an insert and a select, left open:process.reallyExit(0)inside'exit'In our own test suite (node:test, about 140 files with in-memory
@libsql/clientdatabases), the two files that failed most often went from 5 crashed runs in 480 to 0 once each test closed its databases when it ended. Closing all of a file's databases together at its end was not enough: 6 of 360 runs still crashed inside the lastclose(), which fits a double close that also runs fromclose(). Closing each database as soon as it is done with is the workaround that held for us.Ask
Could the 0.6 line pick up libsql 0.10.0, and could tursodatabase/libsql#2261 also go into a 0.5.x release?
@libsql/client0.18.0 depends onlibsql ^0.5.28, so 0.5.29 is what most users get.count.mjs0.6.0-pre.43 on Windows:
npm i libsql@0.6.0-pre.43installs no native binary there, andrequire('libsql')throwsCannot find module 'libsql-win32-x64-msvc'. That optional dependency is not on npm; the binary is published as@libsql/win32-x64-msvc@0.6.0-pre.43. I installed it withnpm i libsql-win32-x64-msvc@npm:@libsql/win32-x64-msvc@0.6.0-pre.43.