Skip to content

Intermittent segfault (0xC0000005) during process teardown on Windows with file::memory:?cache=shared under concurrent processes #231

Description

@ariasr47

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:

  1. Escaping promise rejection. unhandledRejection and uncaughtException handlers armed across ~50 full runs: zero fired.
  2. 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).
  3. A version regression. See the version list above; 0.14.0 crashed on its first run.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions