diff --git a/crates/cachekit/Cargo.toml b/crates/cachekit/Cargo.toml index 3a56b4b..db4e126 100644 --- a/crates/cachekit/Cargo.toml +++ b/crates/cachekit/Cargo.toml @@ -24,6 +24,9 @@ workers = ["dep:worker", "dep:js-sys", "dep:getrandom"] encryption = ["cachekit-core/encryption"] l1 = ["dep:moka"] macros = ["dep:cachekit-macros"] +# Opt into ?Send / Rc on native targets (for tokio::task::LocalSet, single-threaded +# runtimes, etc.). Same code paths that wasm32 already uses. +unsync = [] [dependencies] cachekit-core = { version = "0.2", features = ["messagepack"] } diff --git a/crates/cachekit/src/backend/cachekitio.rs b/crates/cachekit/src/backend/cachekitio.rs index 4182f54..f28b416 100644 --- a/crates/cachekit/src/backend/cachekitio.rs +++ b/crates/cachekit/src/backend/cachekitio.rs @@ -119,7 +119,8 @@ pub(crate) fn from_http_status_sanitized(status: u16, body: &[u8], api_key: &str // ── Backend impl ────────────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] -#[async_trait] +#[cfg_attr(not(feature = "unsync"), async_trait)] +#[cfg_attr(feature = "unsync", async_trait(?Send))] impl Backend for CachekitIO { async fn get(&self, key: &str) -> Result>, BackendError> { let req = self.with_standard_headers( diff --git a/crates/cachekit/src/backend/cachekitio_lock.rs b/crates/cachekit/src/backend/cachekitio_lock.rs index 316a408..b0a3ad1 100644 --- a/crates/cachekit/src/backend/cachekitio_lock.rs +++ b/crates/cachekit/src/backend/cachekitio_lock.rs @@ -20,7 +20,8 @@ struct LockAcquireResponse { } #[cfg(not(target_arch = "wasm32"))] -#[async_trait] +#[cfg_attr(not(feature = "unsync"), async_trait)] +#[cfg_attr(feature = "unsync", async_trait(?Send))] impl LockableBackend for CachekitIO { async fn acquire_lock( &self, diff --git a/crates/cachekit/src/backend/cachekitio_ttl.rs b/crates/cachekit/src/backend/cachekitio_ttl.rs index 3534bb5..b1ed3ef 100644 --- a/crates/cachekit/src/backend/cachekitio_ttl.rs +++ b/crates/cachekit/src/backend/cachekitio_ttl.rs @@ -21,7 +21,8 @@ struct RefreshTtlRequest { } #[cfg(not(target_arch = "wasm32"))] -#[async_trait] +#[cfg_attr(not(feature = "unsync"), async_trait)] +#[cfg_attr(feature = "unsync", async_trait(?Send))] impl TtlInspectable for CachekitIO { async fn ttl(&self, key: &str) -> Result, BackendError> { let url = format!( diff --git a/crates/cachekit/src/backend/mod.rs b/crates/cachekit/src/backend/mod.rs index 8fd3683..f6d0d2c 100644 --- a/crates/cachekit/src/backend/mod.rs +++ b/crates/cachekit/src/backend/mod.rs @@ -24,10 +24,10 @@ pub struct HealthStatus { /// Async cache backend abstraction. /// -/// Implementors must be `Send + Sync` on native targets. -/// On `wasm32` targets `Send` is relaxed (`?Send`) because the Workers runtime -/// is single-threaded and `reqwest`/`worker::Fetch` futures are `!Send`. -#[cfg(not(target_arch = "wasm32"))] +/// Implementors must be `Send + Sync` on native targets (unless the `unsync` +/// feature is enabled). On `wasm32` targets or with `unsync`, `Send` is relaxed +/// (`?Send`) because the runtime is single-threaded. +#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))] #[async_trait] pub trait Backend: Send + Sync { /// Retrieve the raw bytes stored under `key`, or `None` if absent. @@ -51,25 +51,38 @@ pub trait Backend: Send + Sync { async fn health(&self) -> Result; } -#[cfg(target_arch = "wasm32")] +/// Async cache backend abstraction (`?Send` variant). +/// +/// Active when compiling for `wasm32` or with the `unsync` feature. +/// Identical API to the `Send + Sync` variant but without thread-safety bounds. +#[cfg(any(target_arch = "wasm32", feature = "unsync"))] #[async_trait(?Send)] pub trait Backend { + /// Retrieve the raw bytes stored under `key`, or `None` if absent. async fn get(&self, key: &str) -> Result>, BackendError>; + + /// Store `value` under `key`, optionally expiring after `ttl`. async fn set( &self, key: &str, value: Vec, ttl: Option, ) -> Result<(), BackendError>; + + /// Remove `key` and return `true` if it existed. async fn delete(&self, key: &str) -> Result; + + /// Return `true` if `key` exists without fetching the value. async fn exists(&self, key: &str) -> Result; + + /// Return health/status information for this backend. async fn health(&self) -> Result; } // ── TtlInspectable ─────────────────────────────────────────────────────────── /// Optional extension for backends that can report the remaining TTL of a key. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))] #[async_trait] pub trait TtlInspectable: Backend { /// Return the remaining TTL for `key`, or `None` if the key does not exist @@ -84,11 +97,15 @@ pub trait TtlInspectable: Backend { } } -#[cfg(target_arch = "wasm32")] +/// Optional extension for backends that can report the remaining TTL of a key (`?Send` variant). +#[cfg(any(target_arch = "wasm32", feature = "unsync"))] #[async_trait(?Send)] pub trait TtlInspectable: Backend { + /// Return the remaining TTL for `key`, or `None` if the key does not exist + /// or has no expiry. async fn ttl(&self, key: &str) -> Result, BackendError>; + /// Refresh the TTL on an existing key. Default: not supported. async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result { Err(BackendError::permanent( "refresh_ttl not supported by this backend", @@ -99,7 +116,7 @@ pub trait TtlInspectable: Backend { // ── LockableBackend ───────────────────────────────────────────────────────── /// Optional extension for backends that support distributed locking. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))] #[async_trait] pub trait LockableBackend: Backend { /// Acquire a distributed lock. Returns lock_id if acquired, None if contested. @@ -112,14 +129,17 @@ pub trait LockableBackend: Backend { async fn release_lock(&self, key: &str, lock_id: &str) -> Result; } -#[cfg(target_arch = "wasm32")] +/// Optional extension for backends that support distributed locking (`?Send` variant). +#[cfg(any(target_arch = "wasm32", feature = "unsync"))] #[async_trait(?Send)] pub trait LockableBackend: Backend { + /// Acquire a distributed lock. Returns lock_id if acquired, None if contested. async fn acquire_lock( &self, key: &str, timeout_ms: u64, ) -> Result, BackendError>; + /// Release a distributed lock. Returns true if released. async fn release_lock(&self, key: &str, lock_id: &str) -> Result; } diff --git a/crates/cachekit/src/backend/redis.rs b/crates/cachekit/src/backend/redis.rs index 434942d..8972d33 100644 --- a/crates/cachekit/src/backend/redis.rs +++ b/crates/cachekit/src/backend/redis.rs @@ -75,7 +75,8 @@ impl RedisBackend { // ── Backend impl ────────────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] -#[async_trait] +#[cfg_attr(not(feature = "unsync"), async_trait)] +#[cfg_attr(feature = "unsync", async_trait(?Send))] impl Backend for RedisBackend { async fn get(&self, key: &str) -> Result>, BackendError> { let result: Option = self.client.get(key).await.map_err(redis_err)?; @@ -127,7 +128,8 @@ impl Backend for RedisBackend { // ── TtlInspectable impl ─────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] -#[async_trait] +#[cfg_attr(not(feature = "unsync"), async_trait)] +#[cfg_attr(feature = "unsync", async_trait(?Send))] impl TtlInspectable for RedisBackend { async fn ttl(&self, key: &str) -> Result, BackendError> { // Redis TTL return values: diff --git a/crates/cachekit/src/client.rs b/crates/cachekit/src/client.rs index 1801792..f621df1 100644 --- a/crates/cachekit/src/client.rs +++ b/crates/cachekit/src/client.rs @@ -8,28 +8,35 @@ use crate::serializer; // ── SharedBackend type alias ────────────────────────────────────────────────── -/// Thread-safe reference to a heap-allocated backend. +/// Reference-counted pointer to a heap-allocated backend. /// -/// On native targets we require `Send + Sync` for use across threads. -/// On `wasm32` the Workers runtime is single-threaded so `Rc` is sufficient. -#[cfg(not(target_arch = "wasm32"))] +/// On native targets (without `unsync`) we require `Send + Sync` via `Arc`. +/// On `wasm32` or with the `unsync` feature, `Rc` is used instead — the runtime +/// is single-threaded so `Send` bounds are unnecessary. +#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))] pub type SharedBackend = std::sync::Arc; -#[cfg(target_arch = "wasm32")] +/// Reference-counted pointer to a heap-allocated backend (`?Send` variant). +#[cfg(any(target_arch = "wasm32", feature = "unsync"))] pub type SharedBackend = std::rc::Rc; // ── SharedEncryption type alias ────────────────────────────────────────────── -/// Thread-safe reference to the encryption layer. +/// Reference-counted pointer to the encryption layer. /// -/// On native targets `Arc` is used (requires `Sync`). -/// On `wasm32` the Workers runtime is single-threaded so `Rc` is sufficient -/// and avoids the `!Sync` problem caused by `Cell` inside cachekit-core's -/// nonce counter. -#[cfg(all(feature = "encryption", not(target_arch = "wasm32")))] +/// On native targets (without `unsync`) `Arc` is used (requires `Sync`). +/// On `wasm32` or with `unsync`, `Rc` is used — avoids the `!Sync` problem +/// caused by `Cell` inside cachekit-core's nonce counter. +#[cfg(all( + feature = "encryption", + not(any(target_arch = "wasm32", feature = "unsync")) +))] type SharedEncryption = std::sync::Arc; -#[cfg(all(feature = "encryption", target_arch = "wasm32"))] +#[cfg(all( + feature = "encryption", + any(target_arch = "wasm32", feature = "unsync") +))] type SharedEncryption = std::rc::Rc; // ── Key validation ──────────────────────────────────────────────────────────── @@ -105,8 +112,13 @@ impl CacheKit { .build() .map_err(|e| CachekitError::Config(e.to_string()))?; + #[cfg(not(feature = "unsync"))] + let shared: SharedBackend = std::sync::Arc::new(backend); + #[cfg(feature = "unsync")] + let shared: SharedBackend = std::rc::Rc::new(backend); + let mut builder = CacheKitBuilder::default() - .backend(std::sync::Arc::new(backend)) + .backend(shared) .default_ttl(config.default_ttl) .max_payload_bytes(config.max_payload_bytes) .l1_capacity(config.l1_capacity); diff --git a/crates/cachekit/src/error.rs b/crates/cachekit/src/error.rs index 2dbeaa8..1ca9462 100644 --- a/crates/cachekit/src/error.rs +++ b/crates/cachekit/src/error.rs @@ -80,11 +80,11 @@ pub struct BackendError { /// Human-readable description. pub message: String, /// The underlying error that caused this backend error, if any. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(any(target_arch = "wasm32", feature = "unsync")))] #[source] pub source: Option>, /// The underlying error that caused this backend error, if any. - #[cfg(target_arch = "wasm32")] + #[cfg(any(target_arch = "wasm32", feature = "unsync"))] #[source] pub source: Option>, } diff --git a/crates/cachekit/tests/client_tests.rs b/crates/cachekit/tests/client_tests.rs index ac58c0b..0936dc9 100644 --- a/crates/cachekit/tests/client_tests.rs +++ b/crates/cachekit/tests/client_tests.rs @@ -22,7 +22,7 @@ struct User { fn mock_client() -> CacheKit { CacheKit::builder() - .backend(MockBackend::new()) + .backend(MockBackend::shared()) .default_ttl(Duration::from_secs(60)) .no_l1() .build() @@ -94,7 +94,7 @@ async fn client_delete() { #[tokio::test] async fn client_payload_too_large() { let client = CacheKit::builder() - .backend(MockBackend::new()) + .backend(MockBackend::shared()) .max_payload_bytes(10) .no_l1() .build() @@ -161,7 +161,7 @@ async fn client_key_validation() { // Boundary case: exactly 1024 bytes should be accepted let client2 = CacheKit::builder() - .backend(MockBackend::new()) + .backend(MockBackend::shared()) .no_l1() .build() .expect("client builds"); diff --git a/crates/cachekit/tests/common/mod.rs b/crates/cachekit/tests/common/mod.rs index 691d1c1..b539470 100644 --- a/crates/cachekit/tests/common/mod.rs +++ b/crates/cachekit/tests/common/mod.rs @@ -3,28 +3,56 @@ #![allow(dead_code)] use std::collections::HashMap; -use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use tokio::sync::Mutex; use cachekit::backend::{Backend, HealthStatus}; +use cachekit::client::SharedBackend; use cachekit::error::BackendError; /// In-memory mock backend backed by a `Mutex` for use in tests. +/// +/// The internal store uses `Arc` so cloning the backend shares state, +/// regardless of whether the outer wrapper is `Arc` or `Rc`. #[derive(Debug, Default, Clone)] pub struct MockBackend { - pub store: Arc>>>, + pub store: std::sync::Arc>>>, } impl MockBackend { - pub fn new() -> Arc { - Arc::new(Self::default()) + /// Create a new mock and immediately wrap it as a [`SharedBackend`]. + /// + /// Use [`MockBackend::new_with_handle`] when tests need to inspect the + /// store directly (e.g., to verify ciphertext). + pub fn shared() -> SharedBackend { + let mock = Self::default(); + Self::into_shared(mock) + } + + /// Create a new mock, returning both a [`SharedBackend`] and a clone + /// for direct store inspection. + pub fn new_with_handle() -> (SharedBackend, Self) { + let mock = Self::default(); + let handle = mock.clone(); + (Self::into_shared(mock), handle) + } + + fn into_shared(mock: Self) -> SharedBackend { + #[cfg(not(any(target_arch = "wasm32", feature = "unsync")))] + { + std::sync::Arc::new(mock) + } + #[cfg(any(target_arch = "wasm32", feature = "unsync"))] + { + std::rc::Rc::new(mock) + } } } -#[async_trait] +#[cfg_attr(not(any(target_arch = "wasm32", feature = "unsync")), async_trait)] +#[cfg_attr(any(target_arch = "wasm32", feature = "unsync"), async_trait(?Send))] impl Backend for MockBackend { async fn get(&self, key: &str) -> Result>, BackendError> { Ok(self.store.lock().await.get(key).cloned()) diff --git a/crates/cachekit/tests/encryption_tests.rs b/crates/cachekit/tests/encryption_tests.rs index c04f1b1..99a66c2 100644 --- a/crates/cachekit/tests/encryption_tests.rs +++ b/crates/cachekit/tests/encryption_tests.rs @@ -5,12 +5,12 @@ mod common; -use std::sync::Arc; use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::common::MockBackend; +use cachekit::client::SharedBackend; use cachekit::{CacheKit, CachekitError}; // ── Test fixtures ───────────────────────────────────────────────────────────── @@ -29,7 +29,7 @@ struct Secret { user_id: u64, } -fn make_encrypted_client(backend: Arc) -> CacheKit { +fn make_encrypted_client(backend: SharedBackend) -> CacheKit { CacheKit::builder() .backend(backend) .default_ttl(Duration::from_secs(60)) @@ -40,7 +40,7 @@ fn make_encrypted_client(backend: Arc) -> CacheKit { .expect("client builds") } -fn make_encrypted_client_with_l1(backend: Arc) -> CacheKit { +fn make_encrypted_client_with_l1(backend: SharedBackend) -> CacheKit { CacheKit::builder() .backend(backend) .default_ttl(Duration::from_secs(60)) @@ -55,7 +55,7 @@ fn make_encrypted_client_with_l1(backend: Arc) -> CacheKit { #[tokio::test] async fn secure_set_and_get() { - let backend = MockBackend::new(); + let backend = MockBackend::shared(); let client = make_encrypted_client(backend); let secret = Secret { @@ -79,8 +79,8 @@ async fn secure_set_and_get() { #[tokio::test] async fn secure_data_is_encrypted_in_backend() { - let backend = MockBackend::new(); - let client = make_encrypted_client(backend.clone()); + let (shared, backend) = MockBackend::new_with_handle(); + let client = make_encrypted_client(shared); let secret = Secret { api_key: "sk-live-SUPERSECRET".to_owned(), // pragma: allowlist secret @@ -117,7 +117,7 @@ async fn secure_data_is_encrypted_in_backend() { #[tokio::test] async fn secure_without_master_key_fails() { let client = CacheKit::builder() - .backend(MockBackend::new()) + .backend(MockBackend::shared()) .no_l1() .build() .expect("client builds without encryption"); @@ -138,7 +138,7 @@ async fn secure_without_master_key_fails() { #[tokio::test] async fn secure_get_missing_returns_none() { - let client = make_encrypted_client(MockBackend::new()); + let client = make_encrypted_client(MockBackend::shared()); let secure = client.secure().unwrap(); let result: Option = secure.get("nonexistent").await.expect("get should succeed"); @@ -147,7 +147,7 @@ async fn secure_get_missing_returns_none() { #[tokio::test] async fn secure_delete() { - let client = make_encrypted_client(MockBackend::new()); + let client = make_encrypted_client(MockBackend::shared()); let secure = client.secure().unwrap(); secure.set("to-delete", &"temporary").await.unwrap(); @@ -162,8 +162,8 @@ async fn secure_delete() { #[tokio::test] async fn secure_wrong_key_fails_decryption() { - let backend = MockBackend::new(); - let client = make_encrypted_client(backend.clone()); + let (shared, backend) = MockBackend::new_with_handle(); + let client = make_encrypted_client(shared); let secure = client.secure().unwrap(); secure.set("key-a", &"secret data").await.unwrap(); @@ -186,10 +186,11 @@ async fn secure_wrong_key_fails_decryption() { #[tokio::test] async fn secure_different_tenants_cant_decrypt() { - let backend = MockBackend::new(); + let (shared_a, _backend) = MockBackend::new_with_handle(); + let shared_b = shared_a.clone(); let client_a = CacheKit::builder() - .backend(backend.clone()) + .backend(shared_a) .no_l1() .encryption_from_bytes(TEST_MASTER_KEY, "tenant-a") .unwrap() @@ -197,7 +198,7 @@ async fn secure_different_tenants_cant_decrypt() { .unwrap(); let client_b = CacheKit::builder() - .backend(backend) + .backend(shared_b) .no_l1() .encryption_from_bytes(TEST_MASTER_KEY, "tenant-b") .unwrap() @@ -222,7 +223,7 @@ async fn secure_different_tenants_cant_decrypt() { #[tokio::test] async fn secure_hex_builder() { let client = CacheKit::builder() - .backend(MockBackend::new()) + .backend(MockBackend::shared()) .no_l1() .encryption(&test_master_key_hex(), "hex-tenant") .expect("hex encryption setup") @@ -238,8 +239,8 @@ async fn secure_hex_builder() { #[tokio::test] async fn secure_with_l1_roundtrip() { - let backend = MockBackend::new(); - let client = make_encrypted_client_with_l1(backend.clone()); + let (shared, backend) = MockBackend::new_with_handle(); + let client = make_encrypted_client_with_l1(shared); let secure = client.secure().unwrap(); secure.set("l1-test", &"encrypted in L1").await.unwrap(); @@ -258,8 +259,8 @@ async fn secure_with_l1_roundtrip() { #[tokio::test] async fn secure_l1_stores_ciphertext_not_plaintext() { - let backend = MockBackend::new(); - let client = make_encrypted_client_with_l1(backend.clone()); + let (shared, backend) = MockBackend::new_with_handle(); + let client = make_encrypted_client_with_l1(shared); let secure = client.secure().unwrap(); secure.set("l1-cipher", &"PLAINTEXT_VALUE").await.unwrap(); @@ -281,9 +282,9 @@ async fn secure_l1_stores_ciphertext_not_plaintext() { #[tokio::test] async fn secure_with_namespace() { - let backend = MockBackend::new(); + let (shared, backend) = MockBackend::new_with_handle(); let client = CacheKit::builder() - .backend(backend.clone()) + .backend(shared) .namespace("ns") .no_l1() .encryption_from_bytes(TEST_MASTER_KEY, "test-tenant") diff --git a/crates/cachekit/tests/macro_tests.rs b/crates/cachekit/tests/macro_tests.rs index d67927d..b80f549 100644 --- a/crates/cachekit/tests/macro_tests.rs +++ b/crates/cachekit/tests/macro_tests.rs @@ -8,7 +8,6 @@ mod common; use std::collections::HashMap; -use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; @@ -16,32 +15,50 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use cachekit::backend::{Backend, HealthStatus}; +use cachekit::client::SharedBackend; use cachekit::error::BackendError; use cachekit::{cachekit, CacheKit, CachekitError}; // ── CountingBackend ────────────────────────────────────────────────────────── -/// In-memory backend that also counts how many get/set calls it receives. +/// Shared state for CountingBackend (Clone shares the same underlying data). #[derive(Debug, Default)] -struct CountingBackend { +struct CountingInner { store: Mutex>>, set_count: std::sync::atomic::AtomicU32, } +/// In-memory backend that also counts how many get/set calls it receives. +#[derive(Debug, Default, Clone)] +struct CountingBackend { + inner: std::sync::Arc, +} + impl CountingBackend { - fn new() -> Arc { - Arc::new(Self::default()) + fn new_with_handle() -> (SharedBackend, Self) { + let backend = Self { + inner: std::sync::Arc::new(CountingInner::default()), + }; + let handle = backend.clone(); + #[cfg(not(any(target_arch = "wasm32", feature = "unsync")))] + let shared: SharedBackend = std::sync::Arc::new(backend); + #[cfg(any(target_arch = "wasm32", feature = "unsync"))] + let shared: SharedBackend = std::rc::Rc::new(backend); + (shared, handle) } fn sets(&self) -> u32 { - self.set_count.load(std::sync::atomic::Ordering::SeqCst) + self.inner + .set_count + .load(std::sync::atomic::Ordering::SeqCst) } } -#[async_trait] +#[cfg_attr(not(any(target_arch = "wasm32", feature = "unsync")), async_trait)] +#[cfg_attr(any(target_arch = "wasm32", feature = "unsync"), async_trait(?Send))] impl Backend for CountingBackend { async fn get(&self, key: &str) -> Result>, BackendError> { - Ok(self.store.lock().await.get(key).cloned()) + Ok(self.inner.store.lock().await.get(key).cloned()) } async fn set( @@ -50,18 +67,19 @@ impl Backend for CountingBackend { value: Vec, _ttl: Option, ) -> Result<(), BackendError> { - self.set_count + self.inner + .set_count .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - self.store.lock().await.insert(key.to_owned(), value); + self.inner.store.lock().await.insert(key.to_owned(), value); Ok(()) } async fn delete(&self, key: &str) -> Result { - Ok(self.store.lock().await.remove(key).is_some()) + Ok(self.inner.store.lock().await.remove(key).is_some()) } async fn exists(&self, key: &str) -> Result { - Ok(self.store.lock().await.contains_key(key)) + Ok(self.inner.store.lock().await.contains_key(key)) } async fn health(&self) -> Result { @@ -116,15 +134,15 @@ async fn get_no_extra_args(cache: &CacheKit) -> Result { // ── Tests ──────────────────────────────────────────────────────────────────── /// Build a client with a CountingBackend and return both. -fn mock_client_counting() -> (CacheKit, Arc) { - let backend = CountingBackend::new(); +fn mock_client_counting() -> (CacheKit, CountingBackend) { + let (shared, handle) = CountingBackend::new_with_handle(); let client = CacheKit::builder() - .backend(backend.clone()) + .backend(shared) .default_ttl(Duration::from_secs(300)) .no_l1() .build() .expect("mock client builds"); - (client, backend) + (client, handle) } #[tokio::test]