tursodatabase / tursodatabase/libsql-js
Intermittent segfault (0xC0000005) during process teardown on Windows with `file::memory:?cache=shared` under concurrent processes
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 335
- Forks
- 48
- Avg merge
- 3d 2h
- Merged PRs (30d)
- 3
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 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.
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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running the reduced child.mjs reproduction with the db.js client and shared-cache URL under concurrent Windows processes. Compare runs with and without client.close() in beforeExit, then investigate the native teardown or N-API finalizer path; done means identifying the crashing native resource or providing a deterministic pre-exit release that eliminates the access violation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js, sqlite
- Domain
- backend, databases, operating-systems
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100