Skip to content

Commit b6dba81

Browse files
Rust: load in-process cdylib once per process and never unload it
Fixes a Windows STATUS_ACCESS_VIOLATION (0xc0000005) crash in the Rust e2e binary: FfiShared::Drop unloaded the cdylib (FreeLibrary/dlclose) when a connection closed, racing the runtime's still-live worker threads — a late worker-thread callback into the unmapped module faults on Windows. The crash hit both Rust transport cells because the in-process smoke test runs regardless of transport. Load each cdylib once into a process-global cache and leak it (Box::leak) so its code stays mapped for the process lifetime, matching the Node host (module-global load, never unloaded) and the runtime's never-shutdown process-global tokio runtime. close() still shuts the host down; only the library unload is removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 05f9fc5 commit b6dba81

1 file changed

Lines changed: 55 additions & 26 deletions

File tree

rust/src/ffi.rs

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
//! native callback that feeds an async reader. The framing is unchanged — the
1010
//! same LSP `Content-Length:` frames the stdio transport uses.
1111
12+
use std::collections::HashMap;
1213
use std::ffi::c_void;
1314
use std::path::{Path, PathBuf};
1415
use std::pin::Pin;
1516
use std::sync::Arc;
1617
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering};
18+
use std::sync::{Mutex, OnceLock};
1719
use std::task::{Context, Poll};
1820

1921
use libloading::Library;
@@ -56,11 +58,11 @@ extern "C" fn on_outbound(user_data: *mut c_void, bytes: *const u8, len: usize)
5658
let _ = state.tx.send(slice.to_vec());
5759
}
5860

59-
/// Loaded library, bound exports, and connection lifecycle state, shared
60-
/// between the [`FfiWriter`] and the owning [`Client`]. Kept alive for the
61-
/// connection's lifetime; dropping it unloads the cdylib.
61+
/// Bound exports and connection lifecycle state, shared between the
62+
/// [`FfiWriter`] and the owning [`Client`]. The cdylib itself is loaded
63+
/// process-globally and never unloaded (see [`load_library`]), so this holds
64+
/// only the bound fn pointers and connection state.
6265
pub(crate) struct FfiShared {
63-
_lib: Library,
6466
host_shutdown: HostShutdownFn,
6567
connection_write: ConnectionWriteFn,
6668
connection_close: ConnectionCloseFn,
@@ -185,10 +187,10 @@ impl AsyncWrite for FfiWriter {
185187
}
186188
}
187189

