From d4d6bb23b92056ebafec0b55591de1fa2910cecd Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Fri, 15 May 2026 21:53:03 -0700 Subject: [PATCH 1/2] chore: document WebUIHandler thread-safety WebUIHandler is auto-derived Send + Sync (its only field is a function pointer plugin factory), and the existing doc comment already says "plugin instances are created per-render ... allowing concurrent renders with &self". But neither the type-level docs nor the rust integration page state Send + Sync explicitly, so an adopter has to either read the field list and reason about it themselves, or try sharing one across threads and hope the compiler agrees. This PR: - Expands the doc comment on `WebUIHandler` with a "Thread safety" section that states `Send + Sync`, explains why (function-pointer factory + per-render local context), and shows the `Arc` sharing pattern. - Adds a `Thread safety` section to `docs/guide/integrations/rust.md` with the same `Arc` example so adopters following the integration page don't have to read the rustdoc. - Adds a compile-time `assert_send_sync::()` inside the existing `#[cfg(test)]` module. If a future field breaks Send or Sync, the build fails immediately and prompts an update to both the type and the docs. No behavior change; docs + one compile-time assertion. --- crates/webui-handler/src/lib.rs | 29 +++++++++++++++++++++++++ docs/guide/integrations/rust.md | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index eef67b88..aa5f380b 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -187,6 +187,27 @@ impl<'a> RenderOptions<'a> { /// /// The handler is stateless: plugin instances are created per-render from /// the stored factory function, allowing concurrent renders with `&self`. +/// +/// # Thread safety +/// +/// `WebUIHandler` is `Send + Sync` (auto-derived). The only field is a +/// function pointer to a plugin factory; nothing in the handler itself +/// is borrowed or mutable across renders. Per-render state lives in a +/// local [`WebUIProcessContext`] created inside [`render`](Self::render). +/// +/// This means you can share a single handler across threads without +/// locking. The typical pattern is to construct it once at startup, +/// wrap it in an [`Arc`](std::sync::Arc) (or store it in router +/// `State`), and call [`render`](Self::render) with `&self` from +/// any request task: +/// +/// ```ignore +/// use std::sync::Arc; +/// use webui::WebUIHandler; +/// +/// let handler = Arc::new(WebUIHandler::new()); +/// // ... clone `handler` into each request handler closure ... +/// ``` pub struct WebUIHandler { plugin_factory: Option Box>, } @@ -1536,6 +1557,14 @@ mod tests { }; use webui_test_utils::test_json; + // Compile-time guarantee that the handler can be shared across threads. + // If a future change introduces a non-Send/Sync field, this fails to + // compile, prompting an update to both the type and its documentation. + const _: fn() = || { + fn assert_send_sync() {} + assert_send_sync::(); + }; + // A simple test writer implementation struct TestWriter { content: RefCell, diff --git a/docs/guide/integrations/rust.md b/docs/guide/integrations/rust.md index a42e4011..964f6701 100644 --- a/docs/guide/integrations/rust.md +++ b/docs/guide/integrations/rust.md @@ -256,3 +256,41 @@ HttpResponse::Ok() | `MissingFragment(String)` | `entry_id` not found in the protocol. | | `TypeError(String)` / `Evaluation(String)` | Template/expression runtime errors. | +## Thread safety + +`WebUIHandler` is `Send + Sync`. The handler is stateless: per-render state lives in a local context created inside `handle`, and the only stored field is a plugin factory function pointer. Construct one handler at startup, wrap it in an [`Arc`], and call `handler.handle(...)` from any request task without locking. + +### Sharing a handler across tasks + +The realistic pattern is "construct once, clone into many tasks": + +```rust +use std::sync::Arc; +use webui::{RenderOptions, WebUIHandler, WebUIProtocol}; +use webui_handler::plugin::fast_v3::FastV3HydrationPlugin; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let protocol = Arc::new(WebUIProtocol::from_protobuf_file("dist/protocol.bin")?); + let handler = Arc::new(WebUIHandler::with_plugin(|| { + Box::new(FastV3HydrationPlugin::new()) + })); + + while let Some(request) = accept_request().await { + let handler = Arc::clone(&handler); + let protocol = Arc::clone(&protocol); + tokio::spawn(async move { + let options = RenderOptions::new("index.html", &request.path); + let mut writer = request.into_writer(); + if let Err(error) = handler.handle(&protocol, &request.state, &options, &mut writer) { + tracing::error!(?error, "render failed"); + } + }); + } + Ok(()) +} +``` + +The same shape applies to other async runtimes (`actix_web::rt::spawn`, `smol::spawn`, etc.) and to thread pools (`std::thread::spawn` with a `move` closure cloning the `Arc`). Because `handle` takes `&self`, no `Mutex` is needed; concurrent renders run in parallel. + +[`Arc`]: https://doc.rust-lang.org/std/sync/struct.Arc.html From fbe0e3de49093cabea8428d7203add0248daac98 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Tue, 19 May 2026 14:31:28 -0700 Subject: [PATCH 2/2] docs(handler): fix rustdoc link, update DESIGN.md to match current handler API Addresses review feedback on #297: - Rustdoc on `WebUIHandler` now links to `Self::handle` (the canonical documented entry point used in the integration page) instead of `Self::render`. Reframes 'is Send + Sync' as 'is already Send + Sync' to make clear this PR documents an existing property, not a new one. - Drops the broken private intra-doc link to `WebUIProcessContext` (the struct is private; rustdoc emitted a private_intra_doc_links warning). Replaced with plain code text. - DESIGN.md handler section updated to match current code: * `plugin: Option>` -> `plugin_factory: Option Box>` * `with_plugin(plugin: Box<...>)` -> `with_plugin(factory: fn() -> Box<...>)` * `handle(&mut self, ...)` -> `handle(&self, ...)` * New 'Thread safety' subsection making the Send + Sync / Arc-without-Mutex contract explicit in the spec. - DESIGN.md partial/action/template free-function signatures updated to match `route_handler.rs` (they no longer take a handler argument, which was the same staleness that contradicted the thread-safety story by implying `&mut WebUIHandler` was required). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 56 ++++++++++++++++++++------------- crates/webui-handler/src/lib.rs | 10 +++--- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 0d5fe0a5..e7cd2ce4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -486,7 +486,7 @@ pub enum ExpressionError { ### Core API ```rust pub struct WebUIHandler { - plugin: Option>, + plugin_factory: Option Box>, } /// Options controlling how the handler renders a protocol. @@ -515,10 +515,10 @@ impl<'a> RenderOptions<'a> { impl WebUIHandler { pub fn new() -> Self; - pub fn with_plugin(plugin: Box) -> Self; + pub fn with_plugin(factory: fn() -> Box) -> Self; pub fn handle( - &mut self, + &self, protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>, @@ -527,6 +527,16 @@ impl WebUIHandler { } ``` +#### Thread safety + +`WebUIHandler` is `Send + Sync` (auto-derived; the sole field is a function +pointer). The handler is stateless — per-render state lives in a local +context created inside `handle`, and plugin instances are created fresh per +render from the stored factory. A single handler can therefore be shared +across threads (typically wrapped in `Arc`) and `handle` called with `&self` +from concurrent tasks. No `Mutex` is required; concurrent renders run in +parallel. + #### ProtocolIndex `ProtocolIndex` is a pre-computed index over a `WebUIProtocol` that accelerates @@ -564,35 +574,39 @@ pub fn match_route_cached( #### Partial and Action Response Functions +These are free functions in `webui_handler::route_handler`; they operate +on the protocol + index directly and do not take a `WebUIHandler` (the +handler is only needed when emitting HTML through a `ResponseWriter`). + ```rust /// Produce a JSON partial response for client-side navigation. -/// `protocol_index` provides cached route matching and component indices. +/// `index` provides cached route matching and component indices. pub fn render_partial( - handler: &mut WebUIHandler, protocol: &WebUIProtocol, - protocol_index: &mut ProtocolIndex, - options: &RenderOptions<'_>, + entry_id: &str, + request_path: &str, inventory_hex: &str, -) -> Result; + index: &mut ProtocolIndex, +) -> Result; -/// Produce a response for a mutation action (POST). -/// `protocol_index` provides cached route matching and component indices. +/// Produce a JSON response for a POST mutation action. +/// `index` provides cached route matching for invalidation-tag resolution. pub fn render_action_response( - handler: &mut WebUIHandler, protocol: &WebUIProtocol, - protocol_index: &mut ProtocolIndex, - options: &RenderOptions<'_>, - inventory_hex: &str, -) -> Result; + state: Value, + entry_id: &str, + request_path: &str, + index: &mut ProtocolIndex, +) -> Value; -/// Emit client template scripts/markup for the given components. -/// `protocol_index` provides the component index for inventory tracking. +/// Return compiled templates and CSS for specific components by tag name. +/// `index` provides the component index for inventory tracking. pub fn render_component_templates( - handler: &WebUIHandler, protocol: &WebUIProtocol, - protocol_index: &ProtocolIndex, - components: &[String], -) -> Vec; + component_tags: &[&str], + inventory_hex: &str, + index: &ProtocolIndex, +) -> Result; ``` #### Component Inventory Functions diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index aa5f380b..63481f4d 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -190,15 +190,15 @@ impl<'a> RenderOptions<'a> { /// /// # Thread safety /// -/// `WebUIHandler` is `Send + Sync` (auto-derived). The only field is a -/// function pointer to a plugin factory; nothing in the handler itself -/// is borrowed or mutable across renders. Per-render state lives in a -/// local [`WebUIProcessContext`] created inside [`render`](Self::render). +/// `WebUIHandler` is already `Send + Sync` (auto-derived). The only field +/// is a function pointer to a plugin factory; nothing in the handler +/// itself is borrowed or mutable across renders. Per-render state lives +/// in a private `WebUIProcessContext` created inside [`handle`](Self::handle). /// /// This means you can share a single handler across threads without /// locking. The typical pattern is to construct it once at startup, /// wrap it in an [`Arc`](std::sync::Arc) (or store it in router -/// `State`), and call [`render`](Self::render) with `&self` from +/// `State`), and call [`handle`](Self::handle) with `&self` from /// any request task: /// /// ```ignore