From 77f0370c9b13a01e8814fb816c219487c63edf09 Mon Sep 17 00:00:00 2001 From: Brian Karfunkel <1545637+bkfunk@users.noreply.github.com> Date: Wed, 13 May 2026 14:45:56 -0400 Subject: [PATCH] feat(library): make LibraryLoader/DocumentLoader Send on native targets `LibraryLoader` and `DocumentLoader` were declared with `#[async_trait(?Send)]`, which propagated `?Send` through every resolver and through `resolve_dependencies*`. On wasm32 that's necessary (wasm-bindgen futures are deliberately `!Send` because the web platform is single-threaded), but on native it forced consumers running on a multi-threaded tokio runtime (e.g. Tauri's default) into ceremonies like `spawn_blocking` + `new_current_thread`, paying a fresh-runtime construction on every part load. The underlying work is already `Send` on native: `LocalLoader` uses `tokio::fs` and `BufReader` (both `Send`), `HttpLoader` uses `reqwest::Client` futures (`Send`), and `PartCache` lives in `Arc>` (already `Send + Sync`). The `?Send` was the only thing forcing the worse codepath. Switch the four `#[async_trait(?Send)]` attributes (the two traits in `library.rs` and the four impls across the two resolvers) to: #[cfg_attr(not(target_arch = "wasm32"), async_trait)] #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] This is the standard pattern for libraries that target both platforms. Native consumers can now `.await` `LibraryLoader` futures directly on worker threads; the wasm32 build is unaffected. Adds compile-time `Send` checks in both resolvers: dead helper functions whose bodies call `assert_send::(_)` on the future returned by `load_ref`/`load_colors`. These don't run at runtime, but will fail to compile if the futures ever lose `Send` again. Verified: - `cargo check --workspace` passes on native - `cargo check -p ldraw --target wasm32-unknown-unknown` passes - `cargo test -p ldraw --lib` passes Co-Authored-By: Claude Opus 4.7 (1M context) --- ldraw/src/library.rs | 6 ++++-- ldraw/src/resolvers/http.rs | 19 +++++++++++++++++-- ldraw/src/resolvers/local.rs | 23 +++++++++++++++++++++-- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/ldraw/src/library.rs b/ldraw/src/library.rs index f60489c..f7f97d5 100644 --- a/ldraw/src/library.rs +++ b/ldraw/src/library.rs @@ -27,7 +27,8 @@ pub enum FileLocation { Local, } -#[async_trait(?Send)] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] pub trait DocumentLoader { async fn load_document( &self, @@ -36,7 +37,8 @@ pub trait DocumentLoader { ) -> Result; } -#[async_trait(?Send)] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] pub trait LibraryLoader { async fn load_colors(&self) -> Result; diff --git a/ldraw/src/resolvers/http.rs b/ldraw/src/resolvers/http.rs index 33a9e97..c09067e 100644 --- a/ldraw/src/resolvers/http.rs +++ b/ldraw/src/resolvers/http.rs @@ -29,7 +29,21 @@ impl HttpLoader { } } -#[async_trait(?Send)] +/// Compile-time check that `HttpLoader::load_ref` and `load_colors` return +/// `Send` futures on non-wasm targets. See the parallel helper in +/// `resolvers::local` for context. +#[cfg(not(target_arch = "wasm32"))] +#[allow(dead_code)] +fn _assert_httploader_futures_are_send(loader: &HttpLoader, colors: &ColorCatalog) { + fn assert_send(_: &F) {} + let f1 = loader.load_ref(PartAlias::from("dummy".to_string()), false, colors); + assert_send(&f1); + let f2 = loader.load_colors(); + assert_send(&f2); +} + +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl DocumentLoader for HttpLoader { async fn load_document( &self, @@ -46,7 +60,8 @@ impl DocumentLoader for HttpLoader { } } -#[async_trait(?Send)] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl LibraryLoader for HttpLoader { async fn load_colors(&self) -> Result { let ldraw_url_base = self.ldraw_url_base.as_ref(); diff --git a/ldraw/src/resolvers/local.rs b/ldraw/src/resolvers/local.rs index b8512e0..0753083 100644 --- a/ldraw/src/resolvers/local.rs +++ b/ldraw/src/resolvers/local.rs @@ -26,7 +26,25 @@ impl LocalLoader { } } -#[async_trait(?Send)] +/// Compile-time check: on non-wasm targets, `LocalLoader::load_ref` and +/// `load_colors` must return `Send` futures so multi-threaded runtimes can +/// `.await` them on a worker thread. If the future is `!Send`, this function +/// fails to compile, which is the entire point. +/// +/// The function is never called; its body exists only to be type-checked by +/// the compiler. +#[cfg(not(target_arch = "wasm32"))] +#[allow(dead_code)] +fn _assert_localloader_futures_are_send(loader: &LocalLoader, colors: &ColorCatalog) { + fn assert_send(_: &F) {} + let f1 = loader.load_ref(PartAlias::from("dummy".to_string()), false, colors); + assert_send(&f1); + let f2 = loader.load_colors(); + assert_send(&f2); +} + +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl DocumentLoader for LocalLoader { async fn load_document( &self, @@ -44,7 +62,8 @@ impl DocumentLoader for LocalLoader { } } -#[async_trait(?Send)] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl LibraryLoader for LocalLoader { async fn load_colors(&self) -> Result { let ldrawdir = match self.ldrawdir.clone() {