diff --git a/Cargo.lock b/Cargo.lock index 7cdf324b5..bd12b6c18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,7 +403,7 @@ dependencies = [ "serde_json", "serde_qs", "serde_repr", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tsify-next", "uniffi", @@ -442,7 +442,7 @@ dependencies = [ "sha1", "sha2", "subtle", - "thiserror 1.0.69", + "thiserror 2.0.12", "tsify-next", "typenum", "uniffi", @@ -476,7 +476,7 @@ dependencies = [ "quote", "serde", "syn", - "thiserror 1.0.69", + "thiserror 2.0.12", "tsify-next", "wasm-bindgen", ] @@ -498,7 +498,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.12", "tsify-next", "uniffi", "uuid", @@ -524,7 +524,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.12", "uniffi", "uuid", ] @@ -542,7 +542,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tsify-next", "uniffi", @@ -558,7 +558,7 @@ dependencies = [ "js-sys", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tsify-next", "wasm-bindgen", @@ -576,7 +576,7 @@ dependencies = [ "chrono", "serde", "serde_repr", - "thiserror 1.0.69", + "thiserror 2.0.12", "uniffi", "uuid", "wasm-bindgen", @@ -595,7 +595,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "uuid", "validator", @@ -616,7 +616,7 @@ dependencies = [ "rsa", "serde", "ssh-key", - "thiserror 1.0.69", + "thiserror 2.0.12", "tsify-next", "uniffi", "wasm-bindgen", @@ -629,12 +629,14 @@ dependencies = [ "async-trait", "bitwarden-error", "console_error_panic_hook", + "gloo-timers", "js-sys", "log", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", + "tokio-util", "tsify-next", "wasm-bindgen", "wasm-bindgen-futures", @@ -663,7 +665,7 @@ dependencies = [ "oslog", "rustls-platform-verifier", "schemars", - "thiserror 1.0.69", + "thiserror 2.0.12", "uniffi", "uuid", ] @@ -688,7 +690,7 @@ dependencies = [ "serde_repr", "sha1", "sha2", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tsify-next", "uniffi", @@ -1778,6 +1780,18 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "goblin" version = "0.8.2" diff --git a/crates/bitwarden-threading/Cargo.toml b/crates/bitwarden-threading/Cargo.toml index 2ad31b794..bbcf74492 100644 --- a/crates/bitwarden-threading/Cargo.toml +++ b/crates/bitwarden-threading/Cargo.toml @@ -9,6 +9,9 @@ repository.workspace = true license-file.workspace = true keywords.workspace = true +[features] +time = ["gloo-timers"] + [dependencies] bitwarden-error = { workspace = true } log = { workspace = true } @@ -16,8 +19,10 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { features = ["sync", "time", "rt"], workspace = true } +tokio-util = { version = "0.7.15" } [target.'cfg(target_arch="wasm32")'.dependencies] +gloo-timers = { version = "0.3.0", features = ["futures"], optional = true } js-sys = { workspace = true } tsify-next = { workspace = true } wasm-bindgen = { workspace = true } diff --git a/crates/bitwarden-threading/src/cancellation_token.rs b/crates/bitwarden-threading/src/cancellation_token.rs new file mode 100644 index 000000000..d73e39fd2 --- /dev/null +++ b/crates/bitwarden-threading/src/cancellation_token.rs @@ -0,0 +1,147 @@ +pub use tokio_util::sync::CancellationToken; + +#[cfg(target_arch = "wasm32")] +pub mod wasm { + use tokio::select; + use tokio_util::sync::DropGuard; + use wasm_bindgen::prelude::*; + use wasm_bindgen_futures::spawn_local; + + use super::*; + + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(js_namespace = console, js_name = log)] + pub fn console_log(message: &str); + + #[wasm_bindgen] + #[derive(Clone)] + pub type AbortController; + + #[wasm_bindgen(constructor)] + pub fn new() -> AbortController; + + #[wasm_bindgen(method, getter)] + pub fn signal(this: &AbortController) -> AbortSignal; + + #[wasm_bindgen(method, js_name = abort)] + pub fn abort(this: &AbortController, reason: JsValue); + + #[wasm_bindgen] + pub type AbortSignal; + + #[wasm_bindgen(method, getter)] + pub fn aborted(this: &AbortSignal) -> bool; + + #[wasm_bindgen(method, js_name = addEventListener)] + pub fn add_event_listener( + this: &AbortSignal, + event_type: &str, + callback: &Closure, + ); + } + + pub trait CancellationTokenExt { + /// Converts a `CancellationToken` to an `AbortController`. + /// The signal only travels in one direction: `CancellationToken` -> `AbortController`, + /// i.e. the `AbortController` will be aborted when the `CancellationToken` is cancelled. + fn to_abort_controller(self) -> AbortController; + fn to_bidirectional_abort_controller(self) -> (AbortController, DropGuard); + } + + impl CancellationTokenExt for CancellationToken { + fn to_abort_controller(self) -> AbortController { + let controller = AbortController::new(); + + let token_clone = self.clone(); + let controller_clone = controller.clone(); + + let closure_dropped_token = CancellationToken::new(); + let drop_guard = closure_dropped_token.clone().drop_guard(); + + spawn_local(async move { + select! { + _ = token_clone.cancelled() => { + controller_clone.abort(JsValue::from("Rust token cancelled")); + }, + _ = closure_dropped_token.cancelled() => {} + } + }); + + let closure = Closure::new({ + let _drop_guard = drop_guard; + move || { + // Do nothing + } + }); + controller.signal().add_event_listener("abort", &closure); + closure.forget(); // Transfer ownership to the JS runtime + + controller + } + + fn to_bidirectional_abort_controller(self) -> (AbortController, DropGuard) { + let controller = AbortController::new(); + + let drop_guard = connect_token_and_controller(self.clone(), controller.clone()); + + (controller, drop_guard) + } + } + + pub trait AbortControllerExt { + fn to_cancellation_token(&self) -> CancellationToken; + fn to_bidirectional_cancellation_token(&self) -> (CancellationToken, DropGuard); + } + + impl AbortControllerExt for AbortController { + fn to_cancellation_token(&self) -> CancellationToken { + let token = CancellationToken::new(); + + let token_clone = token.clone(); + let closure = Closure::new(move || { + token_clone.cancel(); + }); + self.signal().add_event_listener("abort", &closure); + closure.forget(); // Transfer ownership to the JS runtime + + token + } + + fn to_bidirectional_cancellation_token(&self) -> (CancellationToken, DropGuard) { + let token = CancellationToken::new(); + + let drop_guard = connect_token_and_controller(token.clone(), self.clone()); + + (token, drop_guard) + } + } + + fn connect_token_and_controller( + token: CancellationToken, + controller: AbortController, + ) -> DropGuard { + let token_clone = token.clone(); + let controller_clone = controller.clone(); + + let guarded_token = CancellationToken::new(); + let drop_guard = guarded_token.clone().drop_guard(); + + spawn_local(async move { + select! { + _ = token_clone.cancelled() => { + controller_clone.abort(JsValue::from("Rust token cancelled")); + }, + _ = guarded_token.cancelled() => {} + } + }); + + let closure = Closure::new(move || { + token.cancel(); + }); + controller.signal().add_event_listener("abort", &closure); + closure.forget(); // Transfer ownership to the JS runtime + + drop_guard + } +} diff --git a/crates/bitwarden-threading/src/lib.rs b/crates/bitwarden-threading/src/lib.rs index 127c6694d..3a4f6ed49 100644 --- a/crates/bitwarden-threading/src/lib.rs +++ b/crates/bitwarden-threading/src/lib.rs @@ -1,3 +1,5 @@ +pub mod cancellation_token; mod thread_bound_runner; +pub mod time; pub use thread_bound_runner::ThreadBoundRunner; diff --git a/crates/bitwarden-threading/src/time.rs b/crates/bitwarden-threading/src/time.rs new file mode 100644 index 000000000..06c5fb98e --- /dev/null +++ b/crates/bitwarden-threading/src/time.rs @@ -0,0 +1,56 @@ +use std::time::Duration; + +#[cfg(not(target_arch = "wasm32"))] +pub async fn sleep(duration: Duration) { + tokio::time::sleep(duration).await; +} + +#[cfg(target_arch = "wasm32")] +pub async fn sleep(duration: Duration) { + use gloo_timers::future::sleep; + + sleep(duration).await; +} + +#[cfg(test)] +mod test { + use wasm_bindgen_test::wasm_bindgen_test; + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + async fn should_sleep_wasm() { + use js_sys::Date; + + use super::*; + + console_error_panic_hook::set_once(); + let start = Date::now(); + + sleep(Duration::from_millis(100)).await; + + let end = Date::now(); + let elapsed = end - start; + + assert!(elapsed >= 90.0, "Elapsed time was less than expected"); + } + + // #[cfg(not(target_arch = "wasm32"))] + #[tokio::test] + async fn should_sleep_tokio() { + use std::time::Instant; + + use super::*; + + let start = Instant::now(); + + sleep(Duration::from_millis(100)).await; + + let end = Instant::now(); + let elapsed = end.duration_since(start); + + assert!( + elapsed >= Duration::from_millis(90), + "Elapsed time was less than expected" + ); + } +} diff --git a/crates/bitwarden-threading/tests/cancellation_token/mod.rs b/crates/bitwarden-threading/tests/cancellation_token/mod.rs new file mode 100644 index 000000000..f539b360f --- /dev/null +++ b/crates/bitwarden-threading/tests/cancellation_token/mod.rs @@ -0,0 +1 @@ +mod wasm; diff --git a/crates/bitwarden-threading/tests/cancellation_token/wasm.rs b/crates/bitwarden-threading/tests/cancellation_token/wasm.rs new file mode 100644 index 000000000..306719c97 --- /dev/null +++ b/crates/bitwarden-threading/tests/cancellation_token/wasm.rs @@ -0,0 +1,306 @@ +use std::time::Duration; + +use bitwarden_threading::{ + cancellation_token::{ + wasm::{AbortController, AbortControllerExt, CancellationTokenExt}, + CancellationToken, + }, + time::sleep, +}; +use wasm_bindgen::externref_heap_live_count; +use wasm_bindgen_test::wasm_bindgen_test; + +mod to_abort_controller { + use super::*; + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn rust_cancel_propagates_to_js() { + console_error_panic_hook::set_once(); + + let token = CancellationToken::new(); + let controller: AbortController = token.clone().to_abort_controller(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + token.cancel(); + // Give the cancellation some time to propagate + sleep(Duration::from_millis(100)).await; + + assert!(token.is_cancelled()); + assert!(controller.signal().aborted()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn js_abort_does_not_propagate_to_rust() { + console_error_panic_hook::set_once(); + + let token = CancellationToken::new(); + let controller: AbortController = token.clone().to_abort_controller(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + controller.abort(wasm_bindgen::JsValue::from("Test reason")); + // Give the cancellation some time to propagate + sleep(Duration::from_millis(100)).await; + + assert!(!token.is_cancelled()); + assert!(controller.signal().aborted()); + } +} + +mod to_bidirectional_abort_controller { + use super::*; + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn rust_cancel_propagates_to_js() { + console_error_panic_hook::set_once(); + + let token = CancellationToken::new(); + let (controller, _drop_guard) = token.clone().to_bidirectional_abort_controller(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + token.cancel(); + // Give the cancellation some time to propagate + sleep(Duration::from_millis(100)).await; + + assert!(token.is_cancelled()); + assert!(controller.signal().aborted()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn js_abort_propagates_to_rust() { + console_error_panic_hook::set_once(); + + let token = CancellationToken::new(); + let (controller, _drop_guard) = token.clone().to_bidirectional_abort_controller(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + controller.abort(wasm_bindgen::JsValue::from("Test reason")); + // Give the cancellation some time to propagate + sleep(Duration::from_millis(100)).await; + + assert!(token.is_cancelled()); + assert!(controller.signal().aborted()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn rust_cancel_does_not_propagate_to_js_when_guard_has_been_dropped() { + console_error_panic_hook::set_once(); + + let token = CancellationToken::new(); + let (controller, drop_guard) = token.clone().to_bidirectional_abort_controller(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + drop(drop_guard); + sleep(Duration::from_millis(100)).await; + + token.cancel(); + sleep(Duration::from_millis(100)).await; + + assert!(token.is_cancelled()); + assert!(!controller.signal().aborted()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn drops_reference_to_js_controller_when_rust_token_is_dropped() { + console_error_panic_hook::set_once(); + + let token = CancellationToken::new(); + + let heap_count_before_creating_abort_controller = externref_heap_live_count(); + + let (_controller, drop_guard) = token.clone().to_bidirectional_abort_controller(); + let heap_count_after_creating_abort_controller = externref_heap_live_count(); + + drop(drop_guard); + // Give the drop some time to propagate + sleep(Duration::from_millis(100)).await; + let heap_count_after_dropping_guard = externref_heap_live_count(); + + // Creating the AbortController create 2 strong references to the JS object. + // One is kept internally to be able to propagate cancellations to the JS object and the + // the other is returned to the caller. + assert_eq!( + heap_count_after_creating_abort_controller, + heap_count_before_creating_abort_controller + 2 + ); + + // Dropping the token should the internal strong reference to the JS object, leaving us + // with only the strong reference that was returned to the caller. + // We check this because the reference is kept within a spawn_local future and it'll only be + // dropped when the future is dropped. And the future needs to be dropped or we'll leak + // memory. + assert_eq!( + heap_count_after_dropping_guard, + heap_count_after_creating_abort_controller - 1, + "Dropping the token should drop the internal strong reference to the JS object" + ); + } +} + +mod to_cancellation_token { + use super::*; + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn rust_cancel_does_not_propagate_to_js() { + console_error_panic_hook::set_once(); + + let controller = AbortController::new(); + let token = controller.clone().to_cancellation_token(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + token.cancel(); + // Give the cancellation some time to propagate + sleep(Duration::from_millis(100)).await; + + assert!(token.is_cancelled()); + assert!(!controller.signal().aborted()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn js_abort_propagate_to_rust() { + console_error_panic_hook::set_once(); + + let controller = AbortController::new(); + let token = controller.clone().to_cancellation_token(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + controller.abort(wasm_bindgen::JsValue::from("Test reason")); + // Give the cancellation some time to propagate + sleep(Duration::from_millis(100)).await; + + assert!(token.is_cancelled()); + assert!(controller.signal().aborted()); + } +} + +mod to_bidirectional_cancellation_token { + use super::*; + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn rust_cancel_propagates_to_js() { + console_error_panic_hook::set_once(); + + let controller = AbortController::new(); + let (token, _drop_guard) = controller.clone().to_bidirectional_cancellation_token(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + token.cancel(); + // Give the cancellation some time to propagate + sleep(Duration::from_millis(100)).await; + + assert!(token.is_cancelled()); + assert!(controller.signal().aborted()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn js_abort_propagates_to_rust() { + console_error_panic_hook::set_once(); + + let controller = AbortController::new(); + let (token, _drop_guard) = controller.clone().to_bidirectional_cancellation_token(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + controller.abort(wasm_bindgen::JsValue::from("Test reason")); + // Give the cancellation some time to propagate + sleep(Duration::from_millis(100)).await; + + assert!(token.is_cancelled()); + assert!(controller.signal().aborted()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn rust_cancel_does_not_propagate_to_js_when_guard_has_been_dropped() { + console_error_panic_hook::set_once(); + + let controller = AbortController::new(); + let (token, drop_guard) = controller.clone().to_bidirectional_cancellation_token(); + + assert!(!token.is_cancelled()); + assert!(!controller.signal().aborted()); + + drop(drop_guard); + sleep(Duration::from_millis(100)).await; + + token.cancel(); + sleep(Duration::from_millis(100)).await; + + assert!(token.is_cancelled()); + assert!(!controller.signal().aborted()); + } + + #[wasm_bindgen_test] + #[allow(dead_code)] // Not actually dead, but rust-analyzer doesn't understand `wasm_bindgen_test` + #[cfg(target_arch = "wasm32")] + async fn drops_reference_to_js_controller_when_rust_token_is_dropped() { + console_error_panic_hook::set_once(); + + let controller = AbortController::new(); + + let heap_count_before_creating_cancellation_token = externref_heap_live_count(); + + let (_token, drop_guard) = controller.clone().to_bidirectional_cancellation_token(); + let heap_count_after_creating_cancellation_token = externref_heap_live_count(); + + drop(drop_guard); + sleep(Duration::from_millis(100)).await; + + let heap_count_after_dropping_guard = externref_heap_live_count(); + + // Creating the AbortController create 2 strong references to the JS object. + // One is kept internally to be able to propagate cancellations to the JS object and the + // the other is returned to the caller. + assert_eq!( + heap_count_after_creating_cancellation_token, + heap_count_before_creating_cancellation_token + 1 + ); + + // Dropping the token should the internal strong reference to the JS object, leaving us + // with only the strong reference that was returned to the caller. + // We check this because the reference is kept within a spawn_local future and it'll only be + // dropped when the future is dropped. And the future needs to be dropped or we'll leak + // memory. + assert_eq!( + heap_count_after_dropping_guard, heap_count_before_creating_cancellation_token, + "Dropping the token should drop the internal strong reference to the JS object" + ); + } +} diff --git a/crates/bitwarden-threading/tests/mod.rs b/crates/bitwarden-threading/tests/mod.rs index 1aba83ea4..97a03509e 100644 --- a/crates/bitwarden-threading/tests/mod.rs +++ b/crates/bitwarden-threading/tests/mod.rs @@ -1,2 +1,2 @@ -mod standard_tokio; -mod wasm; +mod cancellation_token; +mod thread_bound_runner; diff --git a/crates/bitwarden-threading/tests/thread_bound_runner/mod.rs b/crates/bitwarden-threading/tests/thread_bound_runner/mod.rs new file mode 100644 index 000000000..1aba83ea4 --- /dev/null +++ b/crates/bitwarden-threading/tests/thread_bound_runner/mod.rs @@ -0,0 +1,2 @@ +mod standard_tokio; +mod wasm; diff --git a/crates/bitwarden-threading/tests/standard_tokio.rs b/crates/bitwarden-threading/tests/thread_bound_runner/standard_tokio.rs similarity index 100% rename from crates/bitwarden-threading/tests/standard_tokio.rs rename to crates/bitwarden-threading/tests/thread_bound_runner/standard_tokio.rs diff --git a/crates/bitwarden-threading/tests/wasm.rs b/crates/bitwarden-threading/tests/thread_bound_runner/wasm.rs similarity index 100% rename from crates/bitwarden-threading/tests/wasm.rs rename to crates/bitwarden-threading/tests/thread_bound_runner/wasm.rs