From 040d5cac33602e569643be505e3fe191de19d645 Mon Sep 17 00:00:00 2001 From: Mathieu Baudet <1105398+ma2bd@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:59:00 -0400 Subject: [PATCH 1/3] [web] separate Client from RunningClient to support early calls to add_owner --- linera-client/src/chain_listener.rs | 7 +- web/@linera/client/src/chain/application.rs | 5 +- web/@linera/client/src/chain/mod.rs | 12 +- web/@linera/client/src/client.rs | 230 ++++++++++++++------ web/@linera/client/src/lib.rs | 2 +- 5 files changed, 182 insertions(+), 74 deletions(-) diff --git a/linera-client/src/chain_listener.rs b/linera-client/src/chain_listener.rs index 4f4a484a1dab..3b54a397c13a 100644 --- a/linera-client/src/chain_listener.rs +++ b/linera-client/src/chain_listener.rs @@ -291,7 +291,10 @@ impl ChainListener { /// Runs the chain listener. #[instrument(skip(self))] - pub async fn run(mut self) -> Result>, Error> { + pub async fn run( + mut self, + ) -> Result::Storage, Error>>, Error> + { let chain_ids = { let guard = self.context.lock().await; let admin_chain_id = guard.admin_chain_id(); @@ -340,7 +343,7 @@ impl ChainListener { } } future::join_all(self.listening.into_values().map(|client| client.stop())).await; - Ok(()) + Ok(self.storage) }) } diff --git a/web/@linera/client/src/chain/application.rs b/web/@linera/client/src/chain/application.rs index a444bc64512f..f63888c0bb64 100644 --- a/web/@linera/client/src/chain/application.rs +++ b/web/@linera/client/src/chain/application.rs @@ -6,11 +6,11 @@ use linera_core::client::ChainClient; use wasm_bindgen::prelude::*; use web_sys::wasm_bindgen; -use crate::{Client, Environment, JsResult}; +use crate::{client::ClientContext, Environment, JsResult}; #[wasm_bindgen] pub struct Application { - pub(crate) client: Client, + pub(crate) client_context: ClientContext, pub(crate) chain_client: ChainClient, pub(crate) id: ApplicationId, } @@ -70,7 +70,6 @@ impl Application { if !operations.is_empty() { let _hash = self - .client .client_context .lock() .await diff --git a/web/@linera/client/src/chain/mod.rs b/web/@linera/client/src/chain/mod.rs index 2c408de88859..902e52b42206 100644 --- a/web/@linera/client/src/chain/mod.rs +++ b/web/@linera/client/src/chain/mod.rs @@ -14,14 +14,14 @@ use serde::ser::Serialize as _; use wasm_bindgen::prelude::*; use web_sys::{js_sys, wasm_bindgen}; -use crate::{Client, Environment, JsResult}; +use crate::{client::ClientContext, Environment, JsResult}; pub mod application; pub use application::Application; #[wasm_bindgen] pub struct Chain { - pub(crate) client: Client, + pub(crate) client_context: ClientContext, pub(crate) chain_client: ChainClient, } @@ -80,7 +80,6 @@ impl Chain { #[wasm_bindgen] pub async fn transfer(&self, params: TransferParams) -> JsResult<()> { let _hash = self - .client .client_context .lock() .await @@ -124,8 +123,7 @@ impl Chain { options: Option, ) -> JsResult<()> { let AddOwnerOptions { weight } = options.unwrap_or_default(); - self.client - .client_context + self.client_context .lock() .await .apply_client_command(&self.chain_client, |_chain_client| { @@ -143,7 +141,7 @@ impl Chain { pub async fn validator_version_info(&self) -> JsResult { self.chain_client.synchronize_from_validators().await?; let result = self.chain_client.local_committee().await; - let mut client = self.client.client_context.lock().await; + let mut client = self.client_context.lock().await; client.update_wallet(&self.chain_client).await?; let committee = result?; let node_provider = client.make_node_provider(); @@ -187,7 +185,7 @@ impl Chain { pub async fn application(&self, id: &str) -> JsResult { web_sys::console::debug_1(&format!("connecting to Linera application {id}").into()); Ok(Application { - client: self.client.clone(), + client_context: self.client_context.clone(), chain_client: self.chain_client.clone(), id: id.parse()?, }) diff --git a/web/@linera/client/src/client.rs b/web/@linera/client/src/client.rs index a5fe10c97bb2..240227b8db4c 100644 --- a/web/@linera/client/src/client.rs +++ b/web/@linera/client/src/client.rs @@ -1,36 +1,23 @@ // Copyright (c) Zefchain Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -use std::{rc::Rc, sync::Arc}; +use std::{future::Future, pin::Pin, rc::Rc, sync::Arc}; use futures::{ - future::{self, FutureExt as _}, + future::{FutureExt as _, RemoteHandle}, lock::Mutex as AsyncMutex, }; use linera_base::identifiers::{AccountOwner, ChainId}; -use linera_client::chain_listener::{ChainListener, ClientContext as _}; +use linera_client::chain_listener::{ChainListener, ChainListenerConfig, ClientContext as _}; use tokio_util::sync::CancellationToken; use wasm_bindgen::prelude::*; use web_sys::wasm_bindgen; -use crate::{chain::Chain, signer::Signer, storage, wallet::Wallet, Environment, Error, Result}; +use crate::{ + chain::Chain, signer::Signer, storage, wallet::Wallet, Environment, Error, Result, Storage, +}; -/// The full client API, exposed to the wallet implementation. Calls -/// to this API can be trusted to have originated from the user's -/// request. -#[wasm_bindgen] -#[derive(Clone)] -pub struct Client { - // This use of `futures::lock::Mutex` is safe because we only - // expose concurrency to the browser, which must always run all - // futures on the global task queue. - // It does nothing here in this single-threaded context, but is - // hard-coded by `ChainListener`. - pub(crate) client_context: Arc>>, - cancellation_token: CancellationToken, - chain_listener_result: - future::Shared>>>, -} +pub(crate) type ClientContext = Arc>>; #[derive(Default, serde::Deserialize, tsify::Tsify)] #[tsify(from_wasm_abi)] @@ -41,9 +28,24 @@ pub struct ChainOptions { owner: Option, } +/// A client that has been created but whose chain listener has not yet started. +/// +/// Use `chain()` to perform pre-start operations (e.g. `addOwner`), then call +/// `start()` to begin background synchronization and obtain a [`RunningClient`]. +#[wasm_bindgen] +pub struct Client { + pub(crate) client_context: ClientContext, + storage: Storage, + chain_listener_config: ChainListenerConfig, +} + #[wasm_bindgen] impl Client { - /// Creates a new client and connects to the network. + /// Creates a new client without starting the chain listener. + /// + /// After creating the client, you can perform operations like `addOwner` + /// via `chain()`. Call `start()` to begin background chain synchronization + /// and obtain a [`RunningClient`]. /// /// # Errors /// On transport or protocol error, if persistent storage is @@ -65,6 +67,7 @@ impl Client { let default = wallet.default; let genesis_config = wallet.genesis_config.clone(); + let chain_listener_config = options.chain_listener_config.clone(); let client = linera_client::ClientContext::new( storage.clone(), @@ -80,38 +83,81 @@ impl Client { // The `Arc` here is useless, but it is required by the `ChainListener` API. #[expect(clippy::arc_with_non_send_sync)] let client = Arc::new(AsyncMutex::new(client)); - let client_clone = client.clone(); - let cancellation_token = tokio_util::sync::CancellationToken::new(); - let chain_listener = ChainListener::new( - options.chain_listener_config, - client_clone, + + Ok(Client { + client_context: client, storage, + chain_listener_config, + }) + } + + /// Connect to a chain on the Linera network. + /// + /// # Errors + /// + /// If the wallet could not be read or chain synchronization fails. + #[wasm_bindgen] + pub async fn chain(&self, chain: ChainId, options: Option) -> Result { + make_chain(&self.client_context, chain, options).await + } + + /// Cleanly shut down the client without starting the chain listener. + /// + /// # Errors + /// + /// If the context is being referenced by any other objects (chains, + /// applications…). Free these with `.free()` before disposing of this + /// object. + #[wasm_bindgen(js_name = asyncDispose)] + pub async fn async_dispose(self) -> Result<()> { + Arc::into_inner(self.client_context).ok_or(Error::new( + "Client disposed while being referenced elsewhere", + ))?; + Ok(()) + } + + /// Starts the chain listener for background synchronization, consuming this + /// `Client` and returning a [`RunningClient`]. + /// + /// # Errors + /// If the chain listener fails to start. + #[wasm_bindgen] + pub async fn start(self) -> Result { + let cancellation_token = CancellationToken::new(); + let chain_listener_config = self.chain_listener_config; + + let chain_listener_handle = start_listener( + chain_listener_config.clone(), + self.client_context.clone(), + self.storage, cancellation_token.clone(), - tokio::sync::mpsc::unbounded_channel().1, - true, // Enable background sync ) - .run() .await?; - let (run_chain_listener, chain_listener_result) = async move { - let result = chain_listener.await.map_err(|error| { - tracing::error!("ChainListener error: {error:?}"); - Rc::new(error) - }); - tracing::debug!("chain listener completed"); - result - } - .remote_handle(); - - wasm_bindgen_futures::spawn_local(run_chain_listener); - - Ok(Self { - client_context: client, + Ok(RunningClient { + client_context: self.client_context, cancellation_token, - chain_listener_result: chain_listener_result.shared(), + chain_listener_handle: Box::pin(chain_listener_handle), + chain_listener_config, }) } +} + +/// The full client API, exposed to the wallet implementation. Calls +/// to this API can be trusted to have originated from the user's +/// request. +/// +/// Obtained by calling [`Client::start()`]. +#[wasm_bindgen] +pub struct RunningClient { + pub(crate) client_context: ClientContext, + cancellation_token: CancellationToken, + chain_listener_handle: Pin>>>>, + chain_listener_config: ChainListenerConfig, +} +#[wasm_bindgen] +impl RunningClient { /// Connect to a chain on the Linera network. /// /// # Errors @@ -119,20 +165,31 @@ impl Client { /// If the wallet could not be read or chain synchronization fails. #[wasm_bindgen] pub async fn chain(&self, chain: ChainId, options: Option) -> Result { - let options = options.unwrap_or_default(); - let mut chain_client = self - .client_context - .lock() + make_chain(&self.client_context, chain, options).await + } + + /// Stops the chain listener and returns a [`Client`] that can be reconfigured + /// and restarted. + /// + /// # Errors + /// Propagates any errors that occurred during background execution of the + /// chain listener. + #[wasm_bindgen] + pub async fn stop(self) -> Result { + self.cancellation_token.cancel(); + + let storage = self + .chain_listener_handle .await - .make_chain_client(chain) - .await?; - if let Some(owner) = options.owner { - chain_client.set_preferred_owner(owner); - } + .map_err(|e| match Rc::into_inner(e) { + Some(e) => Error::from(e), + None => Error::new("chain listener error (details unavailable)"), + })?; - Ok(Chain { - chain_client, - client: self.clone(), + Ok(Client { + client_context: self.client_context, + storage, + chain_listener_config: self.chain_listener_config, }) } @@ -151,16 +208,67 @@ impl Client { pub async fn async_dispose(self) -> Result<()> { self.cancellation_token.cancel(); - if let Err(Some(e)) = self.chain_listener_result.await.map_err(Rc::into_inner) { - return Err(e.into()); + if let Err(e) = self.chain_listener_handle.await { + if let Some(e) = Rc::into_inner(e) { + return Err(e.into()); + } } - let context = Arc::into_inner(self.client_context).ok_or(Error::new( + Arc::into_inner(self.client_context).ok_or(Error::new( "Client disposed while being referenced elsewhere", ))?; - drop(context); - Ok(()) } } + +/// Shared implementation for creating a [`Chain`] from a [`ClientContext`]. +async fn make_chain( + client_context: &ClientContext, + chain: ChainId, + options: Option, +) -> Result { + let options = options.unwrap_or_default(); + let mut chain_client = client_context.lock().await.make_chain_client(chain).await?; + if let Some(owner) = options.owner { + chain_client.set_preferred_owner(owner); + } + + Ok(Chain { + client_context: client_context.clone(), + chain_client, + }) +} + +async fn start_listener( + config: ChainListenerConfig, + context: ClientContext, + storage: Storage, + cancellation_token: CancellationToken, +) -> Result>>> { + tracing::debug!("starting chain listener..."); + let chain_listener = ChainListener::new( + config, + context, + storage, + cancellation_token, + tokio::sync::mpsc::unbounded_channel().1, + true, // Enable background sync + ) + .run() + .await?; + + let (run_chain_listener, chain_listener_handle) = async move { + let result = chain_listener.await.map_err(|error| { + tracing::error!("ChainListener error: {error:?}"); + Rc::new(error) + }); + tracing::debug!("chain listener completed"); + result + } + .remote_handle(); + + wasm_bindgen_futures::spawn_local(run_chain_listener); + + Ok(chain_listener_handle) +} diff --git a/web/@linera/client/src/lib.rs b/web/@linera/client/src/lib.rs index b83bc867a223..803fe0f7a6d0 100644 --- a/web/@linera/client/src/lib.rs +++ b/web/@linera/client/src/lib.rs @@ -24,7 +24,7 @@ use wasm_bindgen::prelude::*; use web_sys::wasm_bindgen; pub mod client; -pub use client::Client; +pub use client::{Client, RunningClient}; pub mod chain; pub use chain::Chain; pub mod error; From 9a18da7d4c1c7c9cb6e1d283fa63e0bcce29c462 Mon Sep 17 00:00:00 2001 From: Mathieu Baudet <1105398+ma2bd@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:27:46 -0400 Subject: [PATCH 2/3] fix CI --- linera-faucet/server/src/lib.rs | 2 +- linera-service/src/node_service.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/linera-faucet/server/src/lib.rs b/linera-faucet/server/src/lib.rs index c0d21bfc80e2..0187b0d6d59a 100644 --- a/linera-faucet/server/src/lib.rs +++ b/linera-faucet/server/src/lib.rs @@ -1292,7 +1292,7 @@ where .with_graceful_shutdown(cancellation_token.cancelled_owned()) .into_future(); futures::select! { - result = Box::pin(chain_listener).fuse() => result?, + result = Box::pin(chain_listener).fuse() => { result?; }, _ = Box::pin(batch_processor_task).fuse() => {}, result = Box::pin(server).fuse() => result?, }; diff --git a/linera-service/src/node_service.rs b/linera-service/src/node_service.rs index 9150057e3d7c..8d739b1832db 100644 --- a/linera-service/src/node_service.rs +++ b/linera-service/src/node_service.rs @@ -1402,7 +1402,7 @@ where .with_graceful_shutdown(cancellation_token.cancelled_owned()) .into_future(); futures::select! { - result = chain_listener => result?, + result = chain_listener => { result?; }, result = Box::pin(server).fuse() => result?, }; From dd97736b08bf4570393e4826c5deb8bfd5e30679 Mon Sep 17 00:00:00 2001 From: Mathieu Baudet <1105398+ma2bd@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:18:18 -0400 Subject: [PATCH 3/3] update example apps --- examples/bridge-demo/index.html | 2 +- examples/counter/index.html | 2 +- examples/counter/metamask/index.html | 13 +++++++++---- examples/native-fungible/index.html | 2 +- examples/native-fungible/metamask/index.html | 13 +++++++++---- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/examples/bridge-demo/index.html b/examples/bridge-demo/index.html index 3478c9b1df5e..06a86d12073a 100644 --- a/examples/bridge-demo/index.html +++ b/examples/bridge-demo/index.html @@ -665,7 +665,7 @@

Chain -

lineraOwner = signer.address(); lineraChain = await faucet.claimChain(wallet, lineraOwner); - const client = await new linera.Client(wallet, signer); + const client = await (await new linera.Client(wallet, signer)).start(); const chain = await client.chain(lineraChain, { owner: lineraOwner }); wrappedApp = await chain.application(APP_ID); console.log('[bridge-demo] Linera initialized', { lineraChain, lineraOwner, APP_ID, BRIDGE_APP_ID }); diff --git a/examples/counter/index.html b/examples/counter/index.html index 5a4de203425b..5a5736c01624 100644 --- a/examples/counter/index.html +++ b/examples/counter/index.html @@ -64,7 +64,7 @@

Chain history for requesting chain… const chainId = await faucet.claimChain(wallet, owner); document.getElementById('owner').innerText = owner; document.getElementById('chain-id').innerText = chainId; - const client = await new linera.Client(wallet, signer); + const client = await (await new linera.Client(wallet, signer)).start(); let chain = await client.chain(chainId); const counter = await chain.application(import.meta.env.LINERA_APPLICATION_ID); const logs = document.getElementById('logs'); diff --git a/examples/counter/metamask/index.html b/examples/counter/metamask/index.html index 317c4801dbe0..a43d7363bb6b 100644 --- a/examples/counter/metamask/index.html +++ b/examples/counter/metamask/index.html @@ -75,11 +75,16 @@

Chain history for requesting chain… const autosigner = linera.signer.PrivateKey.createRandom(); wallet.setOwner(chainId, autosigner.address()); - const client = await new linera.Client(wallet, new linera.signer.Composite(autosigner, signer)); - const chain = await client.chain(chainId, { owner }); + const deferredClient = await new linera.Client(wallet, new linera.signer.Composite(autosigner, signer)); + + // For autosigning: add the in-memory signer as an owner before starting the chain listener, + // so the user only needs to sign once. + const deferredChain = await deferredClient.chain(chainId, { owner }); + await deferredChain.addOwner(autosigner.address()); + deferredChain.free(); - // For autosigning: we then add the in-memory signer as an owner of the chain to allow it to sign transactions. - await chain.addOwner(autosigner.address()); + const client = await deferredClient.start(); + const chain = await client.chain(chainId, { owner }); // Connect to the counter application on the chain. const counter = await chain.application(import.meta.env.LINERA_APPLICATION_ID); diff --git a/examples/native-fungible/index.html b/examples/native-fungible/index.html index 72e8325bb71c..2994bc76a6c7 100644 --- a/examples/native-fungible/index.html +++ b/examples/native-fungible/index.html @@ -224,7 +224,7 @@

Chain history for requesting a new microchai const wallet = await faucet.createWallet(); const owner = signer.address(); const chainId = await faucet.claimChain(wallet, owner); - const client = await new linera.Client(wallet, signer); + const client = await (await new linera.Client(wallet, signer)).start(); document.querySelector('#chain-id').innerText = chainId; document.querySelector('#owner').innerText = owner; diff --git a/examples/native-fungible/metamask/index.html b/examples/native-fungible/metamask/index.html index 10b7e4f01b85..936b21d99e36 100644 --- a/examples/native-fungible/metamask/index.html +++ b/examples/native-fungible/metamask/index.html @@ -221,11 +221,16 @@

Chain history for requesting a new microchai const autosigner = linera.signer.PrivateKey.createRandom(); wallet.setOwner(chainId, autosigner.address()); - const client = await new linera.Client(wallet, new linera.signer.Composite(autosigner, signer)); - const chain = await client.chain(chainId, { owner }); + const deferredClient = await new linera.Client(wallet, new linera.signer.Composite(autosigner, signer)); + + // For autosigning: add the in-memory signer as an owner before starting the chain listener, + // so the user only needs to sign once. + const deferredChain = await deferredClient.chain(chainId, { owner }); + await deferredChain.addOwner(autosigner.address()); + deferredChain.free(); - // For autosigning: we then add the in-memory signer as an owner of the chain to allow it to sign transactions. - await chain.addOwner(autosigner.address()); + const client = await deferredClient.start(); + const chain = await client.chain(chainId, { owner }); // Connect to the fungible application on the chain. const application = await chain.application(import.meta.env.LINERA_APPLICATION_ID);