188-
/// Prepared FFI host: the loaded cdylib plus the spawn arguments needed to
189-
/// start the runtime worker.
190+
/// Prepared FFI host: the bound cdylib exports plus the spawn arguments needed
191+
/// to start the runtime worker. The cdylib is loaded process-globally and never
192+
/// unloaded (see [`load_library`]).
190193
pub(crate) struct FfiHost {
191-
lib: Library,
192194
library_path: PathBuf,
193195
entrypoint: PathBuf,
194196
environment: Vec<(String, String)>,
@@ -199,8 +201,8 @@ pub(crate) struct FfiHost {
199201
connection_close: ConnectionCloseFn,
200202
}
201203

202-
// SAFETY: as for `FfiShared` — the bound exports are plain fn pointers and the
203-
// library handle is safe to move to the blocking thread that starts the host.
204+
// SAFETY: as for `FfiShared` — the bound exports are plain fn pointers, safe to
205+
// move to the blocking thread that starts the host.
204206
unsafe impl Send for FfiHost {}
205207

206208
impl FfiHost {
@@ -215,29 +217,19 @@ impl FfiHost {
215217
environment: Vec<(String, String)>,
216218
) -> Result<Self, Error> {
217219
let library_path = resolve_library_path(entrypoint)?;
218-
let lib = unsafe { Library::new(&library_path) }.map_err(|e| {
219-
Error::with_message(
220-
ErrorKind::InvalidConfig,
221-
format!(
222-
"failed to load in-process runtime library '{}': {e}",
223-
library_path.display()
224-
),
225-
)
226-
})?;
220+
let lib = load_library(&library_path)?;
227221

228-
let host_start =
229-
*bind::<HostStartFn>(&lib, b"copilot_runtime_host_start\0", &library_path)?;
222+
let host_start = *bind::<HostStartFn>(lib, b"copilot_runtime_host_start\0", &library_path)?;
230223
let host_shutdown =
231-
*bind::<HostShutdownFn>(&lib, b"copilot_runtime_host_shutdown\0", &library_path)?;
224+
*bind::<HostShutdownFn>(lib, b"copilot_runtime_host_shutdown\0", &library_path)?;
232225
let connection_open =
233-
*bind::<ConnectionOpenFn>(&lib, b"copilot_runtime_connection_open\0", &library_path)?;
226+
*bind::<ConnectionOpenFn>(lib, b"copilot_runtime_connection_open\0", &library_path)?;
234227
let connection_write =
235-
*bind::<ConnectionWriteFn>(&lib, b"copilot_runtime_connection_write\0", &library_path)?;
228+
*bind::<ConnectionWriteFn>(lib, b"copilot_runtime_connection_write\0", &library_path)?;
236229
let connection_close =
237-
*bind::<ConnectionCloseFn>(&lib, b"copilot_runtime_connection_close\0", &library_path)?;
230+
*bind::<ConnectionCloseFn>(lib, b"copilot_runtime_connection_close\0", &library_path)?;
238231

239232
Ok(Self {
240-
lib,
241233
library_path,
242234
entrypoint: entrypoint.to_path_buf(),
243235
environment,
@@ -310,7 +302,6 @@ impl FfiHost {
310302
}
311303

312304
let shared = Arc::new(FfiShared {
313-
_lib: self.lib,
314305
host_shutdown: self.host_shutdown,
315306
connection_write: self.connection_write,
316307
connection_close: self.connection_close,
@@ -355,6 +346,44 @@ fn bind<'lib, T>(
355346
})
356347
}
357348

349+
/// Loads the runtime cdylib once per process and never unloads it, returning a
350+
/// `'static` reference. Subsequent loads of the same path reuse the first
351+
/// handle.
352+
///
353+
/// The library is intentionally leaked (never `FreeLibrary`/`dlclose`d), so its
354+
/// code stays mapped for the process lifetime. This mirrors the Node host
355+
/// (which loads the cdylib once into a module-global and never unloads it) and
356+
/// the runtime's own process-global tokio runtime that is never shut down.
357+
/// Unloading the cdylib while shutting a connection down races the runtime's
358+
/// worker threads: on Windows, `FreeLibrary` unmaps the code and any late
359+
/// worker-thread callback into it faults (`STATUS_ACCESS_VIOLATION`). Keeping
360+
/// the module mapped avoids that while `close()` still tears the host down.
361+
fn load_library(library_path: &Path) -> Result<&'static Library, Error> {
362+
static LIBRARIES: OnceLock<Mutex<HashMap<PathBuf, &'static Library>>> = OnceLock::new();
363+
let cache = LIBRARIES.get_or_init(|| Mutex::new(HashMap::new()));
364+
365+
let mut guard = cache
366+
.lock()
367+
.unwrap_or_else(|poisoned| poisoned.into_inner());
368+
if let Some(lib) = guard.get(library_path) {
369+
return Ok(*lib);
370+
}
371+
372+
let lib = unsafe { Library::new(library_path) }.map_err(|e| {
373+
Error::with_message(
374+
ErrorKind::InvalidConfig,
375+
format!(
376+
"failed to load in-process runtime library '{}': {e}",
377+
library_path.display()
378+
),
379+
)
380+
})?;
381+
// Leak the library so it is never unloaded for the process lifetime.
382+
let leaked: &'static Library = Box::leak(Box::new(lib));
383+
guard.insert(library_path.to_path_buf(), leaked);
384+
Ok(leaked)
385+
}
386+
358387
/// The natural platform shared-library file name for the runtime cdylib — the
359388
/// `.node` file renamed to what the Rust cdylib would be called on this OS.
360389
fn natural_library_name() -> &'static str {

0 commit comments

Comments
 (0)