From a67b20ad3b40d5be240daa5afeecdccfd9ecf9d3 Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Tue, 15 Sep 2026 23:44:36 +0300 Subject: [PATCH] Pin the native module in memory so worker thread exit cannot unload it Node.js unloads a native addon when the worker thread that loaded it exits (Environment::~Environment closes every addon a non-main thread loaded), unless another environment still holds the library open. This addon owns process-wide threads that outlive any single environment: the tokio runtime behind the synchronous API and the query timeout thread. On Windows, FreeLibrary() unmaps the DLL underneath them and the next instruction they execute faults with an access violation. This is most of the intermittent 'Test bindings on x86_64-pc-windows-msvc - node@22' failure: ava runs each test file in a worker thread, and the crash showed up as a bare exit code 1 because pwsh reports every abnormal exit that way. Running the suite under cdb caught the faulting thread at , and pinning drops the failure rate from about 20% to about 4% of runs. Pin the library on first use of either process-wide thread: GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN) on Windows and a RTLD_NODELETE dlopen() of our own image elsewhere. The remaining 4% is a double sqlite3_close_v2() in libsql itself: LibsqlConnection::drop and local::Connection::drop both call disconnect() on the same handle, and the second call reads the freed sqlite3 struct. Verified by vendoring libsql with an idempotent disconnect(): 0 crashes in 100 runs. That fix belongs upstream. --- Cargo.toml | 3 +++ src/lib.rs | 60 +++++++++++++++++++++++++++++++++++++++++++- src/query_timeout.rs | 5 +++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2802723..a9c8a76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ tokio = { version = "1.47.1", features = [ "rt-multi-thread" ] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] ntest = "0.9" tokio = { version = "1.47.1", features = ["test-util", "macros"] } diff --git a/src/lib.rs b/src/lib.rs index 16b0e89..50cb6e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1547,10 +1547,68 @@ impl Record { fn runtime() -> Result<&'static Runtime> { static RUNTIME: OnceCell = OnceCell::new(); - let rt = RUNTIME.get_or_try_init(Runtime::new).unwrap(); + let rt = RUNTIME + .get_or_try_init(|| { + pin_module_in_memory(); + Runtime::new() + }) + .unwrap(); Ok(rt) } +/// Keep this library mapped for the lifetime of the process. +/// +/// Node.js unloads a native addon when the worker thread that loaded it exits +/// (`Environment::~Environment` closes every addon a non-main thread loaded), +/// unless something else still holds the library open. This addon owns +/// process-wide threads that outlive any single environment: the tokio runtime +/// behind the synchronous API and the query timeout thread. If the library is +/// unmapped underneath them, they fault on the next instruction they execute, +/// which is how `ava` (test files run in worker threads) crashed with an access +/// violation on Windows. Every process-wide thread must be spawned after this +/// has run. +pub(crate) fn pin_module_in_memory() { + static PIN: std::sync::Once = std::sync::Once::new(); + PIN.call_once(|| { + let addr_in_module = pin_module_in_memory as *const (); + + #[cfg(windows)] + unsafe { + #[link(name = "kernel32")] + extern "system" { + fn GetModuleHandleExW( + flags: u32, + module_name: *const u16, + module: *mut *mut std::ffi::c_void, + ) -> i32; + } + const GET_MODULE_HANDLE_EX_FLAG_PIN: u32 = 0x1; + const GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS: u32 = 0x4; + let mut module = std::ptr::null_mut(); + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_PIN | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + addr_in_module as *const u16, + &mut module, + ); + } + + #[cfg(unix)] + unsafe { + let mut info: libc::Dl_info = std::mem::zeroed(); + if libc::dladdr(addr_in_module as *const libc::c_void, &mut info) != 0 + && !info.dli_fname.is_null() + { + // Re-open our own image with RTLD_NODELETE so dlclose() never + // unmaps it. The handle is intentionally leaked. + libc::dlopen( + info.dli_fname, + libc::RTLD_NOW | libc::RTLD_NOLOAD | libc::RTLD_NODELETE, + ); + } + } + }); +} + fn map_row( env: &Env, column_names: &[std::ffi::CString], diff --git a/src/query_timeout.rs b/src/query_timeout.rs index 2b9cd55..f50a855 100644 --- a/src/query_timeout.rs +++ b/src/query_timeout.rs @@ -88,7 +88,10 @@ impl QueryTimeoutManager { /// Returns the process-wide timeout manager, spawning its single /// background thread on first use. pub fn global() -> &'static QueryTimeoutManager { - GLOBAL.get_or_init(QueryTimeoutManager::new) + GLOBAL.get_or_init(|| { + crate::pin_module_in_memory(); + QueryTimeoutManager::new() + }) } pub fn new() -> Self {