diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..889ae45 --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,35 @@ +allowed-idents-below-min-chars = [ + # clippy defaults + "Eq", + "Err", + "Ge", + "Gt", + "Le", + "Lt", + "Ne", + "Ok", + # standard Rust idioms + "'_", # elided lifetime + "Rc", # std::rc::Rc, peer of Arc + "cx", # async Context (Future::poll convention) + "e", # error binding in `Err(e)` + "fs", # `use std::fs` / `use fs_err as fs` alias + "ip", # IP address (networking) + "rx", # tokio/std mpsc transmit/receive convention + "tx", + # math/engineering conventions + "t0", + "t1", # timestamps + "x1", + "x2", # bounding-box coords + "y1", + "y2", + # workspace conventions + "Io", # io::Error #[from] variant + "Js", # ModuleKind::Js variant (peer of Wasi) + "et", # `et:ws-wasi` WIT package — appears in generated `bindings::et::…` + "id", # short alias for identifier inside closures + "op", # operation-name parameter in et-web JS-interop traits + "ws", # edge_toolkit::ws module (websocket types) +] +min-ident-chars-threshold = 2 diff --git a/.gitignore b/.gitignore index d0928c7..07cb91e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.claude/ *.wasm *.onnx target/ diff --git a/.mise.toml b/.mise.toml index e9130d8..aa95113 100644 --- a/.mise.toml +++ b/.mise.toml @@ -4,6 +4,7 @@ cargo-binstall = "latest" "cargo:ast-grep" = "latest" "cargo:aube" = "latest" "cargo:cargo-expand" = "latest" +"cargo:cargo-unmaintained" = "latest" "cargo:taplo-cli" = "latest" "cargo:wasm-pack" = "latest" "cargo:watchexec-cli" = "latest" @@ -101,6 +102,7 @@ depends = [ "ast-grep-check", "cargo-check", "cargo-clippy", + "cargo-doc-check", "cargo-fmt-check", "dart-check", "dotnet-check", @@ -130,6 +132,11 @@ run = "cargo +nightly fmt -- --check" [tasks.cargo-clippy] run = "cargo clippy --workspace --tests" +[tasks.cargo-doc-check] +description = "Build rustdoc with -D warnings to fail on any doc issues" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc --workspace --no-deps --document-private-items" + [tasks.cargo-clippy-fix] run = "cargo clippy --fix --allow-dirty --allow-staged --workspace --tests" diff --git a/.taplo.toml b/.taplo.toml index 085867c..9481d68 100644 --- a/.taplo.toml +++ b/.taplo.toml @@ -12,3 +12,11 @@ keys = ["package"] [[rule]] formatting = { reorder_arrays = false } keys = ["workspace"] + +# Forbid `path = "..."` dependencies in non-root Cargo.toml files. The root +# Cargo.toml's `[workspace.dependencies]` is the only legitimate place for +# path deps; member crates reference them via `dep.workspace = true`. +[[rule]] +exclude = ["Cargo.toml"] +include = ["**/Cargo.toml"] +schema = { path = "config/taplo/no-path-deps.schema.json" } diff --git a/Cargo.lock b/Cargo.lock index 7e5ff71..bb3869c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1612,6 +1612,7 @@ name = "et-ws-data1" version = "0.1.0" dependencies = [ "edge-toolkit", + "et-web", "et-ws-wasm-agent", "js-sys", "serde", diff --git a/Cargo.toml b/Cargo.toml index fbea5c5..3dcf49f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,13 +45,19 @@ base64 = "0.22.1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.4", features = ["derive"] } edge-toolkit = { path = "libs/edge-toolkit" } +et-modules-service = { path = "services/modules" } et-otlp = { path = "libs/et-otlp" } +et-storage-service = { path = "services/storage" } et-web = { path = "libs/web" } +et-ws-service = { path = "services/ws" } +et-ws-test-server = { path = "services/ws-test-server" } +et-ws-wasm-agent = { path = "services/ws-wasm-agent" } fs-err = "3" lets_find_up = "0.0.4" log = "0.4" onnx-extractor = "0.3" opentelemetry = "0.31" +otlp-mock = { path = "libs/otlp-mock" } rstest = "0.26" secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } @@ -60,7 +66,9 @@ serde-inline-default = "1.0" serde_default = "0.2" serde_json = "1" serde_yaml = "0.9" +temp-env = "0.3" tempfile = "3" +testing_logger = "0.1" thiserror = "2" toml = "0.8" tracing = "0.1" @@ -72,3 +80,135 @@ tracing-actix-web = { version = "0.7", default-features = false, features = [ tracing-opentelemetry = "0.32" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["serde", "v4", "v7"] } + +[profile.release.package.et-ws-wasm-agent] +opt-level = "s" + +[workspace.lints.rust] +# groups +unused = { level = "deny", priority = -1 } +# specific lints +dead_code = "deny" +let_underscore = { level = "deny", priority = -1 } +macro_use_extern_crate = "deny" +non_ascii_idents = "deny" +# Allowing private_bounds & private_interfaces & unnameable_types reduces the noise of using `visibility` crate. +private_bounds = "allow" +private_interfaces = "allow" +unnameable_types = "allow" + +redundant_imports = "deny" +redundant_lifetimes = "deny" +trivial_numeric_casts = "deny" +# raises an error on unmet expect statements so they are easily found and fixed +unfulfilled_lint_expectations = "deny" +unsafe_attr_outside_unsafe = "deny" +unsafe_code = "deny" +unsafe_op_in_unsafe_fn = "deny" +# unused_crate_dependencies fires per test binary: dev-deps declared once but consumed by only a +# subset of `tests/*.rs` files (each compiled as its own crate) are flagged as unused in the +# sibling test crates that don't import them. Tracked at rust-lang/rust#95513; re-enable when +# rustc gains a workspace-aware view. +# unused_crate_dependencies = "deny" +unused_results = "warn" + +[workspace.lints.rustdoc] +private_intra_doc_links = { level = "deny", priority = 8 } + +[workspace.lints.clippy] +# priority numbers are arbitrary ints; higher overrides lower. +# blanket clippy category rules appear at the top, while sub category lints appear below with priority levels +all = { level = "deny", priority = -1 } +complexity = { level = "deny", priority = -1 } +correctness = { level = "deny", priority = -1 } +implicit_return = { level = "allow", priority = 8 } +nursery = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +perf = { level = "deny", priority = -1 } +restriction = { level = "deny", priority = -1 } +style = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +# blanket_clippy_restriction_lints allows the above blanket deny +blanket_clippy_restriction_lints = { level = "allow", priority = 8 } +# allow lint groups to override priorities +integer_division_remainder_used = { level = "deny", priority = 8 } +lint_groups_priority = { level = "allow", priority = 8 } +# Allowing question_mark_used is an explicit implementation choice. +question_mark_used = { level = "allow", priority = 8 } +# Allowing std_instead_of_alloc as we have no reason yet to choose alloc,core over std +single_call_fn = { level = "deny", priority = 4 } +std_instead_of_alloc = { level = "allow", priority = 8 } +std_instead_of_core = { level = "allow", priority = 8 } +str_to_string = { level = "allow", priority = 4 } +struct_excessive_bools = { level = "allow", priority = 8 } +# Allowing shadow_reuse, shadow_same & shadow_unrelated is an explicit style choice, preferring to allow +# developers to reuse a name even if its type changes. +semicolon_if_nothing_returned = { level = "deny", priority = 4 } +shadow_reuse = { level = "allow", priority = 4 } +shadow_same = { level = "allow", priority = 4 } +shadow_unrelated = { level = "allow", priority = 4 } +# Block-scoped statements like `{ *guard = ...; }` use the closing brace to drop borrows; +# moving the `;` outside risks extending MutexGuard lifetimes past the intended scope. +semicolon_outside_block = { level = "allow", priority = 4 } +# Allowing separated_literal_suffix & unseparated_literal_suffix is an explicit style choice. +separated_literal_suffix = { level = "allow", priority = 4 } +unseparated_literal_suffix = { level = "allow", priority = 4 } +# Allowing absolute_paths is an explicit style choice. +absolute_paths = { level = "allow", priority = 4 } +# Allowing pub_use is an explicit style choice to streamline coding. +impl_trait_in_params = { level = "deny", priority = 4 } +pub_use = { level = "allow", priority = 4 } +# Allowing module_name_repetitions & mod_module_files are an explicit code layout choice. +mod_module_files = { level = "allow", priority = 4 } +module_name_repetitions = { level = "allow", priority = 4 } +self_named_module_files = { level = "deny", priority = 4 } +# Allowing missing_docs_in_private_items is an explicit but temporary choice to focus on documentation of public items. +missing_docs_in_private_items = { level = "allow", priority = 2 } +# missing_inline_in_public_items forces use of `#[inline]` on public items, which is not desirable. +missing_inline_in_public_items = { level = "allow", priority = 4 } +# Allowing missing_errors_doc as fixing unwrap's is the more important task. +future_not_send = { level = "deny", priority = 4 } +missing_errors_doc = { level = "allow", priority = 4 } +# pattern_type_mismatch fights with idiomatic match ergonomics (e.g. `for (k, v) in &map`, +# `if let Some(x) = &option`). +pattern_type_mismatch = { level = "allow", priority = 4 } +unused_async = { level = "deny", priority = 2 } +# as_conversions & cast_lossless & cast_possible_wrap should be replaced with safer wrapped conversion +as_conversions = "deny" +cast_lossless = "deny" +cast_possible_wrap = "deny" +clone_on_ref_ptr = { level = "deny", priority = 4 } +infinite_loop = "deny" +too_many_arguments = { level = "deny", priority = 2 } +# Allowing missing_trait_methods to avoid forcing explicit implementation of default trait methods +missing_trait_methods = { level = "allow", priority = 4 } +pub_with_shorthand = { level = "allow", priority = 4 } +pub_without_shorthand = { level = "deny", priority = 4 } +# clippy removing pub(crate) is quite confusing at times +redundant_pub_crate = { level = "allow", priority = 4 } + +type_complexity = { level = "deny", priority = 4 } + +min_ident_chars = { level = "deny", priority = 4 } + +# Allowing arbitrary_source_item_ordering: strict alphabetical ordering destroys semantic grouping +# (e.g. WsMessage variants in connection-lifecycle order, struct fields ordered by importance). +arbitrary_source_item_ordering = { level = "allow", priority = 4 } + +# Force use of #[expect(..)] instead of #[allow(..)] +allow_attributes = { level = "deny", priority = 4 } +# This lint also covers #[expect] without a reason, so allow this to prevent silly reason values. +allow_attributes_without_reason = { level = "allow", priority = 4 } + +let_underscore_must_use = { level = "deny", priority = 4 } +let_underscore_untyped = { level = "deny", priority = 4 } +missing_panics_doc = { level = "allow", priority = 4 } + +[workspace.metadata.unmaintained] +ignore = [ + # It is a hg repo, which is not supported by cargo-unmaintained + "oorandom", + # https://github.com/rustsec/advisory-db/issues/2132 + "serde_yaml", + "unsafe-libyaml", +] diff --git a/config/ast-grep/rules/no-doctest.yml b/config/ast-grep/rules/no-doctest.yml new file mode 100644 index 0000000..3299c37 --- /dev/null +++ b/config/ast-grep/rules/no-doctest.yml @@ -0,0 +1,18 @@ +id: no-doctest +language: Rust +severity: error +message: | + Doctests are forbidden. Move runnable examples into a `tests/` file + (every test file must start with `#![cfg(test)]`). +rule: + any: + - kind: line_comment + all: + - regex: "^(///|//!)" + - regex: "```" + - kind: block_comment + all: + - regex: '^/\*[*!]' + - regex: "```" +ignores: + - generated/** diff --git a/config/taplo/no-path-deps.schema.json b/config/taplo/no-path-deps.schema.json new file mode 100644 index 0000000..1054aac --- /dev/null +++ b/config/taplo/no-path-deps.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Workspace member Cargo.toml — no path dependencies", + "description": "Forbids `path = ...` deps outside workspace root; `const` is a hack so taplo prints it as the error.", + "definitions": { + "depTable": { + "type": "object", + "additionalProperties": { + "properties": { + "path": { + "const": "forbidden outside workspace root; declare in [workspace.dependencies] and use `.workspace = true`" + } + } + } + } + }, + "type": "object", + "patternProperties": { + "^(dev-|build-)?dependencies$": { "$ref": "#/definitions/depTable" } + }, + "properties": { + "target": { + "type": "object", + "additionalProperties": { + "type": "object", + "patternProperties": { + "^(dev-|build-)?dependencies$": { "$ref": "#/definitions/depTable" } + } + } + } + } +} diff --git a/libs/edge-toolkit/Cargo.toml b/libs/edge-toolkit/Cargo.toml index 47fcacb..47e9265 100644 --- a/libs/edge-toolkit/Cargo.toml +++ b/libs/edge-toolkit/Cargo.toml @@ -6,6 +6,9 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +doctest = false + [dependencies] base64.workspace = true lets_find_up.workspace = true @@ -21,6 +24,9 @@ thiserror.workspace = true [dev-dependencies] rstest.workspace = true -temp-env = "0.3" +temp-env.workspace = true tempfile.workspace = true -testing_logger = "0.1" +testing_logger.workspace = true + +[lints] +workspace = true diff --git a/libs/edge-toolkit/src/args.rs b/libs/edge-toolkit/src/args.rs index 7fec7d8..b860b2b 100644 --- a/libs/edge-toolkit/src/args.rs +++ b/libs/edge-toolkit/src/args.rs @@ -7,11 +7,15 @@ /// which may happen if the invoking environment is not similar to a "std" environment. #[must_use] pub fn executable_name() -> String { - executable_name_inner(std::env::args().collect()) + executable_name_inner(&std::env::args().collect::>()) } -#[expect(clippy::unwrap_used)] -pub fn executable_name_inner(args: Vec) -> String { +#[must_use] +#[expect( + clippy::unwrap_used, + reason = "argv[0] and file_stem are guaranteed under a std environment" +)] +pub fn executable_name_inner(args: &[String]) -> String { let path = args.first().unwrap(); let path = std::path::PathBuf::from(path); path.file_stem().unwrap().to_string_lossy().to_string() diff --git a/libs/edge-toolkit/src/auth.rs b/libs/edge-toolkit/src/auth.rs index 2695717..9950443 100644 --- a/libs/edge-toolkit/src/auth.rs +++ b/libs/edge-toolkit/src/auth.rs @@ -1,8 +1,9 @@ -use base64::{Engine, engine::general_purpose::STANDARD as b64standard}; -use secrecy::{ExposeSecret, SecretString}; +use base64::{Engine as _, engine::general_purpose::STANDARD as b64standard}; +use secrecy::{ExposeSecret as _, SecretString}; use serde::Deserialize; #[derive(Clone, Debug, Deserialize)] +#[non_exhaustive] /// Basic Authentication config. pub struct BasicAuth { /// Username. @@ -14,17 +15,17 @@ pub struct BasicAuth { impl BasicAuth { /// Create a new `BasicAuth` instance. #[must_use] - pub fn new(username: String, password: SecretString) -> Self { + pub const fn new(username: String, password: SecretString) -> Self { Self { username, password } } - /// Add authorisation header to HashMap. + /// Add authorisation header to `HashMap`. pub fn add_basic_auth_header(&self, headers: &mut std::collections::HashMap) { let mut buf = String::default(); b64standard.encode_string( format!("{}:{}", self.username, self.password.expose_secret()).as_bytes(), &mut buf, ); - headers.insert("authorization".to_string(), format!("Basic {buf}")); + let _previous = headers.insert("authorization".to_string(), format!("Basic {buf}")); } } diff --git a/libs/edge-toolkit/src/config.rs b/libs/edge-toolkit/src/config.rs index fced1ef..839ed12 100644 --- a/libs/edge-toolkit/src/config.rs +++ b/libs/edge-toolkit/src/config.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; use serde_default::DefaultFromSerde; +use serde_inline_default::serde_inline_default; use crate::args::executable_name; use crate::auth::BasicAuth; @@ -48,9 +49,9 @@ pub fn default_modules_folders() -> Vec { return paths; } match mise_npm_modules_path("onnxruntime-web") { - Some(p) => { - log::info!("Resolved npm:onnxruntime-web modules path: {}", p.display()); - paths.push(p); + Some(path) => { + log::info!("Resolved npm:onnxruntime-web modules path: {}", path.display()); + paths.push(path); } None => { log::warn!( @@ -72,9 +73,9 @@ pub fn default_modules_folders() -> Vec { // runtime (no `micropip.install` of non-stdlib wheels) still work, and // contributors who don't need the full set can skip the 200 MB download. match mise_where("http:pyodide").or_else(|| mise_npm_modules_path("pyodide")) { - Some(p) => { - log::info!("Resolved pyodide modules path: {}", p.display()); - paths.push(p); + Some(path) => { + log::info!("Resolved pyodide modules path: {}", path.display()); + paths.push(path); } None => { log::warn!( @@ -104,17 +105,18 @@ pub fn mise_where(tool: &str) -> Option { if !output.status.success() { return None; } - let s = std::str::from_utf8(&output.stdout).ok()?; - let p = PathBuf::from(s.trim()); - p.is_dir().then_some(p) + let stdout = std::str::from_utf8(&output.stdout).ok()?; + let path = PathBuf::from(stdout.trim()); + path.is_dir().then_some(path) } -/// Returns the directory containing `` for an `npm:` mise -/// install — i.e. the `node_modules` directory you'd point `MODULES_PATHS` -/// at. Calls `mise where npm:` to find the install root, then -/// delegates to [`find_npm_modules_path_in`] to handle the per-backend -/// layout differences. Returns `None` if `mise where` fails or the -/// package isn't present in any supported layout. +/// Returns the directory containing `` for an `npm:` mise install. +/// +/// I.e. the `node_modules` directory you'd point `MODULES_PATHS` at. Calls +/// `mise where npm:` to find the install root, then delegates to +/// [`find_npm_modules_path_in`] to handle the per-backend layout differences. +/// Returns `None` if `mise where` fails or the package isn't present in any +/// supported layout. #[must_use] pub fn mise_npm_modules_path(package: &str) -> Option { let install = mise_where(&format!("npm:{package}"))?; @@ -140,9 +142,9 @@ pub fn find_npm_modules_path_in(install: &Path, package: &str) -> Option Option u16 { - Services::OtlpCollector.port() -} - -/// Default url for the otlp collector. This is the tracing endpoint path for OpenObserve trace collection. -#[must_use] -pub fn default_otlp_collector_url() -> String { - format!("http://{LOCALHOST}:{}/api/default/v1", default_otlp_collector_port()) -} - /// Default service label name for use in OpenTelemetry. /// /// Removes "-server" suffix from the invoked executable name if present, @@ -185,11 +175,12 @@ pub enum OtlpProtocol { } /// OpenTelemetry service config. +#[serde_inline_default] #[derive(Clone, Debug, DefaultFromSerde, Deserialize)] #[non_exhaustive] pub struct OtlpConfig { /// OpenTelemetry collector URL. - #[serde(default = "default_otlp_collector_url")] + #[serde_inline_default(format!("http://{LOCALHOST}:{}/api/default/v1", Services::OtlpCollector.port()))] pub collector_url: String, /// OpenTelemetry protocol. #[serde(default)] diff --git a/libs/edge-toolkit/src/input.rs b/libs/edge-toolkit/src/input.rs index cc7cf84..efffefe 100644 --- a/libs/edge-toolkit/src/input.rs +++ b/libs/edge-toolkit/src/input.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct ClusterInput { pub cluster_name: String, #[serde(default)] @@ -9,12 +10,14 @@ pub struct ClusterInput { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct Agent { pub name: String, pub resources: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct Resource { #[serde(rename = "type")] pub resource_type: String, diff --git a/libs/edge-toolkit/src/ports.rs b/libs/edge-toolkit/src/ports.rs index 00178a8..85106ef 100644 --- a/libs/edge-toolkit/src/ports.rs +++ b/libs/edge-toolkit/src/ports.rs @@ -18,7 +18,7 @@ use Services::{InsecureWebSocketServer, OtlpCollector, SecureWebSocketServer}; impl Services { /// Get the allocation port for the service. #[must_use] - pub const fn port(&self) -> u16 { + pub const fn port(self) -> u16 { match self { // OpenObserve specific http port OtlpCollector => 5080, diff --git a/libs/edge-toolkit/src/ws.rs b/libs/edge-toolkit/src/ws.rs index bb26973..4623f4b 100644 --- a/libs/edge-toolkit/src/ws.rs +++ b/libs/edge-toolkit/src/ws.rs @@ -1,5 +1,9 @@ use serde::{Deserialize, Serialize}; +#[expect( + clippy::exhaustive_enums, + reason = "wire protocol enum: variants exhaustively describe the JSON shape, downstream matches are exhaustive" +)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ConnectStatus { @@ -7,6 +11,10 @@ pub enum ConnectStatus { Reconnected, } +#[expect( + clippy::exhaustive_enums, + reason = "wire protocol enum: variants exhaustively describe the JSON shape, downstream matches are exhaustive" +)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum MessageDeliveryStatus { @@ -16,6 +24,10 @@ pub enum MessageDeliveryStatus { Broadcast, } +#[expect( + clippy::exhaustive_enums, + reason = "wire protocol enum: variants exhaustively describe the JSON shape, downstream matches are exhaustive" +)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum MessageScope { @@ -23,6 +35,10 @@ pub enum MessageScope { Broadcast, } +#[expect( + clippy::exhaustive_enums, + reason = "wire protocol enum: variants exhaustively describe the JSON shape, downstream matches are exhaustive" +)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AgentConnectionState { @@ -31,12 +47,28 @@ pub enum AgentConnectionState { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[non_exhaustive] pub struct AgentSummary { pub agent_id: String, pub state: AgentConnectionState, pub last_known_ip: Option, } +impl AgentSummary { + #[must_use] + pub const fn new(agent_id: String, state: AgentConnectionState, last_known_ip: Option) -> Self { + Self { + agent_id, + state, + last_known_ip, + } + } +} + +#[expect( + clippy::exhaustive_enums, + reason = "wire protocol enum: variants exhaustively describe the JSON shape, downstream matches are exhaustive" +)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WsMessage { diff --git a/libs/edge-toolkit/src/ws_server.rs b/libs/edge-toolkit/src/ws_server.rs index edb8473..aa34877 100644 --- a/libs/edge-toolkit/src/ws_server.rs +++ b/libs/edge-toolkit/src/ws_server.rs @@ -1,5 +1,5 @@ use std::collections::BTreeMap; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -7,6 +7,7 @@ use thiserror::Error; use crate::ws::{AgentConnectionState, AgentSummary, ConnectStatus}; #[derive(Debug, Error)] +#[non_exhaustive] pub enum RegistryError { #[error(transparent)] Io(#[from] std::io::Error), @@ -19,16 +20,18 @@ pub enum RegistryError { } impl From> for RegistryError { - fn from(_: PoisonError) -> Self { - RegistryError::LockPoisoned + fn from(_source: PoisonError) -> Self { + Self::LockPoisoned } } -/// Why an `acknowledge_message` call rejected the ack. The variant -/// itself describes *what* went wrong; the optional payload is the -/// recipient/sender id the caller can quote back in a wire-level -/// status message. +/// Why an `acknowledge_message` call rejected the ack. +/// +/// The variant itself describes *what* went wrong; the optional payload is +/// the recipient/sender id the caller can quote back in a wire-level status +/// message. #[derive(Debug, Error)] +#[non_exhaustive] pub enum AcknowledgeError { #[error("unknown acknowledging agent {0}")] UnknownAgent(String), @@ -41,12 +44,24 @@ pub enum AcknowledgeError { } impl From> for AcknowledgeError { - fn from(_: PoisonError) -> Self { - AcknowledgeError::LockPoisoned + fn from(_source: PoisonError) -> Self { + Self::LockPoisoned } } +/// Take the lock, recovering from poison by returning the inner guard. +/// +/// We never observe poisoned state in the wild — every panic-prone path +/// holds the lock briefly around infallible map ops. Recovering keeps the +/// registry usable if a future change introduces a panic under the lock. +fn lock_agents( + agents: &Mutex>>, +) -> MutexGuard<'_, BTreeMap>> { + agents.lock().unwrap_or_else(PoisonError::into_inner) +} + #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct PendingDirectMessage { pub message_id: String, pub from_agent_id: String, @@ -55,6 +70,7 @@ pub struct PendingDirectMessage { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct AgentRecord { pub state: AgentConnectionState, pub last_known_ip: Option, @@ -64,11 +80,42 @@ pub struct AgentRecord { pub pending_direct_messages: BTreeMap, } +impl AgentRecord { + /// Construct a record with no pending direct messages. + #[must_use] + pub const fn new(state: AgentConnectionState, last_known_ip: Option, session: Option) -> Self { + Self { + state, + last_known_ip, + session, + pending_direct_messages: BTreeMap::new(), + } + } + + /// Replace `pending_direct_messages` (chainable; used by persistence reloaders). + #[must_use] + pub fn with_pending_direct_messages(mut self, pending: BTreeMap) -> Self { + self.pending_direct_messages = pending; + self + } +} + #[derive(Clone)] +#[non_exhaustive] pub struct AgentRegistry { pub agents: Arc>>>, } +impl AgentRegistry { + /// Wrap a populated agent map; useful for tests building fixtures. + #[must_use] + pub fn from_agents(agents: BTreeMap>) -> Self { + Self { + agents: Arc::new(Mutex::new(agents)), + } + } +} + impl Default for AgentRegistry { fn default() -> Self { Self { @@ -80,12 +127,15 @@ impl Default for AgentRegistry { impl AgentRegistry { pub fn load(path: &std::path::Path) -> Result { if !path.exists() { - log::warn!("Registry file {:?} does not exist, starting with empty registry", path); + log::warn!( + "Registry file {} does not exist, starting with empty registry", + path.display() + ); return Ok(Self::default()); } let yaml = std::fs::read_to_string(path)?; let agents: BTreeMap> = serde_yaml::from_str(&yaml)?; - log::info!("Loaded {} agents from registry {:?}", agents.len(), path); + log::info!("Loaded {} agents from registry {}", agents.len(), path.display()); Ok(Self { agents: Arc::new(Mutex::new(agents)), }) @@ -96,8 +146,9 @@ impl AgentRegistry { pub fn save(&self, path: &std::path::Path) -> Result<(), RegistryError> { let agents = self.agents.lock()?; let yaml = serde_yaml::to_string(&*agents)?; + drop(agents); std::fs::write(path, yaml)?; - log::info!("Agent registry saved to {:?}", path); + log::info!("Agent registry saved to {}", path.display()); Ok(()) } @@ -108,7 +159,7 @@ impl AgentRegistry { client_ip: &str, session: S, ) -> (String, ConnectStatus) { - let mut agents = self.agents.lock().expect("agent registry lock poisoned"); + let mut agents = lock_agents(&self.agents); if let Some(requested_id) = requested_id && let Some(record) = agents.get_mut(&requested_id) @@ -119,7 +170,7 @@ impl AgentRegistry { return (requested_id, ConnectStatus::Reconnected); } - agents.insert( + let _previous: Option> = agents.insert( new_id.clone(), AgentRecord { state: AgentConnectionState::Connected, @@ -128,19 +179,21 @@ impl AgentRegistry { pending_direct_messages: BTreeMap::new(), }, ); + drop(agents); (new_id, ConnectStatus::Assigned) } pub fn mark_disconnected(&self, agent_id: &str) { - let mut agents = self.agents.lock().expect("agent registry lock poisoned"); + let mut agents = lock_agents(&self.agents); if let Some(record) = agents.get_mut(agent_id) { record.state = AgentConnectionState::Disconnected; record.session = None; } } + #[must_use] pub fn list_agents(&self) -> Vec { - let agents = self.agents.lock().expect("agent registry lock poisoned"); + let agents = lock_agents(&self.agents); let mut summaries = agents .iter() .map(|(agent_id, record)| AgentSummary { @@ -149,10 +202,19 @@ impl AgentRegistry { last_known_ip: record.last_known_ip.clone(), }) .collect::>(); + drop(agents); summaries.sort_by(|left, right| left.agent_id.cmp(&right.agent_id)); summaries } + /// # Panics + /// Panics if `to_agent_id` is not present in the registry — the caller is + /// expected to have validated that the recipient exists before queueing. + #[must_use] + #[expect( + clippy::expect_used, + reason = "caller contract: to_agent_id must reference a known agent" + )] pub fn queue_direct( &self, message_id: String, @@ -161,7 +223,7 @@ impl AgentRegistry { server_received_at: String, message: serde_json::Value, ) -> (PendingDirectMessage, Option) { - let mut agents = self.agents.lock().expect("agent registry lock poisoned"); + let mut agents = lock_agents(&self.agents); let recipient = agents .get_mut(to_agent_id) .expect("queue_direct called for unknown target agent"); @@ -172,21 +234,18 @@ impl AgentRegistry { server_received_at, message, }; - recipient - .pending_direct_messages - .insert(from_agent_id.to_string(), pending); - let session = recipient.session.clone(); - let pending = recipient + let _previous: Option = recipient .pending_direct_messages - .get(from_agent_id) - .expect("pending direct message was just inserted") - .clone(); + .insert(from_agent_id.to_string(), pending.clone()); + drop(agents); + (pending, session) } + #[must_use] pub fn pending_messages_for(&self, agent_id: &str) -> Vec { - let agents = self.agents.lock().expect("agent registry lock poisoned"); + let agents = lock_agents(&self.agents); agents .get(agent_id) .map(|record| { @@ -211,7 +270,7 @@ impl AgentRegistry { let sender_agent_id = recipient .pending_direct_messages .iter() - .find_map(|(id, p)| (p.message_id == message_id).then(|| id.clone())) + .find_map(|(id, pending)| (pending.message_id == message_id).then(|| id.clone())) .ok_or(AcknowledgeError::NoPendingMessage)?; // The `find_map` above drops its iterator before we re-borrow // `pending_direct_messages` mutably for the removal. The double @@ -221,26 +280,31 @@ impl AgentRegistry { .pending_direct_messages .remove(&sender_agent_id) .ok_or(AcknowledgeError::NoPendingMessage)?; - let sender_session = agents.get(&sender_agent_id).and_then(|r| r.session.clone()); + let sender_session = agents.get(&sender_agent_id).and_then(|record| record.session.clone()); + drop(agents); Ok((pending.message_id, sender_session, sender_agent_id)) } + #[must_use] pub fn connected_sessions(&self, excluding_agent_id: &str) -> Vec<(String, S)> { - let agents = self.agents.lock().expect("agent registry lock poisoned"); + let agents = lock_agents(&self.agents); agents .iter() .filter_map(|(agent_id, record)| { if agent_id == excluding_agent_id { return None; } - record.session.clone().map(|s| (agent_id.clone(), s)) + record.session.clone().map(|session| (agent_id.clone(), session)) }) .collect() } + #[must_use] pub fn agent_session(&self, agent_id: &str) -> Option { - let agents = self.agents.lock().expect("agent registry lock poisoned"); - agents.get(agent_id).and_then(|r| r.session.clone()) + let agents = lock_agents(&self.agents); + let session = agents.get(agent_id).and_then(|record| record.session.clone()); + drop(agents); + session } } diff --git a/libs/edge-toolkit/tests/args.rs b/libs/edge-toolkit/tests/args.rs index cc11d04..7d8b982 100644 --- a/libs/edge-toolkit/tests/args.rs +++ b/libs/edge-toolkit/tests/args.rs @@ -7,7 +7,7 @@ fn executable_name(#[case] args: Vec<&str>) { let args: Vec = args.into_iter().map(String::from).collect(); assert_eq!( - edge_toolkit::args::executable_name_inner(args), + edge_toolkit::args::executable_name_inner(&args), "et-ws-server".to_string() ); } diff --git a/libs/edge-toolkit/tests/http_pyodide.rs b/libs/edge-toolkit/tests/http_pyodide.rs index 4b3086f..002eec8 100644 --- a/libs/edge-toolkit/tests/http_pyodide.rs +++ b/libs/edge-toolkit/tests/http_pyodide.rs @@ -11,6 +11,10 @@ //! silently passing. #![cfg(test)] +#![expect( + clippy::panic, + reason = "test code: missing mise install and unreadable install dir should fail loudly with a clear hint" +)] use std::collections::HashSet; use std::fs; @@ -53,7 +57,7 @@ fn http_pyodide_install_contains_full_wheel_set() { .unwrap_or_else(|err| panic!("failed to read http:pyodide install dir {}: {err}", install.display())); let wheel_names: HashSet = entries - .filter_map(|entry| entry.ok()) + .filter_map(Result::ok) .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "whl")) .map(|entry| entry.file_name().to_string_lossy().into_owned()) .collect(); diff --git a/libs/edge-toolkit/tests/no_mise.rs b/libs/edge-toolkit/tests/no_mise.rs index d0c4b70..e4c5137 100644 --- a/libs/edge-toolkit/tests/no_mise.rs +++ b/libs/edge-toolkit/tests/no_mise.rs @@ -5,6 +5,7 @@ //! warnings at startup. #![cfg(test)] +#![expect(clippy::unwrap_used, reason = "test code: failed tempdir setup should fail the test")] use std::path::PathBuf; @@ -41,8 +42,8 @@ fn returns_only_workspace_paths_when_mise_missing() { // host-dependent — just check the suffixes are right. let suffixes: Vec = paths .iter() - .map(|p| { - p.components() + .map(|path| { + path.components() .rev() .take(2) .collect::>() @@ -61,7 +62,7 @@ fn returns_only_workspace_paths_when_mise_missing() { assert!( suffixes .iter() - .any(|s| s.ends_with(&expected_path) || s == &expected_path), + .any(|suffix| suffix.ends_with(&expected_path) || suffix == &expected_path), "expected a path ending in {expected:?}, got {paths:?}", ); } @@ -72,7 +73,10 @@ fn returns_only_workspace_paths_when_mise_missing() { assert!( records.is_empty(), "expected no log records when mise is unavailable, got {:?}", - records.iter().map(|r| (r.level, r.body.as_str())).collect::>(), + records + .iter() + .map(|record| (record.level, record.body.as_str())) + .collect::>(), ); }); } diff --git a/libs/edge-toolkit/tests/npm_mod.rs b/libs/edge-toolkit/tests/npm_mod.rs index 2dcc7d6..f2de70b 100644 --- a/libs/edge-toolkit/tests/npm_mod.rs +++ b/libs/edge-toolkit/tests/npm_mod.rs @@ -4,6 +4,7 @@ //! verifies the resolver picks the right `node_modules` directory. #![cfg(test)] +#![expect(clippy::unwrap_used, reason = "test code: failed tempdir setup should fail the test")] use std::fs; diff --git a/libs/et-otlp/Cargo.toml b/libs/et-otlp/Cargo.toml index c49ba71..401b022 100644 --- a/libs/et-otlp/Cargo.toml +++ b/libs/et-otlp/Cargo.toml @@ -6,6 +6,9 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +doctest = false + [dependencies] edge-toolkit.workspace = true hostname = "0.4" @@ -24,3 +27,6 @@ tracing.workspace = true tracing-log = "0.2" tracing-opentelemetry.workspace = true tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/libs/et-otlp/src/lib.rs b/libs/et-otlp/src/lib.rs index 69521c7..21e04a0 100644 --- a/libs/et-otlp/src/lib.rs +++ b/libs/et-otlp/src/lib.rs @@ -1,4 +1,4 @@ -//! Shared OpenTelemetry / OTLP setup for edge-toolkit services. +//! Shared `OpenTelemetry` / OTLP setup for edge-toolkit services. //! //! Wires up: //! - The W3C tracecontext propagator (so `traceparent` headers cross @@ -7,28 +7,35 @@ //! - An OTLP/HTTP log exporter, exposed through `tracing` so `info!` and //! friends are forwarded. //! - A `tracing` subscriber that fans `info!`/`error!` out to stdout *and* -//! the OTel pipeline. +//! the `OTel` pipeline. //! //! Returns an `OtelHandles` which the caller must `shutdown()` before exit //! so batched spans/logs are flushed — otherwise short-lived processes //! (e.g. the wasi-runner, which exits as soon as a module finishes) drop //! their tail-end spans. +#![expect( + clippy::expect_used, + reason = "init runs once at startup; exporter build / RUST_LOG / subscriber failures should crash early" +)] use edge_toolkit::config::{OtlpConfig, OtlpProtocol}; use opentelemetry::{KeyValue, trace::TracerProvider as _}; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; -use opentelemetry_otlp::{LogExporter, WithExportConfig, WithHttpConfig}; +use opentelemetry_otlp::{LogExporter, WithExportConfig as _, WithHttpConfig as _}; use opentelemetry_sdk::logs::SdkLoggerProvider; use opentelemetry_sdk::trace::SdkTracerProvider; use opentelemetry_sdk::{Resource, propagation::TraceContextPropagator}; use tracing::subscriber::set_global_default; use tracing_opentelemetry::OpenTelemetryLayer; -use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; +use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt as _}; pub const RUST_LOG: &str = "RUST_LOG"; -/// Handles for the spans + logs pipelines. Drop alone won't flush — call -/// [`OtelHandles::shutdown`] at the end of `main()` (or in a Drop guard). +/// Handles for the spans + logs pipelines. +/// +/// Drop alone won't flush — call [`OtelHandles::shutdown`] at the end of +/// `main()` (or in a Drop guard). +#[non_exhaustive] pub struct OtelHandles { pub tracer_provider: SdkTracerProvider, pub logger_provider: SdkLoggerProvider, @@ -38,18 +45,20 @@ impl OtelHandles { /// Flush any buffered spans/logs and tear down the exporters. pub fn shutdown(self) { // Errors here are non-fatal — the process is exiting anyway. - let _ = self.tracer_provider.shutdown(); - let _ = self.logger_provider.shutdown(); + drop(self.tracer_provider.shutdown()); + drop(self.logger_provider.shutdown()); } } -/// Initialise the global tracing subscriber + OTel pipeline against -/// `config`. Call exactly once per process; subsequent calls panic via +/// Initialise the global tracing subscriber + `OTel` pipeline against `config`. +/// +/// Call exactly once per process; subsequent calls panic via /// `set_global_default`. +#[must_use] pub fn init(config: &OtlpConfig) -> OtelHandles { // tracing_log forwards `log` crate records (used by transitive deps) // through the tracing subscriber. - let _ = tracing_log::LogTracer::init(); + drop(tracing_log::LogTracer::init()); let mut headers = std::collections::HashMap::new(); if let Some(auth) = &config.auth { @@ -74,7 +83,7 @@ pub fn init(config: &OtlpConfig) -> OtelHandles { .expect("build OTLP span exporter"); let mut service_descriptors = vec![KeyValue::new("service.version", env!("CARGO_PKG_VERSION").to_string())]; - if let Some(hostname) = hostname::get().ok().and_then(|h| h.into_string().ok()) { + if let Some(hostname) = hostname::get().ok().and_then(|host| host.into_string().ok()) { service_descriptors.push(KeyValue::new("service.instance", hostname)); } let resource = Resource::builder() diff --git a/libs/otlp-mock/Cargo.toml b/libs/otlp-mock/Cargo.toml index fa0993c..3a617f8 100644 --- a/libs/otlp-mock/Cargo.toml +++ b/libs/otlp-mock/Cargo.toml @@ -6,7 +6,13 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +doctest = false + [dependencies] actix-rt.workspace = true actix-web.workspace = true serde_json.workspace = true + +[lints] +workspace = true diff --git a/libs/otlp-mock/src/lib.rs b/libs/otlp-mock/src/lib.rs index bf396b0..c2db5c2 100644 --- a/libs/otlp-mock/src/lib.rs +++ b/libs/otlp-mock/src/lib.rs @@ -11,8 +11,17 @@ //! - `POST /traces` //! - `POST /logs` //! -//! so tests should set `OTLP_COLLECTOR_URL=` and +//! so tests should set `OTLP_COLLECTOR_URL=` and //! `OTLP_PROTOCOL=JSON`. +#![expect( + clippy::unwrap_used, + clippy::panic, + reason = "in-process test mock; bind/poison/startup failures should fail the test fast" +)] +#![expect( + clippy::exhaustive_structs, + reason = "actix-web's #[post] generates pub marker structs we can't annotate; FlatSpan has #[non_exhaustive]" +)] use std::net::TcpListener; use std::sync::{Arc, Mutex}; @@ -26,20 +35,26 @@ struct Captured { logs: Mutex>, } -/// Handle to a running mock collector. The server is shut down when this -/// struct is dropped (the actix runtime is owned by the spawned thread — -/// when our struct goes out of scope, the spawned thread's tokio runtime -/// stays alive but the handle pointing at it is dropped, which is fine for -/// test scope). +/// Handle to a running mock collector. +/// +/// The server is shut down when this struct is dropped (the actix runtime is +/// owned by the spawned thread — when our struct goes out of scope, the +/// spawned thread's tokio runtime stays alive but the handle pointing at it +/// is dropped, which is fine for test scope). pub struct OtlpMock { - /// Pass this to `OTLP_COLLECTOR_URL` in env so OTLP exporters target - /// the mock. Trace endpoint is `/traces`; logs is - /// `/logs` — matches `et_otlp::init`'s URL convention. - pub collector_url: String, + collector_url: String, captured: Arc, } impl OtlpMock { + /// Pass this to `OTLP_COLLECTOR_URL` in env so OTLP exporters target + /// the mock. Trace endpoint is `/traces`; logs is + /// `/logs` — matches `et_otlp::init`'s URL convention. + #[must_use] + pub fn collector_url(&self) -> &str { + &self.collector_url + } + /// Snapshot the trace payloads received so far. Each element is one /// `ExportTraceServiceRequest` body (top-level shape: /// `{ "resourceSpans": [...] }`). @@ -66,16 +81,16 @@ impl OtlpMock { let Some(resource_spans) = req.get("resourceSpans").and_then(Value::as_array) else { continue; }; - for rs in resource_spans { - let service_name = rs + for resource_span in resource_spans { + let service_name = resource_span .get("resource") - .and_then(|r| r.get("attributes")) + .and_then(|resource| resource.get("attributes")) .and_then(Value::as_array) .and_then(|attrs| { attrs.iter().find_map(|attr| { if attr.get("key").and_then(Value::as_str) == Some("service.name") { attr.get("value") - .and_then(|v| v.get("stringValue")) + .and_then(|value| value.get("stringValue")) .and_then(Value::as_str) .map(str::to_string) } else { @@ -84,11 +99,11 @@ impl OtlpMock { }) }) .unwrap_or_default(); - let Some(scope_spans) = rs.get("scopeSpans").and_then(Value::as_array) else { + let Some(scope_spans) = resource_span.get("scopeSpans").and_then(Value::as_array) else { continue; }; - for ss in scope_spans { - let Some(spans) = ss.get("spans").and_then(Value::as_array) else { + for scope_span in scope_spans { + let Some(spans) = scope_span.get("spans").and_then(Value::as_array) else { continue; }; for span in spans { @@ -117,6 +132,7 @@ impl OtlpMock { /// Flattened span view for assertions. #[derive(Clone, Debug)] +#[non_exhaustive] pub struct FlatSpan { pub service_name: String, /// Base64-encoded 16-byte trace id (OTLP/HTTP-JSON proto-JSON encoding). @@ -126,6 +142,10 @@ pub struct FlatSpan { pub name: String, } +#[expect( + clippy::single_call_fn, + reason = "actix-web route handler; registered via the #[post] macro" +)] #[post("/traces")] async fn handle_traces(state: web::Data>, body: web::Json) -> HttpResponse { state.traces.lock().unwrap().push(body.into_inner()); @@ -133,23 +153,30 @@ async fn handle_traces(state: web::Data>, body: web::Json) HttpResponse::Ok().content_type("application/json").body("{}") } +#[expect( + clippy::single_call_fn, + reason = "actix-web route handler; registered via the #[post] macro" +)] #[post("/logs")] async fn handle_logs(state: web::Data>, body: web::Json) -> HttpResponse { state.logs.lock().unwrap().push(body.into_inner()); HttpResponse::Ok().content_type("application/json").body("{}") } -/// Start the mock on a free port and return its handle. The HTTP server -/// runs on its own thread + actix runtime; the test's runtime is untouched. +/// Start the mock on a free port and return its handle. +/// +/// The HTTP server runs on its own thread + actix runtime; the test's +/// runtime is untouched. +#[must_use] pub fn start() -> OtlpMock { // Bind to :0 to grab a free port, then drop the listener so the actix // runtime can re-bind to it. (Same trick as `et-ws-test-server`.) let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); let captured = Arc::new(Captured::default()); - let captured_for_server = captured.clone(); + let captured_for_server = Arc::clone(&captured); let addr = format!("127.0.0.1:{port}"); - std::thread::spawn(move || { + let _join = std::thread::spawn(move || { actix_rt::System::new().block_on(async move { let data = web::Data::new(captured_for_server); HttpServer::new(move || { @@ -169,7 +196,7 @@ pub fn start() -> OtlpMock { // Wait for the server to start accepting connections so the caller can // immediately point exporters at it. - for _ in 0..50 { + for _ in 0_u32..50 { if std::net::TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() { return OtlpMock { collector_url: format!("http://127.0.0.1:{port}"), diff --git a/libs/web/Cargo.toml b/libs/web/Cargo.toml index 6f001c5..5f96ae5 100644 --- a/libs/web/Cargo.toml +++ b/libs/web/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] js-sys = "0.3" @@ -21,3 +22,6 @@ web-sys = { version = "0.3", features = [ "MessageEvent", "Navigator", ] } + +[lints] +workspace = true diff --git a/libs/web/src/error.rs b/libs/web/src/error.rs index e5ca258..b4bb499 100644 --- a/libs/web/src/error.rs +++ b/libs/web/src/error.rs @@ -4,7 +4,7 @@ //! - `.dyn_into::().map_err(|_| JsValue::from_str("..."))` — replace //! with `.dyn_into_msg::("...")`. //! - `.map_err(|e| JsValue::from_str(&format!("ctx: {e:?}")))` -//! — replace with `.js_context("ctx")` (it formats the inner JsValue +//! — replace with `.js_context("ctx")` (it formats the inner `JsValue` //! into the prefix string itself). //! //! This is the only file in the workspace where `map_err` is permitted — @@ -13,7 +13,8 @@ use wasm_bindgen::{JsCast, JsValue}; /// Replacement for `.dyn_into::().map_err(|_| JsValue::from_str(msg))`. -/// Drops the original (irrelevant — it's just the same JsValue we tried to +/// +/// Drops the original (irrelevant — it's just the same `JsValue` we tried to /// cast) and surfaces a descriptive string instead. pub trait JsCastExt { fn dyn_into_msg(self, msg: &str) -> Result; @@ -21,13 +22,15 @@ pub trait JsCastExt { impl JsCastExt for S { fn dyn_into_msg(self, msg: &str) -> Result { - self.dyn_into::().map_err(|_| JsValue::from_str(msg)) + self.dyn_into::().map_err(|_original| JsValue::from_str(msg)) } } -/// Replacement for `.map_err(|e| JsValue::from_str(&format!("ctx: {e:?}")))` -/// on any `Result` where `E: std::fmt::Debug`. Use `.js_context("ctx")` -/// to attach a prefix to a JS error you propagated from a fallible call. +/// Replacement for `.map_err(|e| JsValue::from_str(&format!("ctx: {e:?}")))`. +/// +/// Works on any `Result` where `E: std::fmt::Debug`. Use +/// `.js_context("ctx")` to attach a prefix to a JS error you propagated from +/// a fallible call. pub trait JsResultExt { fn js_context(self, context: &str) -> Result; } @@ -38,8 +41,9 @@ impl JsResultExt for Result { } } -/// `.into_promise("op")` shortcut for the WASM modules' recurring -/// `.dyn_into_msg::("op did not return a Promise")` pattern. +/// `.into_promise("op")` shortcut for the WASM modules' recurring pattern. +/// +/// Equivalent to `.dyn_into_msg::("op did not return a Promise")`. /// Pass the JS-side operation name (e.g. `"requestAdapter"`); the trait /// formats the standard "did not return a Promise" message itself. pub trait JsPromiseExt { @@ -49,12 +53,13 @@ pub trait JsPromiseExt { impl JsPromiseExt for S { fn into_promise(self, op: &str) -> Result { self.dyn_into::() - .map_err(|_| JsValue::from_str(&format!("{op} did not return a Promise"))) + .map_err(|_original| JsValue::from_str(&format!("{op} did not return a Promise"))) } } -/// `.into_function("op")` shortcut for the WASM modules' recurring -/// `.dyn_into_msg::("op is not callable")` pattern. Pass the +/// `.into_function("op")` shortcut for the WASM modules' recurring pattern. +/// +/// Equivalent to `.dyn_into_msg::("op is not callable")`. Pass the /// JS-side identifier (e.g. `"requestPermission"`); the trait formats /// the standard "is not callable" message itself. pub trait JsFunctionExt { @@ -64,6 +69,6 @@ pub trait JsFunctionExt { impl JsFunctionExt for S { fn into_function(self, op: &str) -> Result { self.dyn_into::() - .map_err(|_| JsValue::from_str(&format!("{op} is not callable"))) + .map_err(|_original| JsValue::from_str(&format!("{op} is not callable"))) } } diff --git a/libs/web/src/lib.rs b/libs/web/src/lib.rs index 49a95eb..de4eb6d 100644 --- a/libs/web/src/lib.rs +++ b/libs/web/src/lib.rs @@ -18,6 +18,10 @@ pub fn get_media_devices(navigator: &web_sys::Navigator) -> Result Result { if target.is_null() || target.is_undefined() { return Ok(SENSOR_PERMISSION_GRANTED.to_string()); diff --git a/services/modules/Cargo.toml b/services/modules/Cargo.toml index 589ae71..2f80024 100644 --- a/services/modules/Cargo.toml +++ b/services/modules/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +doctest = false + [dependencies] actix-files = "0.6" actix-web = "4" @@ -18,3 +21,6 @@ tracing.workspace = true [dev-dependencies] actix-rt = "2" tempfile.workspace = true + +[lints] +workspace = true diff --git a/services/modules/src/lib.rs b/services/modules/src/lib.rs index 4f08c67..2959dae 100644 --- a/services/modules/src/lib.rs +++ b/services/modules/src/lib.rs @@ -10,6 +10,7 @@ use serde_inline_default::serde_inline_default; /// Modules config. #[serde_inline_default] #[derive(Clone, Debug, DefaultFromSerde, Deserialize)] +#[non_exhaustive] pub struct ModulesConfig { #[serde(default = "default_modules_folders")] pub paths: Vec, @@ -17,26 +18,34 @@ pub struct ModulesConfig { pub root: String, } +impl ModulesConfig { + #[must_use] + pub const fn new(paths: Vec, root: String) -> Self { + Self { paths, root } + } +} + fn read_package_name(package_json: &std::path::Path) -> Option { let content = std::fs::read_to_string(package_json).ok()?; - let v: serde_json::Value = serde_json::from_str(&content).ok()?; - v.get("name")?.as_str().map(str::to_string) + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + value.get("name")?.as_str().map(str::to_string) } -/// Scan all configured module paths and return a sorted list of (name, pkg_dir) pairs. +/// Scan all configured module paths and return a sorted list of `(name, pkg_dir)` pairs. +#[must_use] pub fn list_modules(config: &ModulesConfig) -> Vec<(String, PathBuf)> { let mut modules: Vec<(String, PathBuf)> = Vec::new(); for path in &config.paths { let pkg_dir = path.join("pkg"); if pkg_dir.is_dir() { let name = read_package_name(&pkg_dir.join("package.json")) - .or_else(|| path.file_name().and_then(|n| n.to_str()).map(str::to_string)); + .or_else(|| path.file_name().and_then(|name| name.to_str()).map(str::to_string)); if let Some(name) = name { modules.push((name, pkg_dir)); } } else if path.join("package.json").is_file() { let name = read_package_name(&path.join("package.json")) - .or_else(|| path.file_name().and_then(|n| n.to_str()).map(str::to_string)); + .or_else(|| path.file_name().and_then(|name| name.to_str()).map(str::to_string)); if let Some(name) = name { modules.push((name, path.clone())); } @@ -62,15 +71,23 @@ pub fn list_modules(config: &ModulesConfig) -> Vec<(String, PathBuf)> { if let Some(name) = name { modules.push((name, entry_path)); } + } else { + // No `pkg/` and no root `package.json`; not a module dir. } } } + } else { + // Configured path is neither a module dir nor a readable parent dir; skip silently. } } - modules.sort_by(|a, b| a.0.cmp(&b.0)); + modules.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); modules } +#[expect( + clippy::single_call_fn, + reason = "actix-web route handler; registered via web::get().to(...)" +)] async fn list_modules_handler(config: web::Data) -> HttpResponse { let names: Vec = list_modules(&config).into_iter().map(|(name, _)| name).collect(); HttpResponse::Ok().json(names) @@ -78,20 +95,27 @@ async fn list_modules_handler(config: web::Data) -> HttpResponse /// Register `GET /modules/` (JSON list), `GET /modules/{name}/...` (static files), /// and `GET /` (root module). +/// +/// # Panics +/// Panics if `config.root` is not present in `config.paths` — server config +/// is fatal early so the operator sees the misconfiguration at startup. +#[expect( + clippy::panic, + reason = "missing root module is a config error; failing fast at startup is intentional" +)] pub fn configure(cfg: &mut web::ServiceConfig, config: &ModulesConfig) { let modules = list_modules(config); - let root_module_dir = modules - .iter() - .find(|(name, _)| name == &config.root) - .map(|(_, path)| path.clone()) - .unwrap_or_else(|| panic!("Root module '{}' not found", config.root)); + let root_module_dir = modules.iter().find(|(name, _)| name == &config.root).map_or_else( + || panic!("Root module '{}' not found", config.root), + |(_, path)| path.clone(), + ); - cfg.route("/modules/", web::get().to(list_modules_handler)); + let _routed = cfg.route("/modules/", web::get().to(list_modules_handler)); for (name, pkg_dir) in &modules { - cfg.service(Files::new(&format!("/modules/{name}"), pkg_dir)); + let _served = cfg.service(Files::new(&format!("/modules/{name}"), pkg_dir)); } - cfg.service( + let _root_served = cfg.service( Files::new("/", root_module_dir) .index_file("index.html") .prefer_utf8(true), diff --git a/services/modules/tests/symlinks.rs b/services/modules/tests/symlinks.rs index 422742c..64dbfdb 100644 --- a/services/modules/tests/symlinks.rs +++ b/services/modules/tests/symlinks.rs @@ -11,6 +11,12 @@ #![cfg(test)] #![cfg(unix)] +#![expect( + clippy::unwrap_used, + clippy::expect_used, + clippy::deref_by_slicing, + reason = "test code: fixture setup failures should fail the test" +)] use std::fs; use std::os::unix::fs::symlink; @@ -55,10 +61,7 @@ fn aube_layout_fixture() -> (TempDir, TempDir, ModulesConfig) { symlink(&real_pkg, scan.path().join("onnxruntime-web")).unwrap(); symlink(&static_root, scan.path().join("et-ws-server-static")).unwrap(); - let config = ModulesConfig { - paths: vec![scan.path().to_path_buf()], - root: "et-ws-server-static".to_string(), - }; + let config = ModulesConfig::new(vec![scan.path().to_path_buf()], "et-ws-server-static".to_string()); (store, scan, config) } @@ -73,7 +76,8 @@ async fn list_modules_follows_symlinks_to_package_dirs() { // Both packages — the symlinked target onnxruntime-web and the // symlinked stub root module — should be discovered. - let by_name: std::collections::HashMap<&str, &PathBuf> = found.iter().map(|(n, p)| (n.as_str(), p)).collect(); + let by_name: std::collections::HashMap<&str, &PathBuf> = + found.iter().map(|(name, path)| (name.as_str(), path)).collect(); let pkg_path = by_name .get("onnxruntime-web") .expect("symlinked onnxruntime-web should be discovered"); diff --git a/services/storage/Cargo.toml b/services/storage/Cargo.toml index 0036bad..acc94ad 100644 --- a/services/storage/Cargo.toml +++ b/services/storage/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +doctest = false + [dependencies] actix-files = "0.6" actix-web = "4" @@ -23,3 +26,6 @@ tracing.workspace = true [dev-dependencies] actix-rt = "2" tempfile.workspace = true + +[lints] +workspace = true diff --git a/services/storage/src/lib.rs b/services/storage/src/lib.rs index 64f868d..d596b42 100644 --- a/services/storage/src/lib.rs +++ b/services/storage/src/lib.rs @@ -5,7 +5,7 @@ use actix_files::Files; use actix_web::{HttpRequest, HttpResponse, web}; use actix_web_thiserror::ResponseError; use edge_toolkit::ws_server::AgentRegistry; -use futures_util::StreamExt; +use futures_util::StreamExt as _; use serde::Deserialize; use serde_default::DefaultFromSerde; use thiserror::Error; @@ -20,12 +20,21 @@ pub fn default_storage_folder() -> PathBuf { /// Storage config. #[derive(Clone, Debug, DefaultFromSerde, Deserialize)] +#[non_exhaustive] pub struct StorageConfig { #[serde(default = "default_storage_folder")] pub path: PathBuf, } +impl StorageConfig { + #[must_use] + pub const fn new(path: PathBuf) -> Self { + Self { path } + } +} + #[derive(Debug, Error, ResponseError)] +#[non_exhaustive] pub enum StorageError { #[error("invalid filename")] #[response(status = 400, reason = "BAD_REQUEST")] @@ -48,11 +57,15 @@ pub enum StorageError { // `PoisonError` is generic over the guard type; a generic `From` impl // lets `?` drop the `T` and surface the variant directly. impl From> for StorageError { - fn from(_: PoisonError) -> Self { - StorageError::AgentRegistryPoisoned + fn from(_source: PoisonError) -> Self { + Self::AgentRegistryPoisoned } } +#[expect( + clippy::future_not_send, + reason = "actix-web Payload is !Send by design; handler runs on actix's single-threaded runtime" +)] pub async fn agent_put_file( req: HttpRequest, mut payload: web::Payload, @@ -82,7 +95,7 @@ pub async fn agent_put_file( let mut file = tokio::fs::File::create(path).await?; while let Some(chunk) = payload.next().await { let chunk = chunk?; - tokio::io::copy(&mut &chunk[..], &mut file).await?; + let _copied: u64 = tokio::io::copy(&mut chunk.as_ref(), &mut file).await?; } Ok(HttpResponse::Ok().finish()) @@ -91,7 +104,8 @@ pub async fn agent_put_file( /// Register `PUT /storage/{agent_id}/{filename}` and `GET /storage/...` (static file serving). pub fn configure(cfg: &mut web::ServiceConfig, config: &StorageConfig) { let storage_dir = config.path.clone(); - cfg.route("/storage/{agent_id}/{filename}", web::put().to(agent_put_file::)) + let _configured = cfg + .route("/storage/{agent_id}/{filename}", web::put().to(agent_put_file::)) .service( Files::new("/storage", storage_dir) .show_files_listing() diff --git a/services/storage/tests/put.rs b/services/storage/tests/put.rs index f424b85..795a464 100644 --- a/services/storage/tests/put.rs +++ b/services/storage/tests/put.rs @@ -8,14 +8,18 @@ //! what wires the route into the test app. #![cfg(test)] +#![expect( + clippy::unwrap_used, + clippy::expect_used, + reason = "test code: setup and route invocation failures should fail the test" +)] use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; use actix_web::dev::Payload as DevPayload; -use actix_web::error::ResponseError; +use actix_web::error::ResponseError as _; use actix_web::http::StatusCode; -use actix_web::{App, FromRequest, test, web}; +use actix_web::{App, FromRequest as _, test, web}; use edge_toolkit::ws::AgentConnectionState; use edge_toolkit::ws_server::{AgentRecord, AgentRegistry}; use et_storage_service::{StorageConfig, StorageError, agent_put_file, configure}; @@ -24,24 +28,15 @@ use tempfile::TempDir; /// Build a registry with a single connected agent. fn registry_with_agent(agent_id: &str) -> AgentRegistry<()> { let mut agents = BTreeMap::new(); - agents.insert( + let _previous: Option> = agents.insert( agent_id.to_string(), - AgentRecord { - state: AgentConnectionState::Connected, - last_known_ip: None, - session: Some(()), - pending_direct_messages: BTreeMap::new(), - }, + AgentRecord::new(AgentConnectionState::Connected, None, Some(())), ); - AgentRegistry { - agents: Arc::new(Mutex::new(agents)), - } + AgentRegistry::from_agents(agents) } fn storage_config(tmp: &TempDir) -> StorageConfig { - StorageConfig { - path: tmp.path().to_path_buf(), - } + StorageConfig::new(tmp.path().to_path_buf()) } #[actix_rt::test] @@ -126,7 +121,7 @@ async fn surfaces_io_failure_as_500() { let tmp = tempfile::tempdir().unwrap(); let blocker = tmp.path().join("blocker"); std::fs::write(&blocker, b"i am a file, not a directory").unwrap(); - let config = StorageConfig { path: blocker }; + let config = StorageConfig::new(blocker); let registry = registry_with_agent("agent-1"); let app = test::init_service( App::new() diff --git a/services/ws-modules/audio1/Cargo.toml b/services/ws-modules/audio1/Cargo.toml index b49fd91..eb4098e 100644 --- a/services/ws-modules/audio1/Cargo.toml +++ b/services/ws-modules/audio1/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -32,3 +33,6 @@ web-sys = { version = "0.3", features = [ [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/audio1/src/lib.rs b/services/ws-modules/audio1/src/lib.rs index 0893405..e373051 100644 --- a/services/ws-modules/audio1/src/lib.rs +++ b/services/ws-modules/audio1/src/lib.rs @@ -1,6 +1,12 @@ +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" +)] + use std::cell::RefCell; -use et_web::{JsCastExt, get_media_devices}; +use et_web::{JsCastExt as _, get_media_devices}; use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; use js_sys::{Promise, Reflect}; use serde_json::json; @@ -17,7 +23,7 @@ pub struct MicrophoneAccess { #[wasm_bindgen] impl MicrophoneAccess { #[wasm_bindgen(js_name = request)] - pub async fn request() -> Result { + pub async fn request() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let media_devices = get_media_devices(&window.navigator())?; @@ -34,14 +40,16 @@ impl MicrophoneAccess { stream.get_audio_tracks().length() ); - Ok(MicrophoneAccess { stream }) + Ok(Self { stream }) } + #[must_use] #[wasm_bindgen(js_name = trackCount)] pub fn track_count(&self) -> u32 { self.stream.get_audio_tracks().length() } + #[must_use] #[wasm_bindgen(js_name = rawStream)] pub fn raw_stream(&self) -> JsValue { self.stream.clone().into() @@ -69,10 +77,11 @@ thread_local! { #[wasm_bindgen(start)] pub fn init() { - let _ = tracing_wasm::try_set_as_global_default(); + drop(tracing_wasm::try_set_as_global_default()); info!("audio-capture module initialized"); } +#[must_use] #[wasm_bindgen] pub fn is_running() -> bool { AUDIO_CAPTURE_RUNTIME.with(|runtime| runtime.borrow().is_some()) @@ -85,19 +94,19 @@ pub async fn run() -> Result<(), JsValue> { } set_module_status("audio-capture: entered run()")?; - log("entered run()")?; + log("entered run()"); let outcome = async { let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url)); client.connect()?; wait_for_connected(&client).await?; - log(&format!("websocket connected with agent_id={}", client.get_agent_id()))?; + log(&format!("websocket connected with agent_id={}", client.get_agent_id())); - log("requesting microphone access")?; + log("requesting microphone access"); let access = MicrophoneAccess::request().await?; let tracks = access.track_count(); - log(&format!("microphone access granted: {} tracks", tracks))?; + log(&format!("microphone access granted: {tracks} tracks")); client.send_client_event( "audio", @@ -110,17 +119,19 @@ pub async fn run() -> Result<(), JsValue> { set_module_status("audio-capture: running")?; AUDIO_CAPTURE_RUNTIME.with(|runtime| { - runtime.borrow_mut().replace(AudioCaptureRuntime { client, access }); + let _previous: Option = + runtime.borrow_mut().replace(AudioCaptureRuntime { client, access }); }); let stop_callback = Closure::once_into_js(move || { if is_running() { - let _ = log("workflow finished automatically after 5 seconds"); - let _ = stop(); + log("workflow finished automatically after 5 seconds"); + drop(stop()); } }); let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - window.set_timeout_with_callback_and_timeout_and_arguments_0(stop_callback.unchecked_ref(), 5000)?; + let _id: i32 = + window.set_timeout_with_callback_and_timeout_and_arguments_0(stop_callback.unchecked_ref(), 5000)?; Ok(()) } @@ -128,8 +139,8 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - let _ = set_module_status(&format!("audio-capture: error\n{}", message)); - let _ = log(&format!("error: {}", message)); + drop(set_module_status(&format!("audio-capture: error\n{message}"))); + log(&format!("error: {message}")); } outcome @@ -141,17 +152,16 @@ pub fn stop() -> Result<(), JsValue> { if let Some(mut runtime) = runtime.borrow_mut().take() { runtime.access.stop(); runtime.client.disconnect(); - log("audio-capture stopped")?; + log("audio-capture stopped"); } - Ok::<(), JsValue>(()) - })?; + }); set_module_status("audio-capture: stopped")?; Ok(()) } -fn log(message: &str) -> Result<(), JsValue> { - let line = format!("[audio-capture] {}", message); +fn log(message: &str) { + let line = format!("[audio-capture] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); if let Some(window) = web_sys::window() @@ -162,12 +172,10 @@ fn log(message: &str) -> Result<(), JsValue> { let next = if current.is_empty() { line } else { - format!("{}\n{}", current, line) + format!("{current}\n{line}") }; log_el.set_text_content(Some(&next)); } - - Ok(()) } fn set_module_status(message: &str) -> Result<(), JsValue> { @@ -178,11 +186,11 @@ fn describe_js_error(error: &JsValue) -> String { error .as_string() .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{:?}", error)) + .unwrap_or_else(|| format!("{error:?}")) } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -196,13 +204,13 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - let _ = reject.call1(&JsValue::NULL, &error); + drop(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) @@ -218,5 +226,5 @@ fn websocket_url() -> Result { .as_string() .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{}//{}/ws", ws_protocol, host)) + Ok(format!("{ws_protocol}//{host}/ws")) } diff --git a/services/ws-modules/bluetooth/Cargo.toml b/services/ws-modules/bluetooth/Cargo.toml index 07f53dd..ff6ce02 100644 --- a/services/ws-modules/bluetooth/Cargo.toml +++ b/services/ws-modules/bluetooth/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -24,3 +25,6 @@ web-sys = { version = "0.3", features = ["Window", "console"] } [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/bluetooth/src/lib.rs b/services/ws-modules/bluetooth/src/lib.rs index 82c65a2..4c50c8a 100644 --- a/services/ws-modules/bluetooth/src/lib.rs +++ b/services/ws-modules/bluetooth/src/lib.rs @@ -1,4 +1,10 @@ -use et_web::{JsFunctionExt, JsPromiseExt}; +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" +)] + +use et_web::{JsFunctionExt as _, JsPromiseExt as _}; use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; use js_sys::{Promise, Reflect}; use serde_json::json; @@ -14,7 +20,7 @@ pub struct BluetoothAccess { #[wasm_bindgen] impl BluetoothAccess { #[wasm_bindgen(js_name = request)] - pub async fn request() -> Result { + pub async fn request() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let navigator = window.navigator(); let bluetooth = js_sys::Reflect::get(&navigator, &JsValue::from_str("bluetooth"))?; @@ -25,7 +31,7 @@ impl BluetoothAccess { } let options = js_sys::Object::new(); - js_sys::Reflect::set(&options, &JsValue::from_str("acceptAllDevices"), &JsValue::TRUE)?; + let _: bool = js_sys::Reflect::set(&options, &JsValue::from_str("acceptAllDevices"), &JsValue::TRUE)?; let request_device = js_sys::Reflect::get(&bluetooth, &JsValue::from_str("requestDevice"))? .into_function("navigator.bluetooth.requestDevice")?; @@ -42,9 +48,10 @@ impl BluetoothAccess { .unwrap_or_else(|| "unknown".to_string()) ); - Ok(BluetoothAccess { device }) + Ok(Self { device }) } + #[must_use] pub fn id(&self) -> String { js_sys::Reflect::get(&self.device, &JsValue::from_str("id")) .ok() @@ -52,6 +59,7 @@ impl BluetoothAccess { .unwrap_or_default() } + #[must_use] pub fn name(&self) -> String { js_sys::Reflect::get(&self.device, &JsValue::from_str("name")) .ok() @@ -59,6 +67,7 @@ impl BluetoothAccess { .unwrap_or_else(|| "unknown".to_string()) } + #[must_use] #[wasm_bindgen(js_name = gattConnected)] pub fn gatt_connected(&self) -> bool { js_sys::Reflect::get(&self.device, &JsValue::from_str("gatt")) @@ -79,7 +88,7 @@ impl BluetoothAccess { let connect = js_sys::Reflect::get(&gatt, &JsValue::from_str("connect"))?.into_function("device.gatt.connect")?; let promise = connect.call0(&gatt)?.into_promise("device.gatt.connect")?; - let _server = JsFuture::from(promise).await?; + let _server: JsValue = JsFuture::from(promise).await?; info!("Connected to Bluetooth GATT server for {}", self.name()); Ok(()) } @@ -87,11 +96,16 @@ impl BluetoothAccess { #[wasm_bindgen(start)] pub fn init() { - let _ = tracing_wasm::try_set_as_global_default(); + drop(tracing_wasm::try_set_as_global_default()); info!("bluetooth module initialized"); } +#[must_use] #[wasm_bindgen] +#[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; cannot be marked const" +)] pub fn is_running() -> bool { false } @@ -99,20 +113,20 @@ pub fn is_running() -> bool { #[wasm_bindgen] pub async fn run() -> Result<(), JsValue> { set_module_status("bluetooth: entered run()")?; - log("entered run()")?; + log("entered run()"); let outcome = async { let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url)); client.connect()?; wait_for_connected(&client).await?; - log(&format!("websocket connected with agent_id={}", client.get_agent_id()))?; + log(&format!("websocket connected with agent_id={}", client.get_agent_id())); - log("requesting bluetooth access")?; + log("requesting bluetooth access"); let access = BluetoothAccess::request().await?; let id = access.id(); let name = access.name(); - log(&format!("bluetooth device selected: {} ({})", name, id))?; + log(&format!("bluetooth device selected: {name} ({id})")); client.send_client_event( "bluetooth", @@ -123,7 +137,7 @@ pub async fn run() -> Result<(), JsValue> { }), )?; - set_module_status(&format!("bluetooth: device selected\n{} ({})", name, id))?; + set_module_status(&format!("bluetooth: device selected\n{name} ({id})"))?; client.disconnect(); Ok(()) @@ -132,15 +146,15 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - let _ = set_module_status(&format!("bluetooth: error\n{}", message)); - let _ = log(&format!("error: {}", message)); + drop(set_module_status(&format!("bluetooth: error\n{message}"))); + log(&format!("error: {message}")); } outcome } -fn log(message: &str) -> Result<(), JsValue> { - let line = format!("[bluetooth] {}", message); +fn log(message: &str) { + let line = format!("[bluetooth] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); if let Some(window) = web_sys::window() @@ -151,12 +165,10 @@ fn log(message: &str) -> Result<(), JsValue> { let next = if current.is_empty() { line } else { - format!("{}\n{}", current, line) + format!("{current}\n{line}") }; log_el.set_text_content(Some(&next)); } - - Ok(()) } fn set_module_status(message: &str) -> Result<(), JsValue> { @@ -167,11 +179,11 @@ fn describe_js_error(error: &JsValue) -> String { error .as_string() .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{:?}", error)) + .unwrap_or_else(|| format!("{error:?}")) } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -185,13 +197,13 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - let _ = reject.call1(&JsValue::NULL, &error); + drop(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) @@ -207,5 +219,5 @@ fn websocket_url() -> Result { .as_string() .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{}//{}/ws", ws_protocol, host)) + Ok(format!("{ws_protocol}//{host}/ws")) } diff --git a/services/ws-modules/comm1/Cargo.toml b/services/ws-modules/comm1/Cargo.toml index fa625a1..a435650 100644 --- a/services/ws-modules/comm1/Cargo.toml +++ b/services/ws-modules/comm1/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] -edge-toolkit = { path = "../../../libs/edge-toolkit" } -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +edge-toolkit.workspace = true +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -24,3 +25,6 @@ web-sys = { version = "0.3", features = ["Window", "console"] } [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/comm1/src/lib.rs b/services/ws-modules/comm1/src/lib.rs index cd8ce12..ce6f29b 100644 --- a/services/ws-modules/comm1/src/lib.rs +++ b/services/ws-modules/comm1/src/lib.rs @@ -1,3 +1,9 @@ +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" +)] + use std::cell::RefCell; use std::rc::Rc; @@ -20,82 +26,29 @@ pub fn init() { #[wasm_bindgen] pub async fn run() -> Result<(), JsValue> { - log("comm1: entered run()")?; + log("comm1: entered run()"); let ws_url = websocket_url()?; - log(&format!("comm1: resolved websocket URL: {ws_url}"))?; + log(&format!("comm1: resolved websocket URL: {ws_url}")); let mut client = WsClient::new(WsClientConfig::new(ws_url)); let self_agent_id = Rc::new(RefCell::new(String::new())); let other_connected_agents: Rc>> = Rc::new(RefCell::new(Vec::new())); - let on_message = Closure::wrap(Box::new({ - let self_agent_id = self_agent_id.clone(); - let other_connected_agents = other_connected_agents.clone(); - move |value: JsValue| { - let Some(data) = value.as_string() else { - return; - }; - - let Ok(message) = serde_json::from_str::(&data) else { - return; - }; - - match message { - WsMessage::ListAgentsResponse { agents } => { - let own_id = self_agent_id.borrow().clone(); - let others = agents - .into_iter() - .filter(|agent| { - agent.state == AgentConnectionState::Connected - && !own_id.is_empty() - && agent.agent_id != own_id - }) - .collect::>(); - *other_connected_agents.borrow_mut() = others; - } - WsMessage::AgentMessage { - message_id, - from_agent_id, - scope, - server_received_at, - message, - } => { - let summary = - serde_json::to_string(&message).unwrap_or_else(|_| String::from("")); - let line = format!( - "comm1: received {:?} message {} from {} at {}: {}", - scope, message_id, from_agent_id, server_received_at, summary - ); - web_sys::console::log_1(&JsValue::from_str(&line)); - let _ = set_module_status(&line); - } - WsMessage::MessageStatus { - message_id, - status, - detail, - } => { - let line = format!("comm1: message status update {:?} {:?}: {}", message_id, status, detail); - web_sys::console::log_1(&JsValue::from_str(&line)); - let _ = set_module_status(&line); - } - WsMessage::Invalid { message_id, detail } => { - let line = format!("comm1: invalid server response {:?}: {}", message_id, detail); - web_sys::console::warn_1(&JsValue::from_str(&line)); - let _ = set_module_status(&line); - } - _ => {} - } - } - }) as Box); + let on_message_boxed: Box = Box::new({ + let self_agent_id = Rc::clone(&self_agent_id); + let other_connected_agents = Rc::clone(&other_connected_agents); + move |value: JsValue| handle_incoming_message(&self_agent_id, &other_connected_agents, &value) + }); + let on_message = Closure::wrap(on_message_boxed); client.set_on_message(on_message.as_ref().clone()); client.connect()?; wait_for_connected(&client).await?; let agent_id = wait_for_agent_id(&client).await?; - *self_agent_id.borrow_mut() = agent_id.clone(); + agent_id.clone_into(&mut self_agent_id.borrow_mut()); let msg = format!("comm1: websocket connected with agent_id={agent_id}"); - log(&msg)?; + log(&msg); set_module_status(&msg)?; let target_agent = loop { @@ -112,7 +65,7 @@ pub async fn run() -> Result<(), JsValue> { "comm1: found connected peer agent {}; sending broadcast", target_agent.agent_id ); - log(&msg)?; + log(&msg); set_module_status(&msg)?; client.broadcast_message(json!({ "module": "comm1", @@ -124,7 +77,7 @@ pub async fn run() -> Result<(), JsValue> { sleep_ms(MESSAGE_PAUSE_MS).await?; let msg = format!("comm1: sending direct message to {}", target_agent.agent_id); - log(&msg)?; + log(&msg); set_module_status(&msg)?; client.send_agent_message( target_agent.agent_id.clone(), @@ -139,15 +92,80 @@ pub async fn run() -> Result<(), JsValue> { sleep_ms(MESSAGE_PAUSE_MS).await?; client.disconnect(); let msg = "comm1: workflow complete"; - log(msg)?; + log(msg); set_module_status(msg)?; Ok(()) } -fn log(message: &str) -> Result<(), JsValue> { +fn handle_incoming_message( + self_agent_id: &Rc>, + other_connected_agents: &Rc>>, + value: &JsValue, +) { + let Some(data) = value.as_string() else { + return; + }; + + let Ok(message) = serde_json::from_str::(&data) else { + return; + }; + + match message { + WsMessage::ListAgentsResponse { agents } => { + let own_id = self_agent_id.borrow().clone(); + let others = agents + .into_iter() + .filter(|agent| { + agent.state == AgentConnectionState::Connected && !own_id.is_empty() && agent.agent_id != own_id + }) + .collect::>(); + *other_connected_agents.borrow_mut() = others; + } + WsMessage::AgentMessage { + message_id, + from_agent_id, + scope, + server_received_at, + message, + } => { + let summary = serde_json::to_string(&message).unwrap_or_else(|_| String::from("")); + let line = format!( + "comm1: received {scope:?} message {message_id} from {from_agent_id} at {server_received_at}: {summary}" + ); + web_sys::console::log_1(&JsValue::from_str(&line)); + drop(set_module_status(&line)); + } + WsMessage::MessageStatus { + message_id, + status, + detail, + } => { + let line = format!("comm1: message status update {message_id:?} {status:?}: {detail}"); + web_sys::console::log_1(&JsValue::from_str(&line)); + drop(set_module_status(&line)); + } + WsMessage::Invalid { message_id, detail } => { + let line = format!("comm1: invalid server response {message_id:?}: {detail}"); + web_sys::console::warn_1(&JsValue::from_str(&line)); + drop(set_module_status(&line)); + } + WsMessage::Connect { .. } + | WsMessage::ConnectAck { .. } + | WsMessage::Alive { .. } + | WsMessage::ListAgents + | WsMessage::SendAgentMessage { .. } + | WsMessage::BroadcastMessage { .. } + | WsMessage::MessageAck { .. } + | WsMessage::ClientEvent { .. } + | WsMessage::StoreFile { .. } + | WsMessage::FetchFile { .. } + | WsMessage::Response { .. } => {} + } +} + +fn log(message: &str) { let line = format!("[comm1] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); - Ok(()) } fn set_module_status(message: &str) -> Result<(), JsValue> { @@ -155,7 +173,7 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -166,7 +184,7 @@ async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { } async fn wait_for_agent_id(client: &WsClient) -> Result { - for _ in 0..100 { + for _ in 0_u32..100 { let agent_id = client.get_agent_id(); if !agent_id.is_empty() { return Ok(agent_id); @@ -181,13 +199,13 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - let _ = reject.call1(&JsValue::NULL, &error); + drop(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/data1/Cargo.toml b/services/ws-modules/data1/Cargo.toml index a573d2c..0316df5 100644 --- a/services/ws-modules/data1/Cargo.toml +++ b/services/ws-modules/data1/Cargo.toml @@ -8,10 +8,12 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] -edge-toolkit = { path = "../../../libs/edge-toolkit" } -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +edge-toolkit.workspace = true +et-web.workspace = true +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -34,3 +36,6 @@ web-sys = { version = "0.3", features = [ [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/data1/src/lib.rs b/services/ws-modules/data1/src/lib.rs index cdcf6c3..7ae80e4 100644 --- a/services/ws-modules/data1/src/lib.rs +++ b/services/ws-modules/data1/src/lib.rs @@ -1,7 +1,14 @@ +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" +)] + use std::cell::RefCell; use std::rc::Rc; use edge_toolkit::ws::WsMessage; +use et_web::{JsCastExt as _, JsResultExt as _}; use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea}; use js_sys::{Promise, Reflect}; use tracing::info; @@ -18,15 +25,15 @@ pub fn init() { #[wasm_bindgen] pub async fn run() -> Result<(), JsValue> { let msg = "data1: entered run()"; - log(msg)?; + log(msg); set_module_status(msg)?; let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url)); - let last_response = Rc::new(RefCell::new(None)); - let on_message = Closure::wrap(Box::new({ - let last_response = last_response.clone(); + let last_response: Rc>> = Rc::new(RefCell::new(None)); + let on_message_boxed: Box = Box::new({ + let last_response = Rc::clone(&last_response); move |value: JsValue| { let Some(data) = value.as_string() else { return; @@ -38,66 +45,63 @@ pub async fn run() -> Result<(), JsValue> { *last_response.borrow_mut() = Some(message); } } - }) as Box); + }); + let on_message = Closure::wrap(on_message_boxed); client.set_on_message(on_message.as_ref().clone()); client.connect()?; wait_for_connected(&client).await?; let agent_id = wait_for_agent_id(&client).await?; let msg = format!("data1: connected as {agent_id}"); - log(&msg)?; + log(&msg); set_module_status(&msg)?; let filename = "test_data.txt"; let test_content = format!("Hello from data1 at {}!", js_sys::Date::new_0().to_iso_string()); // 1. Request Store URL - log("data1: requesting store URL")?; - client.send( - &serde_json::to_string(&WsMessage::StoreFile { - filename: filename.to_string(), - }) - .unwrap(), - )?; + log("data1: requesting store URL"); + let store_payload = serde_json::to_string(&WsMessage::StoreFile { + filename: filename.to_string(), + }) + .js_context("serialize StoreFile")?; + client.send(&store_payload)?; let store_url = wait_for_response(&last_response, "PUT to ") .await? .replace("PUT to ", ""); // 2. Perform PUT let msg = format!("data1: storing data to {store_url}"); - log(&msg)?; + log(&msg); set_module_status(&msg)?; put_file(&store_url, &test_content).await?; // 3. Request Fetch URL - log("data1: requesting fetch URL")?; - client.send( - &serde_json::to_string(&WsMessage::FetchFile { - agent_id: agent_id.clone(), - filename: filename.to_string(), - }) - .unwrap(), - )?; + log("data1: requesting fetch URL"); + let fetch_payload = serde_json::to_string(&WsMessage::FetchFile { + agent_id: agent_id.clone(), + filename: filename.to_string(), + }) + .js_context("serialize FetchFile")?; + client.send(&fetch_payload)?; let fetch_url = wait_for_response(&last_response, "GET from ") .await? .replace("GET from ", ""); // 4. Perform GET and Verify let msg = format!("data1: fetching data from {fetch_url}"); - log(&msg)?; + log(&msg); set_module_status(&msg)?; let retrieved_content = get_file(&fetch_url).await?; if retrieved_content == test_content { let msg = "data1: VERIFICATION SUCCESS - data matches!"; - log(msg)?; + log(msg); set_module_status(msg)?; } else { - let msg = format!( - "data1: VERIFICATION FAILURE - data mismatch!\nSent: {}\nGot: {}", - test_content, retrieved_content - ); - log(&msg)?; + let msg = + format!("data1: VERIFICATION FAILURE - data mismatch!\nSent: {test_content}\nGot: {retrieved_content}"); + log(&msg); set_module_status(&msg)?; return Err(JsValue::from_str("Data mismatch")); } @@ -105,7 +109,7 @@ pub async fn run() -> Result<(), JsValue> { sleep_ms(2000).await?; client.disconnect(); let msg = "data1: workflow complete"; - log(msg)?; + log(msg); set_module_status(msg)?; Ok(()) } @@ -117,9 +121,9 @@ async fn put_file(url: &str, content: &str) -> Result<(), JsValue> { opts.set_body(&JsValue::from_str(content)); let request = Request::new_with_str_and_init(url, &opts)?; - let window = web_sys::window().unwrap(); + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?; - let resp: Response = resp_value.dyn_into().unwrap(); + let resp: Response = resp_value.dyn_into_msg("PUT response was not a Response")?; if resp.status() == 200 { Ok(()) @@ -129,9 +133,9 @@ async fn put_file(url: &str, content: &str) -> Result<(), JsValue> { } async fn get_file(url: &str) -> Result { - let window = web_sys::window().unwrap(); + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let resp_value = JsFuture::from(window.fetch_with_str(url)).await?; - let resp: Response = resp_value.dyn_into().unwrap(); + let resp: Response = resp_value.dyn_into_msg("GET response was not a Response")?; if resp.status() != 200 { return Err(JsValue::from_str(&format!("GET failed with status {}", resp.status()))); @@ -143,23 +147,22 @@ async fn get_file(url: &str) -> Result { } async fn wait_for_response(cell: &Rc>>, prefix: &str) -> Result { - for _ in 0..50 { + for _ in 0_u32..50 { let val = cell.borrow().clone(); - if let Some(s) = val - && s.starts_with(prefix) + if let Some(message) = val + && message.starts_with(prefix) { *cell.borrow_mut() = None; - return Ok(s); + return Ok(message); } sleep_ms(100).await?; } Err(JsValue::from_str("Timeout waiting for server response")) } -fn log(message: &str) -> Result<(), JsValue> { +fn log(message: &str) { let line = format!("[data1] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); - Ok(()) } fn set_module_status(message: &str) -> Result<(), JsValue> { @@ -167,7 +170,7 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -177,7 +180,7 @@ async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { } async fn wait_for_agent_id(client: &WsClient) -> Result { - for _ in 0..100 { + for _ in 0_u32..100 { let agent_id = client.get_agent_id(); if !agent_id.is_empty() { return Ok(agent_id); @@ -191,9 +194,10 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, _reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms); + let _id: Result = + window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms); }); JsFuture::from(promise).await.map(|_| ()) } @@ -203,10 +207,10 @@ fn websocket_url() -> Result { let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? .as_string() - .unwrap(); + .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; let host = Reflect::get(&location, &JsValue::from_str("host"))? .as_string() - .unwrap(); + .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; Ok(format!("{ws_protocol}//{host}/ws")) } diff --git a/services/ws-modules/face-detection/Cargo.toml b/services/ws-modules/face-detection/Cargo.toml index 952a66c..d02352c 100644 --- a/services/ws-modules/face-detection/Cargo.toml +++ b/services/ws-modules/face-detection/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [package.metadata.ws-module.dependencies] et-model-face1 = "*" @@ -15,7 +16,7 @@ onnxruntime-web = "*" [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -44,3 +45,6 @@ web-sys = { version = "0.3", features = [ [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/face-detection/src/lib.rs b/services/ws-modules/face-detection/src/lib.rs index 6f81e14..5168d65 100644 --- a/services/ws-modules/face-detection/src/lib.rs +++ b/services/ws-modules/face-detection/src/lib.rs @@ -1,12 +1,33 @@ +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_lossless, + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + clippy::default_numeric_fallback, + clippy::float_arithmetic, + clippy::future_not_send, + clippy::indexing_slicing, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::let_underscore_must_use, + clippy::let_underscore_untyped, + clippy::single_call_fn, + clippy::suboptimal_flops, + let_underscore_drop, + unused_results, + reason = "browser WASM CV module: tensor math, JsFuture, Reflect::set discards, inline f64 literals are inherent" +)] + use std::cell::{Cell, RefCell}; use std::rc::Rc; -use et_web::{JsCastExt, JsFunctionExt, JsPromiseExt, get_media_devices}; +use et_web::{JsCastExt as _, JsFunctionExt as _, JsPromiseExt as _, get_media_devices}; use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; use js_sys::{Array, Float32Array, Function, Promise, Reflect}; use serde_json::json; use tracing::info; -use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, spawn_local}; use web_sys::MediaStreamConstraints; @@ -64,7 +85,7 @@ pub struct VideoCapture { #[wasm_bindgen] impl VideoCapture { #[wasm_bindgen(js_name = request)] - pub async fn request() -> Result { + pub async fn request() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let media_devices = get_media_devices(&window.navigator())?; @@ -81,14 +102,16 @@ impl VideoCapture { stream.get_video_tracks().length() ); - Ok(VideoCapture { stream }) + Ok(Self { stream }) } + #[must_use] #[wasm_bindgen(js_name = trackCount)] pub fn track_count(&self) -> u32 { self.stream.get_video_tracks().length() } + #[must_use] #[wasm_bindgen(js_name = rawStream)] pub fn raw_stream(&self) -> JsValue { self.stream.clone().into() @@ -116,11 +139,16 @@ pub fn init() { info!("face detection workflow module initialized"); } +#[must_use] #[wasm_bindgen] pub fn is_running() -> bool { FACE_RUNTIME.with(|runtime| runtime.borrow().is_some()) } +#[expect( + clippy::too_many_lines, + reason = "single-method wiring of model load, capture, inference + render timers; splitting fragments closures" +)] #[wasm_bindgen] pub async fn run() -> Result<(), JsValue> { if is_running() { @@ -128,13 +156,13 @@ pub async fn run() -> Result<(), JsValue> { } face_set_status("face detection: starting"); - log(&format!("loading RetinaFace model from {FACE_MODEL_PATH}"))?; + log(&format!("loading RetinaFace model from {FACE_MODEL_PATH}")); let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url.clone())); client.connect()?; wait_for_connected(&client).await?; - log(&format!("websocket connected with agent_id={}", client.get_agent_id()))?; + log(&format!("websocket connected with agent_id={}", client.get_agent_id())); let capture = match VideoCapture::request().await { Ok(capture) => capture, @@ -176,15 +204,15 @@ pub async fn run() -> Result<(), JsValue> { let last_has_detection = Rc::new(Cell::new(false)); let inference_count = Rc::new(Cell::new(0)); - let inference_session = session.clone(); + let inference_session = session; let inference_input_name = input_name.clone(); let inference_output_names = output_names.clone(); let inference_client = client.clone(); - let inference_last_summary = last_summary.clone(); - let inference_pending_flag = inference_pending.clone(); - let inference_last_has_detection = last_has_detection.clone(); - let inference_count_ref = inference_count.clone(); - let inference_closure = Closure::wrap(Box::new(move || { + let inference_last_summary = Rc::clone(&last_summary); + let inference_pending_flag = Rc::clone(&inference_pending); + let inference_last_has_detection = Rc::clone(&last_has_detection); + let inference_count_ref = Rc::clone(&inference_count); + let inference_closure_box: Box = Box::new(move || { if inference_pending_flag.get() { return; } @@ -198,10 +226,10 @@ pub async fn run() -> Result<(), JsValue> { let input_name = inference_input_name.clone(); let output_names = inference_output_names.clone(); let client = inference_client.clone(); - let last_summary = inference_last_summary.clone(); - let pending_flag = inference_pending_flag.clone(); - let last_has_detection = inference_last_has_detection.clone(); - let count_ref = inference_count_ref.clone(); + let last_summary = Rc::clone(&inference_last_summary); + let pending_flag = Rc::clone(&inference_pending_flag); + let last_has_detection = Rc::clone(&inference_last_has_detection); + let count_ref = Rc::clone(&inference_count_ref); spawn_local(async move { let outcome = infer_once(&session, &input_name, &output_names, &client, &last_has_detection).await; @@ -215,30 +243,31 @@ pub async fn run() -> Result<(), JsValue> { *last_summary.borrow_mut() = Some(summary); if count >= 20 { - let _ = log("workflow finished automatically after 20 inferences"); + log("workflow finished automatically after 20 inferences"); let _ = stop(); } } Err(error) => { let message = describe_js_error(&error); face_set_status(&format!("face detection: inference error\n{message}")); - let _ = log(&format!("inference error: {message}")); + log(&format!("inference error: {message}")); } } pending_flag.set(false); }); - }) as Box); + }); + let inference_closure = Closure::wrap(inference_closure_box); - let render_last_summary = last_summary.clone(); - let render_closure = Closure::wrap(Box::new(move || { + let render_last_summary = Rc::clone(&last_summary); + let render_closure_box: Box = Box::new(move || { let detections = render_last_summary .borrow() .as_ref() - .map(|summary| summary.detections.clone()) - .unwrap_or_default(); + .map_or_else(Vec::new, |summary| summary.detections.clone()); let _ = face_render(&detections); - }) as Box); + }); + let render_closure = Closure::wrap(render_closure_box); let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let inference_interval_id = window.set_interval_with_callback_and_timeout_and_arguments_0( @@ -252,7 +281,7 @@ pub async fn run() -> Result<(), JsValue> { let stop_callback = Closure::once_into_js(move || { if is_running() { - let _ = log("workflow finished automatically after 30 seconds"); + log("workflow finished automatically after 30 seconds"); let _ = stop(); } }); @@ -264,7 +293,7 @@ pub async fn run() -> Result<(), JsValue> { processed_at: String::from("waiting for first inference"), }; update_face_status(&input_name, &output_names, &startup_summary); - log("face detection demo started")?; + log("face detection demo started"); FACE_RUNTIME.with(|runtime| { *runtime.borrow_mut() = Some(FaceDetectionRuntime { @@ -299,7 +328,7 @@ pub fn stop() -> Result<(), JsValue> { runtime.client.disconnect(); face_detach_stream(); face_set_status("face detection demo stopped."); - log("face detection demo stopped")?; + log("face detection demo stopped"); Ok(()) }) } @@ -392,9 +421,12 @@ fn decode_retinaface_outputs( source_width: f64, source_height: f64, ) -> Result { - let loc_tensor = Reflect::get(outputs, &JsValue::from_str(&output_names[0]))?; - let conf_tensor = Reflect::get(outputs, &JsValue::from_str(&output_names[1]))?; - let landm_tensor = Reflect::get(outputs, &JsValue::from_str(&output_names[2]))?; + let [loc_name, conf_name, landm_name, ..] = output_names else { + return Err(JsValue::from_str("RetinaFace session must expose 3 output names")); + }; + let loc_tensor = Reflect::get(outputs, &JsValue::from_str(loc_name))?; + let conf_tensor = Reflect::get(outputs, &JsValue::from_str(conf_name))?; + let landm_tensor = Reflect::get(outputs, &JsValue::from_str(landm_name))?; let loc_values = tensor_f32_values(&loc_tensor)?; let conf_values = tensor_f32_values(&conf_tensor)?; @@ -441,7 +473,7 @@ fn decode_retinaface_outputs( } let detections = apply_nms(detections, RETINAFACE_NMS_THRESHOLD); - let confidence = detections.first().map(|entry| entry.score).unwrap_or(0.0); + let confidence = detections.first().map_or(0.0, |entry| entry.score); Ok(DetectionSummary { detections, confidence, @@ -541,8 +573,12 @@ fn softmax(values: &[f64]) -> Vec { exps.into_iter().map(|value| value / sum).collect() } +#[expect( + clippy::missing_const_for_fn, + reason = "f64::clamp is not yet const-stable as of Rust 1.95" +)] fn clamp(value: f64, min: f64, max: f64) -> f64 { - value.max(min).min(max) + value.clamp(min, max) } fn method(target: &JsValue, name: &str) -> Result { @@ -695,7 +731,7 @@ fn describe_js_error(error: &JsValue) -> String { .unwrap_or_else(|| format!("{error:?}")) } -fn log(message: &str) -> Result<(), JsValue> { +fn log(message: &str) { let line = format!("[face-detection] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); @@ -711,8 +747,6 @@ fn log(message: &str) -> Result<(), JsValue> { }; log_el.set_text_content(Some(&next)); } - - Ok(()) } async fn face_attach_stream(stream: JsValue) -> Result<(), JsValue> { diff --git a/services/ws-modules/face-detection/src/test_face_detection.rs b/services/ws-modules/face-detection/src/test_face_detection.rs index e0f33e0..682c45b 100644 --- a/services/ws-modules/face-detection/src/test_face_detection.rs +++ b/services/ws-modules/face-detection/src/test_face_detection.rs @@ -1,3 +1,11 @@ +#![cfg(test)] +#![expect( + clippy::float_cmp, + clippy::indexing_slicing, + clippy::default_numeric_fallback, + reason = "test code: exact float comparisons, slice indexing, and inline f64 fixtures are intentional" +)] + use super::*; #[test] diff --git a/services/ws-modules/geolocation/Cargo.toml b/services/ws-modules/geolocation/Cargo.toml index 2a2bc85..bf96126 100644 --- a/services/ws-modules/geolocation/Cargo.toml +++ b/services/ws-modules/geolocation/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -24,3 +25,6 @@ web-sys = { version = "0.3", features = ["Window", "console"] } [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/geolocation/src/lib.rs b/services/ws-modules/geolocation/src/lib.rs index aff8708..c6a1ca9 100644 --- a/services/ws-modules/geolocation/src/lib.rs +++ b/services/ws-modules/geolocation/src/lib.rs @@ -1,4 +1,10 @@ -use et_web::JsFunctionExt; +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" +)] + +use et_web::JsFunctionExt as _; use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; use js_sys::{Promise, Reflect}; use serde_json::json; @@ -14,9 +20,13 @@ pub struct GeolocationReading { } #[wasm_bindgen] +#[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" +)] impl GeolocationReading { #[wasm_bindgen(js_name = request)] - pub async fn request() -> Result { + pub async fn request() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let navigator = window.navigator(); let geolocation = js_sys::Reflect::get(&navigator, &JsValue::from_str("geolocation"))?; @@ -27,33 +37,35 @@ impl GeolocationReading { } let options = js_sys::Object::new(); - js_sys::Reflect::set(&options, &JsValue::from_str("enableHighAccuracy"), &JsValue::TRUE)?; - js_sys::Reflect::set(&options, &JsValue::from_str("maximumAge"), &JsValue::from_f64(0.0))?; - js_sys::Reflect::set(&options, &JsValue::from_str("timeout"), &JsValue::from_f64(10_000.0))?; + let _: bool = js_sys::Reflect::set(&options, &JsValue::from_str("enableHighAccuracy"), &JsValue::TRUE)?; + let _: bool = js_sys::Reflect::set(&options, &JsValue::from_str("maximumAge"), &JsValue::from_f64(0.0))?; + let _: bool = js_sys::Reflect::set(&options, &JsValue::from_str("timeout"), &JsValue::from_f64(10_000.0))?; let promise = js_sys::Promise::new(&mut |resolve, reject| { let reject_for_callback = reject.clone(); - let success = Closure::once(Box::new(move |position: JsValue| { - let _ = resolve.call1(&JsValue::NULL, &position); - }) as Box); + let success_box: Box = Box::new(move |position: JsValue| { + drop(resolve.call1(&JsValue::NULL, &position)); + }); + let success = Closure::once(success_box); - let failure = Closure::once(Box::new(move |error: JsValue| { - let _ = reject_for_callback.call1(&JsValue::NULL, &error); - }) as Box); + let failure_box: Box = Box::new(move |error: JsValue| { + drop(reject_for_callback.call1(&JsValue::NULL, &error)); + }); + let failure = Closure::once(failure_box); match js_sys::Reflect::get(&geolocation, &JsValue::from_str("getCurrentPosition")) .and_then(|value| value.into_function("navigator.geolocation.getCurrentPosition")) { Ok(get_current_position) => { - let _ = get_current_position.call3( + drop(get_current_position.call3( &geolocation, success.as_ref().unchecked_ref(), failure.as_ref().unchecked_ref(), &options, - ); + )); } Err(err) => { - let _ = reject.call1(&JsValue::NULL, &err); + drop(reject.call1(&JsValue::NULL, &err)); } } @@ -78,21 +90,24 @@ impl GeolocationReading { latitude, longitude, accuracy_meters ); - Ok(GeolocationReading { + Ok(Self { latitude, longitude, accuracy_meters, }) } + #[must_use] pub fn latitude(&self) -> f64 { self.latitude } + #[must_use] pub fn longitude(&self) -> f64 { self.longitude } + #[must_use] #[wasm_bindgen(js_name = accuracyMeters)] pub fn accuracy_meters(&self) -> f64 { self.accuracy_meters @@ -101,11 +116,16 @@ impl GeolocationReading { #[wasm_bindgen(start)] pub fn init() { - let _ = tracing_wasm::try_set_as_global_default(); + drop(tracing_wasm::try_set_as_global_default()); info!("geolocation module initialized"); } +#[must_use] #[wasm_bindgen] +#[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; cannot be marked const" +)] pub fn is_running() -> bool { false } @@ -113,21 +133,21 @@ pub fn is_running() -> bool { #[wasm_bindgen] pub async fn run() -> Result<(), JsValue> { set_module_status("geolocation: entered run()")?; - log("entered run()")?; + log("entered run()"); let outcome = async { let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url)); client.connect()?; wait_for_connected(&client).await?; - log(&format!("websocket connected with agent_id={}", client.get_agent_id()))?; + log(&format!("websocket connected with agent_id={}", client.get_agent_id())); - log("requesting geolocation access")?; + log("requesting geolocation access"); let reading = GeolocationReading::request().await?; let lat = reading.latitude(); let lon = reading.longitude(); let acc = reading.accuracy_meters(); - log(&format!("geolocation acquired: lat={} lon={} acc={}m", lat, lon, acc))?; + log(&format!("geolocation acquired: lat={lat} lon={lon} acc={acc}m")); client.send_client_event( "geolocation", @@ -140,8 +160,7 @@ pub async fn run() -> Result<(), JsValue> { )?; set_module_status(&format!( - "geolocation: reading acquired\nlat: {}\nlon: {}\nacc: {}m", - lat, lon, acc + "geolocation: reading acquired\nlat: {lat}\nlon: {lon}\nacc: {acc}m" ))?; client.disconnect(); @@ -151,15 +170,15 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - let _ = set_module_status(&format!("geolocation: error\n{}", message)); - let _ = log(&format!("error: {}", message)); + drop(set_module_status(&format!("geolocation: error\n{message}"))); + log(&format!("error: {message}")); } outcome } -fn log(message: &str) -> Result<(), JsValue> { - let line = format!("[geolocation] {}", message); +fn log(message: &str) { + let line = format!("[geolocation] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); if let Some(window) = web_sys::window() @@ -170,12 +189,10 @@ fn log(message: &str) -> Result<(), JsValue> { let next = if current.is_empty() { line } else { - format!("{}\n{}", current, line) + format!("{current}\n{line}") }; log_el.set_text_content(Some(&next)); } - - Ok(()) } fn set_module_status(message: &str) -> Result<(), JsValue> { @@ -186,11 +203,11 @@ fn describe_js_error(error: &JsValue) -> String { error .as_string() .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{:?}", error)) + .unwrap_or_else(|| format!("{error:?}")) } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -204,13 +221,13 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - let _ = reject.call1(&JsValue::NULL, &error); + drop(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) @@ -226,5 +243,5 @@ fn websocket_url() -> Result { .as_string() .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{}//{}/ws", ws_protocol, host)) + Ok(format!("{ws_protocol}//{host}/ws")) } diff --git a/services/ws-modules/graphics-info/Cargo.toml b/services/ws-modules/graphics-info/Cargo.toml index 057b751..b696775 100644 --- a/services/ws-modules/graphics-info/Cargo.toml +++ b/services/ws-modules/graphics-info/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -24,3 +25,6 @@ web-sys = { version = "0.3", features = ["Document", "Element", "HtmlCanvasEleme [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/graphics-info/src/lib.rs b/services/ws-modules/graphics-info/src/lib.rs index 50ef5e0..3588bcc 100644 --- a/services/ws-modules/graphics-info/src/lib.rs +++ b/services/ws-modules/graphics-info/src/lib.rs @@ -1,4 +1,11 @@ -use et_web::{JsCastExt, JsFunctionExt, JsPromiseExt}; +#![expect( + clippy::float_arithmetic, + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; GPU benchmark uses float math; pipeline helpers are single-use" +)] + +use et_web::{JsCastExt as _, JsFunctionExt as _, JsPromiseExt as _}; use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; use js_sys::{Promise, Reflect}; use serde_json::json; @@ -7,6 +14,15 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use web_sys::HtmlCanvasElement; +// GPUBuffer usage flags (from the WebGPU spec). +const MAP_READ: u32 = 0x0001; +const COPY_SRC: u32 = 0x0004; +const COPY_DST: u32 = 0x0008; +const STORAGE: u32 = 0x0080; + +/// Bytes for a 4x4 f32 matrix. +const MATRIX_BYTES: f64 = 64.0; + #[wasm_bindgen] pub struct GraphicsSupport { webgl_supported: bool, @@ -18,7 +34,11 @@ pub struct GraphicsSupport { #[wasm_bindgen] impl GraphicsSupport { #[wasm_bindgen(js_name = detect)] - pub fn detect() -> Result { + #[expect( + clippy::similar_names, + reason = "webgl and webgl2 are WebGL spec spellings; they must match the struct fields and JS names" + )] + pub fn detect() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let document = window .document() @@ -37,7 +57,7 @@ impl GraphicsSupport { webgl_supported, webgl2_supported, webgpu_supported, webnn_supported ); - Ok(GraphicsSupport { + Ok(Self { webgl_supported, webgl2_supported, webgpu_supported, @@ -45,22 +65,42 @@ impl GraphicsSupport { }) } + #[must_use] #[wasm_bindgen(js_name = webglSupported)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn webgl_supported(&self) -> bool { self.webgl_supported } + #[must_use] #[wasm_bindgen(js_name = webgl2Supported)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn webgl2_supported(&self) -> bool { self.webgl2_supported } + #[must_use] #[wasm_bindgen(js_name = webgpuSupported)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn webgpu_supported(&self) -> bool { self.webgpu_supported } + #[must_use] #[wasm_bindgen(js_name = webnnSupported)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn webnn_supported(&self) -> bool { self.webnn_supported } @@ -75,13 +115,13 @@ pub struct WebGpuProbeResult { #[wasm_bindgen] impl WebGpuProbeResult { #[wasm_bindgen(js_name = test)] - pub async fn test() -> Result { + pub async fn test() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let navigator = window.navigator(); let gpu = js_sys::Reflect::get(&navigator, &JsValue::from_str("gpu"))?; if gpu.is_null() || gpu.is_undefined() { - return Ok(WebGpuProbeResult { + return Ok(Self { adapter_found: false, device_created: false, }); @@ -95,7 +135,7 @@ impl WebGpuProbeResult { if adapter.is_null() || adapter.is_undefined() { info!("WebGPU probe: no adapter available"); - return Ok(WebGpuProbeResult { + return Ok(Self { adapter_found: false, device_created: false, }); @@ -113,18 +153,28 @@ impl WebGpuProbeResult { device_created ); - Ok(WebGpuProbeResult { + Ok(Self { adapter_found: true, device_created, }) } + #[must_use] #[wasm_bindgen(js_name = adapterFound)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn adapter_found(&self) -> bool { self.adapter_found } + #[must_use] #[wasm_bindgen(js_name = deviceCreated)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn device_created(&self) -> bool { self.device_created } @@ -136,300 +186,306 @@ pub struct GpuComputeResult { success: bool, /// Time taken in milliseconds (JS `performance.now()` delta). elapsed_ms: f64, - /// First element of the output matrix (C[0][0]) for spot-check. + /// First element of the output matrix (`C[0][0]`) for spot-check. result_c00: f32, } +/// Build a `GPUBuffer` with the supplied f32 data and `usage` flags. +fn create_buffer_with_data(device: &JsValue, data: &[f32], usage: u32) -> Result { + let buf_desc = js_sys::Object::new(); + let _: bool = js_sys::Reflect::set(&buf_desc, &JsValue::from_str("size"), &JsValue::from_f64(MATRIX_BYTES))?; + let _: bool = js_sys::Reflect::set( + &buf_desc, + &JsValue::from_str("usage"), + &JsValue::from_f64(f64::from(usage)), + )?; + let _: bool = js_sys::Reflect::set( + &buf_desc, + &JsValue::from_str("mappedAtCreation"), + &JsValue::from_bool(true), + )?; + let create_buffer = + js_sys::Reflect::get(device, &JsValue::from_str("createBuffer"))?.dyn_into::()?; + let buf = create_buffer.call1(device, &buf_desc)?; + let get_mapped = + js_sys::Reflect::get(&buf, &JsValue::from_str("getMappedRange"))?.dyn_into::()?; + let mapped = get_mapped.call0(&buf)?; + let mapped_array = js_sys::Float32Array::new(&mapped); + mapped_array.copy_from(data); + let unmap = js_sys::Reflect::get(&buf, &JsValue::from_str("unmap"))?.dyn_into::()?; + let _unmap_result: JsValue = unmap.call0(&buf)?; + Ok(buf) +} + +fn make_bind_group_entry(binding: u32, buf: &JsValue) -> Result { + let entry = js_sys::Object::new(); + let _: bool = js_sys::Reflect::set( + &entry, + &JsValue::from_str("binding"), + &JsValue::from_f64(f64::from(binding)), + )?; + let resource = js_sys::Object::new(); + let _: bool = js_sys::Reflect::set(&resource, &JsValue::from_str("buffer"), buf)?; + let _: bool = js_sys::Reflect::set(&entry, &JsValue::from_str("resource"), &resource)?; + Ok(entry) +} + +#[expect( + clippy::too_many_lines, + reason = "linear WebGPU compute pipeline setup; splitting further obscures the buffer/binding wiring" +)] +async fn run_gpu_compute_inner(device: &JsValue, window: &web_sys::Window) -> Result<(f64, f32), JsValue> { + // Catch any silent WebGPU validation errors. + let push_error_scope = + js_sys::Reflect::get(device, &JsValue::from_str("pushErrorScope"))?.dyn_into::()?; + let _push_result: JsValue = push_error_scope.call1(device, &JsValue::from_str("validation"))?; + + // 4x4 matrices stored as f32 arrays (row-major). A = identity, B = 2x identity. + #[rustfmt::skip] + let mat_a: [f32; 16] = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + ]; + #[rustfmt::skip] + let mat_b: [f32; 16] = [ + 2.0, 0.0, 0.0, 0.0, + 0.0, 2.0, 0.0, 0.0, + 0.0, 0.0, 2.0, 0.0, + 0.0, 0.0, 0.0, 2.0, + ]; + + let buf_a = create_buffer_with_data(device, &mat_a, STORAGE)?; + let buf_b = create_buffer_with_data(device, &mat_b, STORAGE)?; + + let out_desc = js_sys::Object::new(); + let _: bool = js_sys::Reflect::set(&out_desc, &JsValue::from_str("size"), &JsValue::from_f64(MATRIX_BYTES))?; + let _: bool = js_sys::Reflect::set( + &out_desc, + &JsValue::from_str("usage"), + &JsValue::from_f64(f64::from(STORAGE | COPY_SRC)), + )?; + let create_buffer_fn = + js_sys::Reflect::get(device, &JsValue::from_str("createBuffer"))?.dyn_into::()?; + let buf_out = create_buffer_fn.call1(device, &out_desc)?; + + let rb_desc = js_sys::Object::new(); + let _: bool = js_sys::Reflect::set(&rb_desc, &JsValue::from_str("size"), &JsValue::from_f64(MATRIX_BYTES))?; + let _: bool = js_sys::Reflect::set( + &rb_desc, + &JsValue::from_str("usage"), + &JsValue::from_f64(f64::from(COPY_DST | MAP_READ)), + )?; + let buf_readback = create_buffer_fn.call1(device, &rb_desc)?; + + // WGSL compute shader: 4x4 matrix multiply. + let wgsl = " +@group(0) @binding(0) var matA : array; +@group(0) @binding(1) var matB : array; +@group(0) @binding(2) var matC : array; + +@compute @workgroup_size(4, 4) +fn main(@builtin(global_invocation_id) gid : vec3) { + let row = gid.y; + let col = gid.x; + var sum : f32 = 0.0; + for (var k : u32 = 0u; k < 4u; k = k + 1u) { + sum = sum + matA[row * 4u + k] * matB[k * 4u + col]; + } + matC[row * 4u + col] = sum; +} +"; + + let shader_desc = js_sys::Object::new(); + let _: bool = js_sys::Reflect::set(&shader_desc, &JsValue::from_str("code"), &JsValue::from_str(wgsl))?; + let create_shader = + js_sys::Reflect::get(device, &JsValue::from_str("createShaderModule"))?.dyn_into::()?; + let shader = create_shader.call1(device, &shader_desc)?; + + let compute_stage = js_sys::Object::new(); + let _: bool = js_sys::Reflect::set(&compute_stage, &JsValue::from_str("module"), &shader)?; + let _: bool = js_sys::Reflect::set( + &compute_stage, + &JsValue::from_str("entryPoint"), + &JsValue::from_str("main"), + )?; + let cp_desc = js_sys::Object::new(); + let _: bool = js_sys::Reflect::set(&cp_desc, &JsValue::from_str("layout"), &JsValue::from_str("auto"))?; + let _: bool = js_sys::Reflect::set(&cp_desc, &JsValue::from_str("compute"), &compute_stage)?; + let create_cp = js_sys::Reflect::get(device, &JsValue::from_str("createComputePipelineAsync"))? + .dyn_into::()?; + let pipeline = JsFuture::from(create_cp.call1(device, &cp_desc)?.dyn_into::()?).await?; + + let get_bgl = + js_sys::Reflect::get(&pipeline, &JsValue::from_str("getBindGroupLayout"))?.dyn_into::()?; + let bgl = get_bgl.call1(&pipeline, &JsValue::from_f64(0.0))?; + + let bg_entries = js_sys::Array::new(); + let _len_a: u32 = bg_entries.push(&make_bind_group_entry(0, &buf_a)?.into()); + let _len_b: u32 = bg_entries.push(&make_bind_group_entry(1, &buf_b)?.into()); + let _len_c: u32 = bg_entries.push(&make_bind_group_entry(2, &buf_out)?.into()); + let bg_desc = js_sys::Object::new(); + let _: bool = js_sys::Reflect::set(&bg_desc, &JsValue::from_str("layout"), &bgl)?; + let _: bool = js_sys::Reflect::set(&bg_desc, &JsValue::from_str("entries"), &bg_entries)?; + let create_bg = + js_sys::Reflect::get(device, &JsValue::from_str("createBindGroup"))?.dyn_into::()?; + let bind_group = create_bg.call1(device, &bg_desc)?; + + let perf = js_sys::Reflect::get(window, &JsValue::from_str("performance"))?; + let now_fn = js_sys::Reflect::get(&perf, &JsValue::from_str("now"))?.dyn_into::()?; + let t0 = now_fn.call0(&perf)?.as_f64().unwrap_or(0.0_f64); + + let create_encoder = + js_sys::Reflect::get(device, &JsValue::from_str("createCommandEncoder"))?.dyn_into::()?; + let encoder = create_encoder.call0(device)?; + + let begin_compute = + js_sys::Reflect::get(&encoder, &JsValue::from_str("beginComputePass"))?.dyn_into::()?; + let pass = begin_compute.call0(&encoder)?; + + let set_pipeline = + js_sys::Reflect::get(&pass, &JsValue::from_str("setPipeline"))?.dyn_into::()?; + let _sp_result: JsValue = set_pipeline.call1(&pass, &pipeline)?; + + let set_bg = js_sys::Reflect::get(&pass, &JsValue::from_str("setBindGroup"))?.dyn_into::()?; + let _sbg_result: JsValue = set_bg.call2(&pass, &JsValue::from_f64(0.0), &bind_group)?; + + let dispatch = + js_sys::Reflect::get(&pass, &JsValue::from_str("dispatchWorkgroups"))?.dyn_into::()?; + let _disp_result: JsValue = dispatch.call2(&pass, &JsValue::from_f64(1.0), &JsValue::from_f64(1.0))?; + + let end_pass = js_sys::Reflect::get(&pass, &JsValue::from_str("end"))?.dyn_into::()?; + let _end_result: JsValue = end_pass.call0(&pass)?; + + let copy_buf = + js_sys::Reflect::get(&encoder, &JsValue::from_str("copyBufferToBuffer"))?.dyn_into::()?; + let _copy_result: JsValue = copy_buf.call5( + &encoder, + &buf_out, + &JsValue::from_f64(0.0), + &buf_readback, + &JsValue::from_f64(0.0), + &JsValue::from_f64(MATRIX_BYTES), + )?; + + let finish = js_sys::Reflect::get(&encoder, &JsValue::from_str("finish"))?.dyn_into::()?; + let cmd_buf = finish.call0(&encoder)?; + + let queue = js_sys::Reflect::get(device, &JsValue::from_str("queue"))?; + let submit = js_sys::Reflect::get(&queue, &JsValue::from_str("submit"))?.dyn_into::()?; + let cmds = js_sys::Array::new(); + let _cmds_len: u32 = cmds.push(&cmd_buf); + let _submit_result: JsValue = submit.call1(&queue, &cmds)?; + + let pop_error_scope = + js_sys::Reflect::get(device, &JsValue::from_str("popErrorScope"))?.dyn_into::()?; + let gpu_error = JsFuture::from(pop_error_scope.call0(device)?.dyn_into::()?).await?; + if !gpu_error.is_null() && !gpu_error.is_undefined() { + let msg = js_sys::Reflect::get(&gpu_error, &JsValue::from_str("message")) + .ok() + .and_then(|value| value.as_string()) + .unwrap_or_else(|| "unknown GPU validation error".to_string()); + return Err(JsValue::from_str(&format!("WebGPU validation error: {msg}"))); + } + + let map_async = + js_sys::Reflect::get(&buf_readback, &JsValue::from_str("mapAsync"))?.dyn_into::()?; + let _map_result: JsValue = JsFuture::from( + map_async + .call1(&buf_readback, &JsValue::from_f64(1.0))? + .dyn_into::()?, + ) + .await?; + + let t1 = now_fn.call0(&perf)?.as_f64().unwrap_or(0.0_f64); + + let get_mapped = + js_sys::Reflect::get(&buf_readback, &JsValue::from_str("getMappedRange"))?.dyn_into::()?; + let mapped = get_mapped.call0(&buf_readback)?; + let result_array = js_sys::Float32Array::new(&mapped); + let result_c00 = result_array.get_index(0); + + let unmap = js_sys::Reflect::get(&buf_readback, &JsValue::from_str("unmap"))?.dyn_into::()?; + let _unmap_result: JsValue = unmap.call0(&buf_readback)?; + + Ok((t1 - t0, result_c00)) +} + #[wasm_bindgen] impl GpuComputeResult { - /// Run a 4×4 matrix multiply A×B=C on the GPU using a WebGPU compute shader. - /// - /// A and B are hard-coded identity-like matrices so the expected C[0][0] = 1.0. + /// Run a 4x4 matrix multiply A×B=C on the GPU using a WebGPU compute shader. #[wasm_bindgen(js_name = run)] - pub async fn run() -> Result { + pub async fn run() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window"))?; let navigator = window.navigator(); let gpu = js_sys::Reflect::get(&navigator, &JsValue::from_str("gpu"))?; if gpu.is_null() || gpu.is_undefined() { - return Ok(GpuComputeResult { + return Ok(Self { success: false, elapsed_ms: 0.0, result_c00: 0.0, }); } - // requestAdapter let request_adapter: js_sys::Function = js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter"))? .dyn_into_msg("gpu.requestAdapter not callable")?; let adapter = JsFuture::from(request_adapter.call0(&gpu)?.dyn_into::()?).await?; if adapter.is_null() || adapter.is_undefined() { - return Ok(GpuComputeResult { + return Ok(Self { success: false, elapsed_ms: 0.0, result_c00: 0.0, }); } - // requestDevice let request_device: js_sys::Function = js_sys::Reflect::get(&adapter, &JsValue::from_str("requestDevice"))? .dyn_into_msg("adapter.requestDevice not callable")?; let device = JsFuture::from(request_device.call0(&adapter)?.dyn_into::()?).await?; if device.is_null() || device.is_undefined() { - return Ok(GpuComputeResult { + return Ok(Self { success: false, elapsed_ms: 0.0, result_c00: 0.0, }); } - // Catch any silent WebGPU validation errors. - let push_error_scope = - js_sys::Reflect::get(&device, &JsValue::from_str("pushErrorScope"))?.dyn_into::()?; - push_error_scope.call1(&device, &JsValue::from_str("validation"))?; - - // 4×4 matrices stored as f32 arrays (row-major). - // A = identity, B = identity → C = identity, so C[0][0] = 1.0. - #[rustfmt::skip] - let a: [f32; 16] = [ - 1.0, 0.0, 0.0, 0.0, - 0.0, 1.0, 0.0, 0.0, - 0.0, 0.0, 1.0, 0.0, - 0.0, 0.0, 0.0, 1.0, - ]; - #[rustfmt::skip] - let b: [f32; 16] = [ - 2.0, 0.0, 0.0, 0.0, - 0.0, 2.0, 0.0, 0.0, - 0.0, 0.0, 2.0, 0.0, - 0.0, 0.0, 0.0, 2.0, - ]; - - let matrix_bytes = (16 * 4) as f64; // 16 f32 = 64 bytes - - // Helper: create a GPUBuffer from a &[f32]. - let create_buffer_with_data = |data: &[f32], usage: u32| -> Result { - let buf_desc = js_sys::Object::new(); - js_sys::Reflect::set(&buf_desc, &JsValue::from_str("size"), &JsValue::from_f64(matrix_bytes))?; - js_sys::Reflect::set(&buf_desc, &JsValue::from_str("usage"), &JsValue::from_f64(usage as f64))?; - js_sys::Reflect::set( - &buf_desc, - &JsValue::from_str("mappedAtCreation"), - &JsValue::from_bool(true), - )?; - let create_buffer = - js_sys::Reflect::get(&device, &JsValue::from_str("createBuffer"))?.dyn_into::()?; - let buf = create_buffer.call1(&device, &buf_desc)?; - // getMappedRange → write data → unmap - let get_mapped = - js_sys::Reflect::get(&buf, &JsValue::from_str("getMappedRange"))?.dyn_into::()?; - let mapped = get_mapped.call0(&buf)?; - let mapped_array = js_sys::Float32Array::new(&mapped); - mapped_array.copy_from(data); - let unmap = js_sys::Reflect::get(&buf, &JsValue::from_str("unmap"))?.dyn_into::()?; - unmap.call0(&buf)?; - Ok(buf) - }; - - // GPUBuffer usage flags (from the WebGPU spec). - const MAP_READ: u32 = 0x0001; - const COPY_SRC: u32 = 0x0004; - const COPY_DST: u32 = 0x0008; - const STORAGE: u32 = 0x0080; - - let buf_a = create_buffer_with_data(&a, STORAGE)?; - let buf_b = create_buffer_with_data(&b, STORAGE)?; - - // Output buffer (STORAGE | COPY_SRC so we can copy to a readback buffer). - let out_desc = js_sys::Object::new(); - js_sys::Reflect::set(&out_desc, &JsValue::from_str("size"), &JsValue::from_f64(matrix_bytes))?; - js_sys::Reflect::set( - &out_desc, - &JsValue::from_str("usage"), - &JsValue::from_f64((STORAGE | COPY_SRC) as f64), - )?; - let create_buffer_fn = - js_sys::Reflect::get(&device, &JsValue::from_str("createBuffer"))?.dyn_into::()?; - let buf_out = create_buffer_fn.call1(&device, &out_desc)?; - - // Readback buffer (COPY_DST | MAP_READ). - let rb_desc = js_sys::Object::new(); - js_sys::Reflect::set(&rb_desc, &JsValue::from_str("size"), &JsValue::from_f64(matrix_bytes))?; - js_sys::Reflect::set( - &rb_desc, - &JsValue::from_str("usage"), - &JsValue::from_f64((COPY_DST | MAP_READ) as f64), - )?; - let buf_readback = create_buffer_fn.call1(&device, &rb_desc)?; + let (elapsed_ms, result_c00) = run_gpu_compute_inner(&device, &window).await?; - // WGSL compute shader: 4×4 matrix multiply. - let wgsl = r#" -@group(0) @binding(0) var matA : array; -@group(0) @binding(1) var matB : array; -@group(0) @binding(2) var matC : array; - -@compute @workgroup_size(4, 4) -fn main(@builtin(global_invocation_id) gid : vec3) { - let row = gid.y; - let col = gid.x; - var sum : f32 = 0.0; - for (var k : u32 = 0u; k < 4u; k = k + 1u) { - sum = sum + matA[row * 4u + k] * matB[k * 4u + col]; - } - matC[row * 4u + col] = sum; -} -"#; - - // createShaderModule - let shader_desc = js_sys::Object::new(); - js_sys::Reflect::set(&shader_desc, &JsValue::from_str("code"), &JsValue::from_str(wgsl))?; - let create_shader = - js_sys::Reflect::get(&device, &JsValue::from_str("createShaderModule"))?.dyn_into::()?; - let shader = create_shader.call1(&device, &shader_desc)?; - - // createComputePipelineAsync with layout:"auto" — browser derives BGL from shader - let compute_stage = js_sys::Object::new(); - js_sys::Reflect::set(&compute_stage, &JsValue::from_str("module"), &shader)?; - js_sys::Reflect::set( - &compute_stage, - &JsValue::from_str("entryPoint"), - &JsValue::from_str("main"), - )?; - let cp_desc = js_sys::Object::new(); - js_sys::Reflect::set(&cp_desc, &JsValue::from_str("layout"), &JsValue::from_str("auto"))?; - js_sys::Reflect::set(&cp_desc, &JsValue::from_str("compute"), &compute_stage)?; - let create_cp = js_sys::Reflect::get(&device, &JsValue::from_str("createComputePipelineAsync"))? - .dyn_into::()?; - let pipeline = JsFuture::from(create_cp.call1(&device, &cp_desc)?.dyn_into::()?).await?; - - // getBindGroupLayout(0) from the pipeline - let get_bgl = js_sys::Reflect::get(&pipeline, &JsValue::from_str("getBindGroupLayout"))? - .dyn_into::()?; - let bgl = get_bgl.call1(&pipeline, &JsValue::from_f64(0.0))?; - - // createBindGroup - let make_bg_entry = |binding: u32, buf: &JsValue| -> Result { - let entry = js_sys::Object::new(); - js_sys::Reflect::set( - &entry, - &JsValue::from_str("binding"), - &JsValue::from_f64(binding as f64), - )?; - let resource = js_sys::Object::new(); - js_sys::Reflect::set(&resource, &JsValue::from_str("buffer"), buf)?; - js_sys::Reflect::set(&entry, &JsValue::from_str("resource"), &resource)?; - Ok(entry) - }; - let bg_entries = js_sys::Array::new(); - bg_entries.push(&make_bg_entry(0, &buf_a)?.into()); - bg_entries.push(&make_bg_entry(1, &buf_b)?.into()); - bg_entries.push(&make_bg_entry(2, &buf_out)?.into()); - let bg_desc = js_sys::Object::new(); - js_sys::Reflect::set(&bg_desc, &JsValue::from_str("layout"), &bgl)?; - js_sys::Reflect::set(&bg_desc, &JsValue::from_str("entries"), &bg_entries)?; - let create_bg = - js_sys::Reflect::get(&device, &JsValue::from_str("createBindGroup"))?.dyn_into::()?; - let bind_group = create_bg.call1(&device, &bg_desc)?; - - // Record and submit commands. - let perf = js_sys::Reflect::get(&window, &JsValue::from_str("performance"))?; - let now_fn = js_sys::Reflect::get(&perf, &JsValue::from_str("now"))?.dyn_into::()?; - let t0 = now_fn.call0(&perf)?.as_f64().unwrap_or(0.0); - - let create_encoder = js_sys::Reflect::get(&device, &JsValue::from_str("createCommandEncoder"))? - .dyn_into::()?; - let encoder = create_encoder.call0(&device)?; - - let begin_compute = - js_sys::Reflect::get(&encoder, &JsValue::from_str("beginComputePass"))?.dyn_into::()?; - let pass = begin_compute.call0(&encoder)?; - - let set_pipeline = - js_sys::Reflect::get(&pass, &JsValue::from_str("setPipeline"))?.dyn_into::()?; - set_pipeline.call1(&pass, &pipeline)?; - - let set_bg = js_sys::Reflect::get(&pass, &JsValue::from_str("setBindGroup"))?.dyn_into::()?; - set_bg.call2(&pass, &JsValue::from_f64(0.0), &bind_group)?; - - let dispatch = - js_sys::Reflect::get(&pass, &JsValue::from_str("dispatchWorkgroups"))?.dyn_into::()?; - dispatch.call2(&pass, &JsValue::from_f64(1.0), &JsValue::from_f64(1.0))?; - - let end_pass = js_sys::Reflect::get(&pass, &JsValue::from_str("end"))?.dyn_into::()?; - end_pass.call0(&pass)?; - - // Copy output → readback buffer. - let copy_buf = - js_sys::Reflect::get(&encoder, &JsValue::from_str("copyBufferToBuffer"))?.dyn_into::()?; - copy_buf.call5( - &encoder, - &buf_out, - &JsValue::from_f64(0.0), - &buf_readback, - &JsValue::from_f64(0.0), - &JsValue::from_f64(matrix_bytes), - )?; - - let finish = js_sys::Reflect::get(&encoder, &JsValue::from_str("finish"))?.dyn_into::()?; - let cmd_buf = finish.call0(&encoder)?; - - let queue = js_sys::Reflect::get(&device, &JsValue::from_str("queue"))?; - let submit = js_sys::Reflect::get(&queue, &JsValue::from_str("submit"))?.dyn_into::()?; - let cmds = js_sys::Array::new(); - cmds.push(&cmd_buf); - submit.call1(&queue, &cmds)?; - - // Pop error scope — surface any validation error before attempting mapAsync. - let pop_error_scope = - js_sys::Reflect::get(&device, &JsValue::from_str("popErrorScope"))?.dyn_into::()?; - let gpu_error = JsFuture::from(pop_error_scope.call0(&device)?.dyn_into::()?).await?; - if !gpu_error.is_null() && !gpu_error.is_undefined() { - let msg = js_sys::Reflect::get(&gpu_error, &JsValue::from_str("message")) - .ok() - .and_then(|v| v.as_string()) - .unwrap_or_else(|| "unknown GPU validation error".to_string()); - return Err(JsValue::from_str(&format!("WebGPU validation error: {}", msg))); - } - - // Map readback buffer and read C[0][0]. - let map_async = - js_sys::Reflect::get(&buf_readback, &JsValue::from_str("mapAsync"))?.dyn_into::()?; - JsFuture::from( - map_async - .call1(&buf_readback, &JsValue::from_f64(1.0))? - .dyn_into::()?, - ) - .await?; - - let t1 = now_fn.call0(&perf)?.as_f64().unwrap_or(0.0); - - let get_mapped = js_sys::Reflect::get(&buf_readback, &JsValue::from_str("getMappedRange"))? - .dyn_into::()?; - let mapped = get_mapped.call0(&buf_readback)?; - let result_array = js_sys::Float32Array::new(&mapped); - let result_c00 = result_array.get_index(0); - - let unmap = js_sys::Reflect::get(&buf_readback, &JsValue::from_str("unmap"))?.dyn_into::()?; - unmap.call0(&buf_readback)?; - - Ok(GpuComputeResult { + Ok(Self { success: true, - elapsed_ms: t1 - t0, + elapsed_ms, result_c00, }) } + #[must_use] #[wasm_bindgen(js_name = success)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn success(&self) -> bool { self.success } + #[must_use] #[wasm_bindgen(js_name = elapsedMs)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn elapsed_ms(&self) -> f64 { self.elapsed_ms } - /// C[0][0] of the output matrix. For identity × 2×identity the expected value is 2.0. + /// `C[0][0]` of the output matrix. For identity × 2×identity the expected value is 2.0. + #[must_use] #[wasm_bindgen(js_name = resultC00)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn result_c00(&self) -> f32 { self.result_c00 } @@ -447,7 +503,7 @@ pub struct GpuInfo { #[wasm_bindgen] impl GpuInfo { #[wasm_bindgen(js_name = detect)] - pub async fn detect() -> Result { + pub async fn detect() -> Result { if let Some(info) = detect_webgpu_info().await? { return Ok(info); } @@ -456,7 +512,7 @@ impl GpuInfo { return Ok(info); } - Ok(GpuInfo { + Ok(Self { vendor: "unknown".to_string(), renderer: "unknown".to_string(), architecture: "unknown".to_string(), @@ -465,22 +521,27 @@ impl GpuInfo { }) } + #[must_use] pub fn vendor(&self) -> String { self.vendor.clone() } + #[must_use] pub fn renderer(&self) -> String { self.renderer.clone() } + #[must_use] pub fn architecture(&self) -> String { self.architecture.clone() } + #[must_use] pub fn description(&self) -> String { self.description.clone() } + #[must_use] pub fn source(&self) -> String { self.source.clone() } @@ -495,12 +556,11 @@ async fn detect_webgpu_info() -> Result, JsValue> { return Ok(None); } - let request_adapter = match js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter")) + let Some(request_adapter) = js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter")) .ok() .and_then(|value| value.dyn_into::().ok()) - { - Some(request_adapter) => request_adapter, - None => return Ok(None), + else { + return Ok(None); }; let adapter_promise = request_adapter.call0(&gpu)?.into_promise("requestAdapter")?; @@ -559,12 +619,11 @@ fn detect_webgl_info() -> Result, JsValue> { return Ok(None); }; - let get_extension = match js_sys::Reflect::get(&context, &JsValue::from_str("getExtension")) + let Some(get_extension) = js_sys::Reflect::get(&context, &JsValue::from_str("getExtension")) .ok() .and_then(|value| value.dyn_into::().ok()) - { - Some(get_extension) => get_extension, - None => return Ok(None), + else { + return Ok(None); }; let extension = get_extension.call1(&context, &JsValue::from_str("WEBGL_debug_renderer_info"))?; @@ -572,12 +631,11 @@ fn detect_webgl_info() -> Result, JsValue> { return Ok(None); } - let get_parameter = match js_sys::Reflect::get(&context, &JsValue::from_str("getParameter")) + let Some(get_parameter) = js_sys::Reflect::get(&context, &JsValue::from_str("getParameter")) .ok() .and_then(|value| value.dyn_into::().ok()) - { - Some(get_parameter) => get_parameter, - None => return Ok(None), + else { + return Ok(None); }; let vendor_enum = js_sys::Reflect::get(&extension, &JsValue::from_str("UNMASKED_VENDOR_WEBGL"))?; @@ -616,28 +674,37 @@ fn string_or_unknown(value: String) -> String { #[wasm_bindgen(start)] pub fn init() { - let _ = tracing_wasm::try_set_as_global_default(); + drop(tracing_wasm::try_set_as_global_default()); info!("graphics-info module initialized"); } +#[must_use] #[wasm_bindgen] +#[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; cannot be marked const" +)] pub fn is_running() -> bool { false } +#[expect( + clippy::too_many_lines, + reason = "linear scenario script: detect graphics support, probe WebGPU, run compute, emit JSON event" +)] #[wasm_bindgen] pub async fn run() -> Result<(), JsValue> { set_module_status("graphics-info: entered run()")?; - log("entered run()")?; + log("entered run()"); let outcome = async { let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url)); client.connect()?; wait_for_connected(&client).await?; - log(&format!("websocket connected with agent_id={}", client.get_agent_id()))?; + log(&format!("websocket connected with agent_id={}", client.get_agent_id())); - log("detecting graphics support")?; + log("detecting graphics support"); let support = GraphicsSupport::detect()?; log(&format!( "graphics support: webgl={} webgl2={} webgpu={} webnn={}", @@ -645,17 +712,17 @@ pub async fn run() -> Result<(), JsValue> { support.webgl2_supported(), support.webgpu_supported(), support.webnn_supported() - ))?; + )); - log("probing WebGPU")?; + log("probing WebGPU"); let probe = WebGpuProbeResult::test().await?; log(&format!( "WebGPU probe: adapter={} device={}", probe.adapter_found(), probe.device_created() - ))?; + )); - log("detecting GPU info")?; + log("detecting GPU info"); let gpu = GpuInfo::detect().await?; log(&format!( "GPU info: vendor={} renderer={} architecture={} source={}", @@ -663,23 +730,22 @@ pub async fn run() -> Result<(), JsValue> { gpu.renderer(), gpu.architecture(), gpu.source() - ))?; + )); - log("running GPU matrix multiply (4×4)")?; + log("running GPU matrix multiply (4x4)"); let compute = GpuComputeResult::run().await?; if compute.success() { let expected = 2.0_f32; if (compute.result_c00() - expected).abs() < 1e-4 { - log("GPU compute: ok")?; + log("GPU compute: ok"); } else { log(&format!( - "GPU compute: WRONG result C[0][0]={} (expected {})", - compute.result_c00(), - expected - ))?; + "GPU compute: WRONG result C[0][0]={} (expected {expected})", + compute.result_c00() + )); } } else { - log("GPU compute: skipped (WebGPU unavailable)")?; + log("GPU compute: skipped (WebGPU unavailable)"); } client.send_client_event( @@ -734,15 +800,15 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - let _ = set_module_status(&format!("graphics-info: error\n{}", message)); - let _ = log(&format!("error: {}", message)); + drop(set_module_status(&format!("graphics-info: error\n{message}"))); + log(&format!("error: {message}")); } outcome } -fn log(message: &str) -> Result<(), JsValue> { - let line = format!("[graphics-info] {}", message); +fn log(message: &str) { + let line = format!("[graphics-info] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); if let Some(window) = web_sys::window() @@ -753,12 +819,10 @@ fn log(message: &str) -> Result<(), JsValue> { let next = if current.is_empty() { line } else { - format!("{}\n{}", current, line) + format!("{current}\n{line}") }; log_el.set_text_content(Some(&next)); } - - Ok(()) } fn set_module_status(message: &str) -> Result<(), JsValue> { @@ -769,11 +833,11 @@ fn describe_js_error(error: &JsValue) -> String { error .as_string() .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{:?}", error)) + .unwrap_or_else(|| format!("{error:?}")) } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -787,13 +851,13 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - let _ = reject.call1(&JsValue::NULL, &error); + drop(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) @@ -809,5 +873,5 @@ fn websocket_url() -> Result { .as_string() .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{}//{}/ws", ws_protocol, host)) + Ok(format!("{ws_protocol}//{host}/ws")) } diff --git a/services/ws-modules/har1/Cargo.toml b/services/ws-modules/har1/Cargo.toml index 65bc904..ee8cbb0 100644 --- a/services/ws-modules/har1/Cargo.toml +++ b/services/ws-modules/har1/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [package.metadata.ws-module.dependencies] et-model-har-motion1 = "*" @@ -15,7 +16,7 @@ onnxruntime-web = "*" [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -46,3 +47,6 @@ web-sys = { version = "0.3", features = [ [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/har1/src/lib.rs b/services/ws-modules/har1/src/lib.rs index 09368a8..d1e4ace 100644 --- a/services/ws-modules/har1/src/lib.rs +++ b/services/ws-modules/har1/src/lib.rs @@ -1,8 +1,23 @@ +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::default_numeric_fallback, + clippy::float_arithmetic, + clippy::future_not_send, + clippy::indexing_slicing, + clippy::single_call_fn, + clippy::suboptimal_flops, + unused_results, + reason = "browser WASM HAR module: tensor math, sensor buffers, JsFuture, Reflect::set, inline f64 are inherent" +)] + use std::cell::RefCell; use std::collections::VecDeque; use std::rc::Rc; -use et_web::{JsFunctionExt, JsPromiseExt, SENSOR_PERMISSION_GRANTED, request_sensor_permission}; +use et_web::{JsFunctionExt as _, JsPromiseExt as _, SENSOR_PERMISSION_GRANTED, request_sensor_permission}; use et_ws_wasm_agent::{ WsClient, WsClientConfig, js_bool_field, js_nested_object, js_number_field, set_textarea_value, }; @@ -52,18 +67,22 @@ pub struct OrientationReading { #[wasm_bindgen] impl OrientationReading { + #[must_use] pub fn alpha(&self) -> f64 { self.inner.alpha.unwrap_or(0.0) } + #[must_use] pub fn beta(&self) -> f64 { self.inner.beta.unwrap_or(0.0) } + #[must_use] pub fn gamma(&self) -> f64 { self.inner.gamma.unwrap_or(0.0) } + #[must_use] pub fn absolute(&self) -> bool { self.inner.absolute.unwrap_or(false) } @@ -76,51 +95,61 @@ pub struct MotionReading { #[wasm_bindgen] impl MotionReading { + #[must_use] #[wasm_bindgen(js_name = accelerationX)] pub fn acceleration_x(&self) -> f64 { self.inner.acceleration_x.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationY)] pub fn acceleration_y(&self) -> f64 { self.inner.acceleration_y.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationZ)] pub fn acceleration_z(&self) -> f64 { self.inner.acceleration_z.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationIncludingGravityX)] pub fn acceleration_including_gravity_x(&self) -> f64 { self.inner.acceleration_including_gravity_x.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationIncludingGravityY)] pub fn acceleration_including_gravity_y(&self) -> f64 { self.inner.acceleration_including_gravity_y.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationIncludingGravityZ)] pub fn acceleration_including_gravity_z(&self) -> f64 { self.inner.acceleration_including_gravity_z.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = rotationRateAlpha)] pub fn rotation_rate_alpha(&self) -> f64 { self.inner.rotation_rate_alpha.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = rotationRateBeta)] pub fn rotation_rate_beta(&self) -> f64 { self.inner.rotation_rate_beta.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = rotationRateGamma)] pub fn rotation_rate_gamma(&self) -> f64 { self.inner.rotation_rate_gamma.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = intervalMs)] pub fn interval_ms(&self) -> f64 { self.inner.interval_ms.unwrap_or(0.0) @@ -144,9 +173,10 @@ impl Default for DeviceSensors { #[wasm_bindgen] impl DeviceSensors { + #[must_use] #[wasm_bindgen(constructor)] - pub fn new() -> DeviceSensors { - DeviceSensors { + pub fn new() -> Self { + Self { active: false, orientation_state: Rc::new(RefCell::new(None)), motion_state: Rc::new(RefCell::new(None)), @@ -187,8 +217,8 @@ impl DeviceSensors { *self.orientation_state.borrow_mut() = None; *self.motion_state.borrow_mut() = None; - let orientation_state = self.orientation_state.clone(); - let orientation_listener = Closure::wrap(Box::new(move |event: Event| { + let orientation_state = Rc::clone(&self.orientation_state); + let orientation_listener_box: Box = Box::new(move |event: Event| { let value: JsValue = event.into(); *orientation_state.borrow_mut() = Some(OrientationReadingState { alpha: js_number_field(&value, "alpha"), @@ -196,34 +226,42 @@ impl DeviceSensors { gamma: js_number_field(&value, "gamma"), absolute: js_bool_field(&value, "absolute"), }); - }) as Box); + }); + let orientation_listener = Closure::wrap(orientation_listener_box); - let motion_state = self.motion_state.clone(); - let motion_listener = Closure::wrap(Box::new(move |event: Event| { + let motion_state = Rc::clone(&self.motion_state); + let motion_listener_box: Box = Box::new(move |event: Event| { let value: JsValue = event.into(); let acceleration = js_nested_object(&value, "acceleration"); let acceleration_including_gravity = js_nested_object(&value, "accelerationIncludingGravity"); let rotation_rate = js_nested_object(&value, "rotationRate"); *motion_state.borrow_mut() = Some(MotionReadingState { - acceleration_x: acceleration.as_ref().and_then(|v| js_number_field(v, "x")), - acceleration_y: acceleration.as_ref().and_then(|v| js_number_field(v, "y")), - acceleration_z: acceleration.as_ref().and_then(|v| js_number_field(v, "z")), + acceleration_x: acceleration.as_ref().and_then(|reading| js_number_field(reading, "x")), + acceleration_y: acceleration.as_ref().and_then(|reading| js_number_field(reading, "y")), + acceleration_z: acceleration.as_ref().and_then(|reading| js_number_field(reading, "z")), acceleration_including_gravity_x: acceleration_including_gravity .as_ref() - .and_then(|v| js_number_field(v, "x")), + .and_then(|reading| js_number_field(reading, "x")), acceleration_including_gravity_y: acceleration_including_gravity .as_ref() - .and_then(|v| js_number_field(v, "y")), + .and_then(|reading| js_number_field(reading, "y")), acceleration_including_gravity_z: acceleration_including_gravity .as_ref() - .and_then(|v| js_number_field(v, "z")), - rotation_rate_alpha: rotation_rate.as_ref().and_then(|v| js_number_field(v, "alpha")), - rotation_rate_beta: rotation_rate.as_ref().and_then(|v| js_number_field(v, "beta")), - rotation_rate_gamma: rotation_rate.as_ref().and_then(|v| js_number_field(v, "gamma")), + .and_then(|reading| js_number_field(reading, "z")), + rotation_rate_alpha: rotation_rate + .as_ref() + .and_then(|reading| js_number_field(reading, "alpha")), + rotation_rate_beta: rotation_rate + .as_ref() + .and_then(|reading| js_number_field(reading, "beta")), + rotation_rate_gamma: rotation_rate + .as_ref() + .and_then(|reading| js_number_field(reading, "gamma")), interval_ms: js_number_field(&value, "interval"), }); - }) as Box); + }); + let motion_listener = Closure::wrap(motion_listener_box); let target: &web_sys::EventTarget = window.as_ref(); target.add_event_listener_with_callback("deviceorientation", orientation_listener.as_ref().unchecked_ref())?; @@ -259,16 +297,20 @@ impl DeviceSensors { Ok(()) } + #[must_use] #[wasm_bindgen(js_name = isActive)] + #[expect(clippy::missing_const_for_fn, reason = "wasm_bindgen rejects const fns")] pub fn is_active(&self) -> bool { self.active } + #[must_use] #[wasm_bindgen(js_name = hasOrientation)] pub fn has_orientation(&self) -> bool { self.orientation_state.borrow().is_some() } + #[must_use] #[wasm_bindgen(js_name = hasMotion)] pub fn has_motion(&self) -> bool { self.motion_state.borrow().is_some() @@ -295,28 +337,28 @@ impl DeviceSensors { #[wasm_bindgen(start)] pub fn init() { - let _ = tracing_wasm::try_set_as_global_default(); + drop(tracing_wasm::try_set_as_global_default()); info!("har1 workflow module initialized"); } #[wasm_bindgen] pub async fn run() -> Result<(), JsValue> { set_har_status("har1: entered run()")?; - log("entered run()")?; - log("using existing tracing setup")?; + log("entered run()"); + log("using existing tracing setup"); let outcome = async { let ws_url = websocket_url()?; set_har_status(&format!("har1: resolved websocket URL\n{ws_url}"))?; - log(&format!("resolved websocket URL: {ws_url}"))?; + log(&format!("resolved websocket URL: {ws_url}")); let mut client = WsClient::new(WsClientConfig::new(ws_url)); - log("connecting websocket client")?; + log("connecting websocket client"); client.connect()?; wait_for_connected(&client).await?; - log(&format!("websocket connected with agent_id={}", client.get_agent_id()))?; + log(&format!("websocket connected with agent_id={}", client.get_agent_id())); let mut sensors = DeviceSensors::new(); - log("starting har1 workflow")?; + log("starting har1 workflow"); let result = run_inner(&client, &mut sensors).await; let stop_result = sensors.stop(); @@ -324,28 +366,30 @@ pub async fn run() -> Result<(), JsValue> { match (result, stop_result) { (Ok(()), Ok(())) => { - log("har1 workflow finished")?; + log("har1 workflow finished"); Ok(()) } - (Err(error), Ok(())) => Err(error), - (Ok(()), Err(error)) => Err(error), - (Err(error), Err(_)) => Err(error), + (Err(error), Ok(()) | Err(_)) | (Ok(()), Err(error)) => Err(error), } } .await; if let Err(error) = &outcome { let message = describe_js_error(error); - let _ = set_har_status(&format!("har1: error\n{message}")); - let _ = log(&format!("error: {message}")); + drop(set_har_status(&format!("har1: error\n{message}"))); + log(&format!("error: {message}")); } outcome } +#[expect( + clippy::too_many_lines, + reason = "single-method wiring of model load, sensor start, sample buffering, inference loop with class tracking" +)] async fn run_inner(client: &WsClient, sensors: &mut DeviceSensors) -> Result<(), JsValue> { set_har_status("har1: loading HAR model")?; - log(&format!("loading HAR model from {HAR_MODEL_PATH}"))?; + log(&format!("loading HAR model from {HAR_MODEL_PATH}")); let session = create_har_session(HAR_MODEL_PATH).await?; let feat_input_name = nth_string_entry(&session, "inputNames", 0)?; let raw_input_name = nth_string_entry(&session, "inputNames", 1)?; @@ -356,15 +400,15 @@ async fn run_inner(client: &WsClient, sensors: &mut DeviceSensors) -> Result<(), ))?; log(&format!( "HAR model loaded: feat_input={feat_input_name} raw_input={raw_input_name} output={output_name}" - ))?; + )); - log("requesting sensor access")?; + log("requesting sensor access"); sensors.start().await?; - log("sensors started")?; + log("sensors started"); render_sensor_output(sensors)?; - log("waiting for first motion sample")?; + log("waiting for first motion sample"); wait_for_motion_sample(sensors).await?; - log("first motion sample received")?; + log("first motion sample received"); let mut gravity_estimate = [0.0_f64; 3]; let mut sample_buffer: VecDeque<[f32; HAR_FEATURE_COUNT]> = VecDeque::with_capacity(HAR_SEQUENCE_LENGTH); @@ -395,7 +439,7 @@ async fn run_inner(client: &WsClient, sensors: &mut DeviceSensors) -> Result<(), "buffering HAR samples: {}/{}", sample_buffer.len(), HAR_SEQUENCE_LENGTH - ))?; + )); } if sample_buffer.len() < HAR_SEQUENCE_LENGTH { @@ -404,7 +448,7 @@ async fn run_inner(client: &WsClient, sensors: &mut DeviceSensors) -> Result<(), if sample_buffer.len() == HAR_SEQUENCE_LENGTH { set_har_status("har1: HAR sample window full; inference loop active")?; - log("HAR sample window full; starting inference loop")?; + log("HAR sample window full; starting inference loop"); } let now = js_sys::Date::now(); @@ -446,7 +490,7 @@ async fn run_inner(client: &WsClient, sensors: &mut DeviceSensors) -> Result<(), class_change_count, last_class_label.as_deref().unwrap_or("none"), prediction.best_label - ))?; + )); set_har_status(&format!( "har1: inference running\nlatest class: {}\nclass changes: {}/3\nbuffered samples: {}", prediction.best_label, @@ -528,7 +572,7 @@ struct Prediction { logits: Vec, } -fn log(message: &str) -> Result<(), JsValue> { +fn log(message: &str) { let line = format!("[har1] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); @@ -544,8 +588,6 @@ fn log(message: &str) -> Result<(), JsValue> { }; log_el.set_text_content(Some(&next)); } - - Ok(()) } fn set_har_status(message: &str) -> Result<(), JsValue> { @@ -572,7 +614,7 @@ fn method(target: &JsValue, name: &str) -> Result { } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -583,7 +625,7 @@ async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { } async fn wait_for_motion_sample(sensors: &DeviceSensors) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if sensors.has_motion() { return Ok(()); } @@ -758,17 +800,17 @@ fn create_feat_tensor(values: &[f32]) -> Result { /// 8 channels × 4 stats (mean, std, min, max) = 32, plus 4 stats on the /// per-sample vector magnitude (mean, std, min, max) = 36 total. fn compute_feat_input(sample_buffer: &VecDeque<[f32; HAR_FEATURE_COUNT]>) -> [f32; HAR_FEAT_INPUT_SIZE] { - let n = sample_buffer.len() as f32; + let sample_count = sample_buffer.len() as f32; let mut out = [0.0f32; HAR_FEAT_INPUT_SIZE]; // Per-channel stats (32 values) - for ch in 0..HAR_FEATURE_COUNT { - let vals: Vec = sample_buffer.iter().map(|s| s[ch]).collect(); - let mean = vals.iter().sum::() / n; - let std = (vals.iter().map(|v| (v - mean).powi(2)).sum::() / n).sqrt(); - let min = vals.iter().cloned().fold(f32::INFINITY, f32::min); - let max = vals.iter().cloned().fold(f32::NEG_INFINITY, f32::max); - let base = ch * 4; + for channel in 0..HAR_FEATURE_COUNT { + let vals: Vec = sample_buffer.iter().map(|sample| sample[channel]).collect(); + let mean = vals.iter().sum::() / sample_count; + let std = (vals.iter().map(|val| (val - mean).powi(2)).sum::() / sample_count).sqrt(); + let min = vals.iter().copied().fold(f32::INFINITY, f32::min); + let max = vals.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let base = channel * 4; out[base] = mean; out[base + 1] = std; out[base + 2] = min; @@ -778,12 +820,12 @@ fn compute_feat_input(sample_buffer: &VecDeque<[f32; HAR_FEATURE_COUNT]>) -> [f3 // Magnitude stats (4 values, indices 32–35) let mags: Vec = sample_buffer .iter() - .map(|s| s.iter().map(|v| v * v).sum::().sqrt()) + .map(|sample| sample.iter().map(|val| val * val).sum::().sqrt()) .collect(); - let mean = mags.iter().sum::() / n; - let std = (mags.iter().map(|v| (v - mean).powi(2)).sum::() / n).sqrt(); - let min = mags.iter().cloned().fold(f32::INFINITY, f32::min); - let max = mags.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + let mean = mags.iter().sum::() / sample_count; + let std = (mags.iter().map(|val| (val - mean).powi(2)).sum::() / sample_count).sqrt(); + let min = mags.iter().copied().fold(f32::INFINITY, f32::min); + let max = mags.iter().copied().fold(f32::NEG_INFINITY, f32::max); out[32] = mean; out[33] = std; out[34] = min; @@ -837,13 +879,13 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - let _ = reject.call1(&JsValue::NULL, &error); + drop(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/har1/src/test_har1.rs b/services/ws-modules/har1/src/test_har1.rs index 630adae..dc4e7b7 100644 --- a/services/ws-modules/har1/src/test_har1.rs +++ b/services/ws-modules/har1/src/test_har1.rs @@ -1,3 +1,11 @@ +#![cfg(test)] +#![expect( + clippy::float_cmp, + clippy::indexing_slicing, + clippy::default_numeric_fallback, + reason = "test code: exact float comparisons, slice indexing, and inline f64 sensor fixtures are intentional" +)] + use super::*; #[test] diff --git a/services/ws-modules/nfc/Cargo.toml b/services/ws-modules/nfc/Cargo.toml index 30f14f1..a0cd7b6 100644 --- a/services/ws-modules/nfc/Cargo.toml +++ b/services/ws-modules/nfc/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -24,3 +25,6 @@ web-sys = { version = "0.3", features = ["Window", "console"] } [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/nfc/src/lib.rs b/services/ws-modules/nfc/src/lib.rs index 1cc86a6..385bfe1 100644 --- a/services/ws-modules/nfc/src/lib.rs +++ b/services/ws-modules/nfc/src/lib.rs @@ -1,4 +1,10 @@ -use et_web::{JsFunctionExt, JsPromiseExt}; +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" +)] + +use et_web::{JsFunctionExt as _, JsPromiseExt as _}; use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; use js_sys::{Promise, Reflect}; use serde_json::json; @@ -17,12 +23,12 @@ pub struct NfcScanResult { #[wasm_bindgen] impl NfcScanResult { #[wasm_bindgen(js_name = scanOnce)] - pub async fn scan_once() -> Result { + pub async fn scan_once() -> Result { Self::scan_once_with_timeout(NFC_SCAN_TIMEOUT_MS).await } #[wasm_bindgen(js_name = scanOnceWithTimeout)] - pub async fn scan_once_with_timeout(timeout_ms: i32) -> Result { + pub async fn scan_once_with_timeout(timeout_ms: i32) -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let ndef_ctor = js_sys::Reflect::get(&window, &JsValue::from_str("NDEFReader")) .ok() @@ -34,27 +40,34 @@ impl NfcScanResult { let scan = js_sys::Reflect::get(&reader, &JsValue::from_str("scan"))?.into_function("NDEFReader.scan")?; let scan_promise = scan.call0(&reader)?.into_promise("NDEFReader.scan")?; - let _ = JsFuture::from(scan_promise).await?; + let _scan_result: JsValue = JsFuture::from(scan_promise).await?; let promise = js_sys::Promise::new(&mut |resolve, reject| { let reject_for_timeout = reject.clone(); - let timeout_closure = Closure::once(Box::new(move || { - let _ = reject_for_timeout.call1( + #[expect( + clippy::integer_division, + clippy::integer_division_remainder_used, + reason = "convert ms timeout to seconds for human-readable error message; not a crypto context" + )] + let timeout_seconds = timeout_ms / 1000_i32; + let timeout_box: Box = Box::new(move || { + drop(reject_for_timeout.call1( &JsValue::NULL, - &JsValue::from_str(&format!("NFC scan timed out after {} seconds", timeout_ms / 1000)), - ); - }) as Box); + &JsValue::from_str(&format!("NFC scan timed out after {timeout_seconds} seconds")), + )); + }); + let timeout_closure = Closure::once(timeout_box); if let Some(window) = web_sys::window() { - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( + drop(window.set_timeout_with_callback_and_timeout_and_arguments_0( timeout_closure.as_ref().unchecked_ref(), timeout_ms, - ); + )); } - let reject_for_error = reject.clone(); + let reject_for_error = reject; - let on_reading = Closure::once(Box::new(move |event: JsValue| { + let on_reading_box: Box = Box::new(move |event: JsValue| { let serial_number = js_sys::Reflect::get(&event, &JsValue::from_str("serialNumber")) .ok() .and_then(|value| value.as_string()) @@ -62,37 +75,43 @@ impl NfcScanResult { let record_summary = summarize_ndef_records(&event); let payload = js_sys::Object::new(); - let _ = js_sys::Reflect::set( + let _: bool = js_sys::Reflect::set( &payload, &JsValue::from_str("serialNumber"), &JsValue::from_str(&serial_number), - ); - let _ = js_sys::Reflect::set( + ) + .unwrap_or(false); + let _: bool = js_sys::Reflect::set( &payload, &JsValue::from_str("recordSummary"), &JsValue::from_str(&record_summary), - ); - let _ = resolve.call1(&JsValue::NULL, &payload); - }) as Box); + ) + .unwrap_or(false); + drop(resolve.call1(&JsValue::NULL, &payload)); + }); + let on_reading = Closure::once(on_reading_box); - let on_reading_error = Closure::once(Box::new(move |event: JsValue| { + let on_reading_error_box: Box = Box::new(move |event: JsValue| { let message = js_sys::Reflect::get(&event, &JsValue::from_str("message")) .ok() .and_then(|value| value.as_string()) .unwrap_or_else(|| "NFC reading failed".to_string()); - let _ = reject_for_error.call1(&JsValue::NULL, &JsValue::from_str(&message)); - }) as Box); + drop(reject_for_error.call1(&JsValue::NULL, &JsValue::from_str(&message))); + }); + let on_reading_error = Closure::once(on_reading_error_box); - let _ = js_sys::Reflect::set( + let _: bool = js_sys::Reflect::set( &reader, &JsValue::from_str("onreading"), on_reading.as_ref().unchecked_ref(), - ); - let _ = js_sys::Reflect::set( + ) + .unwrap_or(false); + let _: bool = js_sys::Reflect::set( &reader, &JsValue::from_str("onreadingerror"), on_reading_error.as_ref().unchecked_ref(), - ); + ) + .unwrap_or(false); on_reading.forget(); on_reading_error.forget(); @@ -112,45 +131,50 @@ impl NfcScanResult { serial_number, record_summary ); - Ok(NfcScanResult { + Ok(Self { serial_number, record_summary, }) } + #[must_use] #[wasm_bindgen(js_name = serialNumber)] pub fn serial_number(&self) -> String { self.serial_number.clone() } + #[must_use] #[wasm_bindgen(js_name = recordSummary)] pub fn record_summary(&self) -> String { self.record_summary.clone() } } +#[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "JS number→u32 array length conversion via f64; no native u32 type" +)] fn summarize_ndef_records(event: &JsValue) -> String { - let message = match js_sys::Reflect::get(event, &JsValue::from_str("message")) { - Ok(message) => message, - Err(_) => return "no message".to_string(), + let Ok(message) = js_sys::Reflect::get(event, &JsValue::from_str("message")) else { + return "no message".to_string(); }; - let records = match js_sys::Reflect::get(&message, &JsValue::from_str("records")) { - Ok(records) => records, - Err(_) => return "no records".to_string(), + let Ok(records) = js_sys::Reflect::get(&message, &JsValue::from_str("records")) else { + return "no records".to_string(); }; - let length = match js_sys::Reflect::get(&records, &JsValue::from_str("length")) + let Some(length) = js_sys::Reflect::get(&records, &JsValue::from_str("length")) .ok() .and_then(|value| value.as_f64()) - { - Some(length) => length as u32, - None => return "no records".to_string(), + .map(|len| len as u32) + else { + return "no records".to_string(); }; let mut summary = Vec::new(); for index in 0..length { - let record = match js_sys::Reflect::get(&records, &JsValue::from_f64(index as f64)) { - Ok(record) => record, - Err(_) => continue, + let Ok(record) = js_sys::Reflect::get(&records, &JsValue::from_f64(f64::from(index))) else { + continue; }; let record_type = js_sys::Reflect::get(&record, &JsValue::from_str("recordType")) .ok() @@ -184,11 +208,16 @@ fn summarize_ndef_records(event: &JsValue) -> String { #[wasm_bindgen(start)] pub fn init() { - let _ = tracing_wasm::try_set_as_global_default(); + drop(tracing_wasm::try_set_as_global_default()); info!("nfc module initialized"); } +#[must_use] #[wasm_bindgen] +#[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; cannot be marked const" +)] pub fn is_running() -> bool { false } @@ -196,22 +225,22 @@ pub fn is_running() -> bool { #[wasm_bindgen] pub async fn run() -> Result<(), JsValue> { set_module_status("nfc: Starting NFC scan...\nPlease tap an NFC tag within 60 seconds.")?; - log("entered run()")?; + log("entered run()"); let outcome = async { let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url)); client.connect()?; wait_for_connected(&client).await?; - log(&format!("websocket connected with agent_id={}", client.get_agent_id()))?; + log(&format!("websocket connected with agent_id={}", client.get_agent_id())); - log("waiting for NFC tap (60 second timeout)...")?; + log("waiting for NFC tap (60 second timeout)..."); set_module_status("nfc: Waiting for NFC tap...\nPlease hold your device near an NFC tag.")?; let result = NfcScanResult::scan_once().await?; let serial = result.serial_number(); let summary = result.record_summary(); - log(&format!("NFC scan captured: serial={} summary={}", serial, summary))?; + log(&format!("NFC scan captured: serial={serial} summary={summary}")); client.send_client_event( "nfc", @@ -222,7 +251,7 @@ pub async fn run() -> Result<(), JsValue> { }), )?; - set_module_status(&format!("nfc: Scan captured\nSerial: {}\nSummary: {}", serial, summary))?; + set_module_status(&format!("nfc: Scan captured\nSerial: {serial}\nSummary: {summary}"))?; client.disconnect(); Ok(()) @@ -232,25 +261,22 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); let error_display = if message.contains("not available") || message.contains("not supported") { - format!( - "nfc: Not available\nWeb NFC requires: Chrome/Edge on Android, HTTPS connection\nError: {}", - message - ) + format!("nfc: Not available\nWeb NFC requires: Chrome/Edge on Android, HTTPS connection\nError: {message}") } else if message.contains("timed out") || message.contains("timeout") { "nfc: Timeout\n\nNo NFC tag was detected within 60 seconds.\nPlease try again and tap an NFC tag." .to_string() } else { - format!("nfc: Error\n\n{}", message) + format!("nfc: Error\n\n{message}") }; - let _ = set_module_status(&error_display); - let _ = log(&format!("error: {}", message)); + drop(set_module_status(&error_display)); + log(&format!("error: {message}")); } outcome } -fn log(message: &str) -> Result<(), JsValue> { - let line = format!("[nfc] {}", message); +fn log(message: &str) { + let line = format!("[nfc] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); if let Some(window) = web_sys::window() @@ -261,12 +287,10 @@ fn log(message: &str) -> Result<(), JsValue> { let next = if current.is_empty() { line } else { - format!("{}\n{}", current, line) + format!("{current}\n{line}") }; log_el.set_text_content(Some(&next)); } - - Ok(()) } fn set_module_status(message: &str) -> Result<(), JsValue> { @@ -274,8 +298,8 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { } fn describe_js_error(error: &JsValue) -> String { - if let Some(s) = error.as_string() { - return s; + if let Some(text) = error.as_string() { + return text; } if let Some(obj) = error.dyn_ref::() @@ -287,12 +311,12 @@ fn describe_js_error(error: &JsValue) -> String { js_sys::JSON::stringify(error) .ok() - .and_then(|s| s.as_string()) + .and_then(|json| json.as_string()) .unwrap_or_else(|| "Unknown error".to_string()) } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -306,13 +330,13 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - let _ = reject.call1(&JsValue::NULL, &error); + drop(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) @@ -328,5 +352,5 @@ fn websocket_url() -> Result { .as_string() .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{}//{}/ws", ws_protocol, host)) + Ok(format!("{ws_protocol}//{host}/ws")) } diff --git a/services/ws-modules/sensor1/Cargo.toml b/services/ws-modules/sensor1/Cargo.toml index a1c8d40..0e663c3 100644 --- a/services/ws-modules/sensor1/Cargo.toml +++ b/services/ws-modules/sensor1/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -24,3 +25,6 @@ web-sys = { version = "0.3", features = ["Event", "EventTarget", "Navigator", "W [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/sensor1/src/lib.rs b/services/ws-modules/sensor1/src/lib.rs index 0d872f3..a723f03 100644 --- a/services/ws-modules/sensor1/src/lib.rs +++ b/services/ws-modules/sensor1/src/lib.rs @@ -1,3 +1,8 @@ +#![expect( + clippy::future_not_send, + reason = "browser WASM module: JsFuture is Rc-backed and !Send; runs on the wasm single-threaded event loop" +)] + use std::cell::RefCell; use std::rc::Rc; @@ -38,18 +43,22 @@ pub struct OrientationReading { #[wasm_bindgen] impl OrientationReading { + #[must_use] pub fn alpha(&self) -> f64 { self.inner.alpha.unwrap_or(0.0) } + #[must_use] pub fn beta(&self) -> f64 { self.inner.beta.unwrap_or(0.0) } + #[must_use] pub fn gamma(&self) -> f64 { self.inner.gamma.unwrap_or(0.0) } + #[must_use] pub fn absolute(&self) -> bool { self.inner.absolute.unwrap_or(false) } @@ -62,51 +71,61 @@ pub struct MotionReading { #[wasm_bindgen] impl MotionReading { + #[must_use] #[wasm_bindgen(js_name = accelerationX)] pub fn acceleration_x(&self) -> f64 { self.inner.acceleration_x.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationY)] pub fn acceleration_y(&self) -> f64 { self.inner.acceleration_y.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationZ)] pub fn acceleration_z(&self) -> f64 { self.inner.acceleration_z.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationIncludingGravityX)] pub fn acceleration_including_gravity_x(&self) -> f64 { self.inner.acceleration_including_gravity_x.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationIncludingGravityY)] pub fn acceleration_including_gravity_y(&self) -> f64 { self.inner.acceleration_including_gravity_y.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = accelerationIncludingGravityZ)] pub fn acceleration_including_gravity_z(&self) -> f64 { self.inner.acceleration_including_gravity_z.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = rotationRateAlpha)] pub fn rotation_rate_alpha(&self) -> f64 { self.inner.rotation_rate_alpha.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = rotationRateBeta)] pub fn rotation_rate_beta(&self) -> f64 { self.inner.rotation_rate_beta.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = rotationRateGamma)] pub fn rotation_rate_gamma(&self) -> f64 { self.inner.rotation_rate_gamma.unwrap_or(0.0) } + #[must_use] #[wasm_bindgen(js_name = intervalMs)] pub fn interval_ms(&self) -> f64 { self.inner.interval_ms.unwrap_or(0.0) @@ -130,9 +149,10 @@ impl Default for DeviceSensors { #[wasm_bindgen] impl DeviceSensors { + #[must_use] #[wasm_bindgen(constructor)] - pub fn new() -> DeviceSensors { - DeviceSensors { + pub fn new() -> Self { + Self { active: false, orientation_state: Rc::new(RefCell::new(None)), motion_state: Rc::new(RefCell::new(None)), @@ -173,8 +193,8 @@ impl DeviceSensors { *self.orientation_state.borrow_mut() = None; *self.motion_state.borrow_mut() = None; - let orientation_state = self.orientation_state.clone(); - let orientation_listener = Closure::wrap(Box::new(move |event: Event| { + let orientation_state = Rc::clone(&self.orientation_state); + let orientation_boxed: Box = Box::new(move |event: Event| { let value: JsValue = event.into(); *orientation_state.borrow_mut() = Some(OrientationReadingState { alpha: js_number_field(&value, "alpha"), @@ -182,34 +202,42 @@ impl DeviceSensors { gamma: js_number_field(&value, "gamma"), absolute: js_bool_field(&value, "absolute"), }); - }) as Box); + }); + let orientation_listener = Closure::wrap(orientation_boxed); - let motion_state = self.motion_state.clone(); - let motion_listener = Closure::wrap(Box::new(move |event: Event| { + let motion_state = Rc::clone(&self.motion_state); + let motion_boxed: Box = Box::new(move |event: Event| { let value: JsValue = event.into(); let acceleration = js_nested_object(&value, "acceleration"); let acceleration_including_gravity = js_nested_object(&value, "accelerationIncludingGravity"); let rotation_rate = js_nested_object(&value, "rotationRate"); *motion_state.borrow_mut() = Some(MotionReadingState { - acceleration_x: acceleration.as_ref().and_then(|v| js_number_field(v, "x")), - acceleration_y: acceleration.as_ref().and_then(|v| js_number_field(v, "y")), - acceleration_z: acceleration.as_ref().and_then(|v| js_number_field(v, "z")), + acceleration_x: acceleration.as_ref().and_then(|reading| js_number_field(reading, "x")), + acceleration_y: acceleration.as_ref().and_then(|reading| js_number_field(reading, "y")), + acceleration_z: acceleration.as_ref().and_then(|reading| js_number_field(reading, "z")), acceleration_including_gravity_x: acceleration_including_gravity .as_ref() - .and_then(|v| js_number_field(v, "x")), + .and_then(|reading| js_number_field(reading, "x")), acceleration_including_gravity_y: acceleration_including_gravity .as_ref() - .and_then(|v| js_number_field(v, "y")), + .and_then(|reading| js_number_field(reading, "y")), acceleration_including_gravity_z: acceleration_including_gravity .as_ref() - .and_then(|v| js_number_field(v, "z")), - rotation_rate_alpha: rotation_rate.as_ref().and_then(|v| js_number_field(v, "alpha")), - rotation_rate_beta: rotation_rate.as_ref().and_then(|v| js_number_field(v, "beta")), - rotation_rate_gamma: rotation_rate.as_ref().and_then(|v| js_number_field(v, "gamma")), + .and_then(|reading| js_number_field(reading, "z")), + rotation_rate_alpha: rotation_rate + .as_ref() + .and_then(|reading| js_number_field(reading, "alpha")), + rotation_rate_beta: rotation_rate + .as_ref() + .and_then(|reading| js_number_field(reading, "beta")), + rotation_rate_gamma: rotation_rate + .as_ref() + .and_then(|reading| js_number_field(reading, "gamma")), interval_ms: js_number_field(&value, "interval"), }); - }) as Box); + }); + let motion_listener = Closure::wrap(motion_boxed); let target: &web_sys::EventTarget = window.as_ref(); target.add_event_listener_with_callback("deviceorientation", orientation_listener.as_ref().unchecked_ref())?; @@ -245,16 +273,23 @@ impl DeviceSensors { Ok(()) } + #[must_use] #[wasm_bindgen(js_name = isActive)] + #[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" + )] pub fn is_active(&self) -> bool { self.active } + #[must_use] #[wasm_bindgen(js_name = hasOrientation)] pub fn has_orientation(&self) -> bool { self.orientation_state.borrow().is_some() } + #[must_use] #[wasm_bindgen(js_name = hasMotion)] pub fn has_motion(&self) -> bool { self.motion_state.borrow().is_some() @@ -291,10 +326,11 @@ thread_local! { #[wasm_bindgen(start)] pub fn init() { - let _ = tracing_wasm::try_set_as_global_default(); + drop(tracing_wasm::try_set_as_global_default()); info!("sensor stream workflow module initialized"); } +#[must_use] #[wasm_bindgen] pub fn is_running() -> bool { SENSOR_STREAM_RUNTIME.with(|runtime| runtime.borrow().is_some()) @@ -311,16 +347,17 @@ pub async fn run() -> Result<(), JsValue> { sensors.start().await?; render_sensor_output(&sensors)?; - let render_closure = Closure::wrap(Box::new(move || { + let render_boxed: Box = Box::new(move || { SENSOR_STREAM_RUNTIME.with(|runtime| { let runtime_ref = runtime.borrow(); let Some(runtime) = runtime_ref.as_ref() else { return; }; - let _ = render_sensor_output(&runtime.sensors); + drop(render_sensor_output(&runtime.sensors)); }); - }) as Box); + }); + let render_closure = Closure::wrap(render_boxed); let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let render_interval_id = window.set_interval_with_callback_and_timeout_and_arguments_0( @@ -329,7 +366,7 @@ pub async fn run() -> Result<(), JsValue> { )?; SENSOR_STREAM_RUNTIME.with(|runtime| { - runtime.borrow_mut().replace(SensorStreamRuntime { + let _previous: Option = runtime.borrow_mut().replace(SensorStreamRuntime { sensors, render_interval_id, _render_closure: render_closure, @@ -338,11 +375,14 @@ pub async fn run() -> Result<(), JsValue> { let stop_callback = Closure::once_into_js(move || { if is_running() { - let _ = stop(); - let _ = set_sensor_status("sensor stream: finished automatically after 15 seconds"); + drop(stop()); + drop(set_sensor_status( + "sensor stream: finished automatically after 15 seconds", + )); } }); - window.set_timeout_with_callback_and_timeout_and_arguments_0(stop_callback.unchecked_ref(), 15000)?; + let _id: i32 = + window.set_timeout_with_callback_and_timeout_and_arguments_0(stop_callback.unchecked_ref(), 15000)?; set_sensor_status("sensor stream: running")?; Ok(()) diff --git a/services/ws-modules/speech-recognition/Cargo.toml b/services/ws-modules/speech-recognition/Cargo.toml index b260a85..2076a16 100644 --- a/services/ws-modules/speech-recognition/Cargo.toml +++ b/services/ws-modules/speech-recognition/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -24,3 +25,6 @@ web-sys = { version = "0.3", features = ["Window", "console"] } [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/speech-recognition/src/lib.rs b/services/ws-modules/speech-recognition/src/lib.rs index 7a32927..0b34a11 100644 --- a/services/ws-modules/speech-recognition/src/lib.rs +++ b/services/ws-modules/speech-recognition/src/lib.rs @@ -1,7 +1,18 @@ +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::float_arithmetic, + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; module-local helpers and confidence averaging math are inherent" +)] + use std::cell::{Cell, RefCell}; use std::rc::Rc; -use et_web::JsFunctionExt; +use et_web::JsFunctionExt as _; use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; use js_sys::{Promise, Reflect}; use serde_json::json; @@ -16,17 +27,20 @@ pub struct SpeechRecognitionResult { } #[wasm_bindgen] +#[expect(clippy::missing_const_for_fn, reason = "wasm_bindgen rejects const fns")] impl SpeechRecognitionResult { #[wasm_bindgen(js_name = recognizeOnce)] - pub async fn recognize_once() -> Result { + pub async fn recognize_once() -> Result { let session = SpeechRecognitionSession::new()?; session.start().await } + #[must_use] pub fn transcript(&self) -> String { self.transcript.clone() } + #[must_use] pub fn confidence(&self) -> f64 { self.confidence } @@ -41,7 +55,7 @@ pub struct SpeechRecognitionSession { #[wasm_bindgen] impl SpeechRecognitionSession { #[wasm_bindgen(constructor)] - pub fn new() -> Result { + pub fn new() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let speech_recognition_ctor = js_sys::Reflect::get(&window, &JsValue::from_str("SpeechRecognition")) .ok() @@ -55,60 +69,65 @@ impl SpeechRecognitionSession { let constructor = speech_recognition_ctor.into_function("SpeechRecognition constructor")?; let recognition = js_sys::Reflect::construct(&constructor, &js_sys::Array::new())?; - js_sys::Reflect::set(&recognition, &JsValue::from_str("lang"), &JsValue::from_str("en-US"))?; - js_sys::Reflect::set(&recognition, &JsValue::from_str("interimResults"), &JsValue::TRUE)?; - js_sys::Reflect::set( + let _: bool = js_sys::Reflect::set(&recognition, &JsValue::from_str("lang"), &JsValue::from_str("en-US"))?; + let _: bool = js_sys::Reflect::set(&recognition, &JsValue::from_str("interimResults"), &JsValue::TRUE)?; + let _: bool = js_sys::Reflect::set( &recognition, &JsValue::from_str("maxAlternatives"), &JsValue::from_f64(1.0), )?; - Ok(SpeechRecognitionSession { + Ok(Self { recognition, stop_requested: Rc::new(Cell::new(false)), }) } + #[expect( + clippy::too_many_lines, + reason = "single-method wiring of Web Speech API onresult/onerror/onend handlers and the wrapping promise" + )] pub async fn start(&self) -> Result { self.stop_requested.set(false); let recognition = self.recognition.clone(); - let stop_requested = self.stop_requested.clone(); + let stop_requested = Rc::clone(&self.stop_requested); let promise = js_sys::Promise::new(&mut |resolve, reject| { let settled = Rc::new(Cell::new(false)); let resolve_for_result = resolve.clone(); - let resolve_for_end = resolve.clone(); + let resolve_for_end = resolve; let reject_for_error = reject.clone(); let reject_for_end = reject.clone(); - let settled_for_result = settled.clone(); - let settled_for_error = settled.clone(); - let settled_for_end = settled.clone(); + let settled_for_result = Rc::clone(&settled); + let settled_for_error = Rc::clone(&settled); + let settled_for_end = Rc::clone(&settled); let transcript_state: Rc>> = Rc::new(RefCell::new(None)); - let transcript_state_for_result = transcript_state.clone(); - let transcript_state_for_end = transcript_state.clone(); - let stop_requested_for_end = stop_requested.clone(); + let transcript_state_for_result = Rc::clone(&transcript_state); + let transcript_state_for_end = Rc::clone(&transcript_state); + let stop_requested_for_end = Rc::clone(&stop_requested); - let on_result = Closure::wrap(Box::new(move |event: JsValue| { + let on_result_box: Box = Box::new(move |event: JsValue| { if let Some((transcript, confidence, has_final)) = extract_speech_event_transcript(&event) { *transcript_state_for_result.borrow_mut() = Some((transcript.clone(), confidence)); if has_final && !settled_for_result.replace(true) { let payload = js_sys::Object::new(); - let _ = js_sys::Reflect::set( + drop(js_sys::Reflect::set( &payload, &JsValue::from_str("transcript"), &JsValue::from_str(&transcript), - ); - let _ = js_sys::Reflect::set( + )); + drop(js_sys::Reflect::set( &payload, &JsValue::from_str("confidence"), &JsValue::from_f64(confidence), - ); - let _ = resolve_for_result.call1(&JsValue::NULL, &payload); + )); + drop(resolve_for_result.call1(&JsValue::NULL, &payload)); } } - }) as Box); + }); + let on_result = Closure::wrap(on_result_box); - let on_error = Closure::wrap(Box::new(move |event: JsValue| { + let on_error_box: Box = Box::new(move |event: JsValue| { if settled_for_error.replace(true) { return; } @@ -116,63 +135,65 @@ impl SpeechRecognitionSession { .ok() .and_then(|value| value.as_string()) .unwrap_or_else(|| "speech recognition failed".to_string()); - let _ = reject_for_error.call1(&JsValue::NULL, &JsValue::from_str(&message)); - }) as Box); + drop(reject_for_error.call1(&JsValue::NULL, &JsValue::from_str(&message))); + }); + let on_error = Closure::wrap(on_error_box); - let on_end = Closure::wrap(Box::new(move || { + let on_end_box: Box = Box::new(move || { if settled_for_end.replace(true) { return; } if let Some((transcript, confidence)) = transcript_state_for_end.borrow().clone() { let payload = js_sys::Object::new(); - let _ = js_sys::Reflect::set( + drop(js_sys::Reflect::set( &payload, &JsValue::from_str("transcript"), &JsValue::from_str(&transcript), - ); - let _ = js_sys::Reflect::set( + )); + drop(js_sys::Reflect::set( &payload, &JsValue::from_str("confidence"), &JsValue::from_f64(confidence), - ); - let _ = resolve_for_end.call1(&JsValue::NULL, &payload); + )); + drop(resolve_for_end.call1(&JsValue::NULL, &payload)); } else if stop_requested_for_end.get() { - let _ = reject_for_end.call1( + drop(reject_for_end.call1( &JsValue::NULL, &JsValue::from_str("speech recognition stopped before any transcript was captured"), - ); + )); } else { - let _ = reject_for_end.call1( + drop(reject_for_end.call1( &JsValue::NULL, &JsValue::from_str("speech recognition ended without a transcript"), - ); + )); } - }) as Box); + }); + let on_end = Closure::wrap(on_end_box); - let _ = js_sys::Reflect::set( + drop(js_sys::Reflect::set( &recognition, &JsValue::from_str("onresult"), on_result.as_ref().unchecked_ref(), - ); - let _ = js_sys::Reflect::set( + )); + drop(js_sys::Reflect::set( &recognition, &JsValue::from_str("onerror"), on_error.as_ref().unchecked_ref(), - ); - let _ = js_sys::Reflect::set( + )); + drop(js_sys::Reflect::set( &recognition, &JsValue::from_str("onend"), on_end.as_ref().unchecked_ref(), - ); + )); match js_sys::Reflect::get(&recognition, &JsValue::from_str("start")) .and_then(|value| value.into_function("SpeechRecognition.start")) { Ok(start) => { - let _ = start.call0(&recognition); + drop(start.call0(&recognition)); } Err(err) => { - let _ = reject.call1(&JsValue::NULL, &err); + drop(reject.call1(&JsValue::NULL, &err)); } } @@ -187,7 +208,7 @@ impl SpeechRecognitionSession { .ok_or_else(|| JsValue::from_str("Speech recognition transcript missing"))?; let confidence = js_sys::Reflect::get(&result, &JsValue::from_str("confidence"))? .as_f64() - .unwrap_or(0.0); + .unwrap_or(0.0_f64); info!("Speech recognition captured transcript with confidence={}", confidence); @@ -198,7 +219,7 @@ impl SpeechRecognitionSession { self.stop_requested.set(true); let stop = js_sys::Reflect::get(&self.recognition, &JsValue::from_str("stop"))? .into_function("SpeechRecognition.stop")?; - stop.call0(&self.recognition)?; + let _v: JsValue = stop.call0(&self.recognition)?; Ok(()) } } @@ -210,19 +231,17 @@ fn extract_speech_event_transcript(event: &JsValue) -> Option<(String, f64, bool .as_f64()? as u32; let mut transcript_parts = Vec::new(); - let mut confidence = 0.0; + let mut confidence = 0.0_f64; let mut confidence_count = 0_u32; let mut has_final = false; for index in 0..length { - let result = match js_sys::Reflect::get(&results, &JsValue::from_f64(index as f64)) { - Ok(result) => result, - Err(_) => continue, + let Ok(result) = js_sys::Reflect::get(&results, &JsValue::from_f64(f64::from(index))) else { + continue; }; - let alternative = match js_sys::Reflect::get(&result, &JsValue::from_f64(0.0)) { - Ok(alternative) => alternative, - Err(_) => continue, + let Ok(alternative) = js_sys::Reflect::get(&result, &JsValue::from_f64(0.0)) else { + continue; }; if let Some(part) = js_sys::Reflect::get(&alternative, &JsValue::from_str("transcript")) @@ -258,9 +277,9 @@ fn extract_speech_event_transcript(event: &JsValue) -> Option<(String, f64, bool let transcript = transcript_parts.join(" "); let average_confidence = if confidence_count == 0 { - 0.0 + 0.0_f64 } else { - confidence / confidence_count as f64 + confidence / f64::from(confidence_count) }; Some((transcript, average_confidence, has_final)) @@ -277,10 +296,11 @@ thread_local! { #[wasm_bindgen(start)] pub fn init() { - let _ = tracing_wasm::try_set_as_global_default(); + drop(tracing_wasm::try_set_as_global_default()); info!("speech-recognition module initialized"); } +#[must_use] #[wasm_bindgen] pub fn is_running() -> bool { SPEECH_RECOGNITION_RUNTIME.with(|runtime| runtime.borrow().is_some()) @@ -293,43 +313,43 @@ pub async fn run() -> Result<(), JsValue> { } set_module_status("speech-recognition: entered run()")?; - log("entered run()")?; + log("entered run()"); let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url)); client.connect()?; wait_for_connected(&client).await?; - log(&format!("websocket connected with agent_id={}", client.get_agent_id()))?; + log(&format!("websocket connected with agent_id={}", client.get_agent_id())); - log("starting speech recognition session")?; + log("starting speech recognition session"); let session = Rc::new(SpeechRecognitionSession::new()?); SPEECH_RECOGNITION_RUNTIME.with(|runtime| { - runtime.borrow_mut().replace(SpeechRecognitionRuntime { + let _previous: Option = runtime.borrow_mut().replace(SpeechRecognitionRuntime { client: client.clone(), - session: session.clone(), + session: Rc::clone(&session), }); }); set_module_status("speech-recognition: running")?; let start_time = js_sys::Date::now(); - let mut result_count = 0; + let mut result_count = 0_u32; while is_running() { let elapsed_ms = js_sys::Date::now() - start_time; - if elapsed_ms > 30000.0 { - let _ = log("workflow finished automatically after 30 seconds"); - let _ = stop(); + if elapsed_ms > 30_000_f64 { + log("workflow finished automatically after 30 seconds"); + drop(stop()); break; } if result_count >= 3 { - let _ = log("workflow finished automatically after 3 recognition results"); - let _ = stop(); + log("workflow finished automatically after 3 recognition results"); + drop(stop()); break; } - log("awaiting speech recognition...")?; + log("awaiting speech recognition..."); let result_outcome = session.start().await; if !is_running() { @@ -342,9 +362,8 @@ pub async fn run() -> Result<(), JsValue> { let transcript = result.transcript(); let confidence = result.confidence(); log(&format!( - "speech recognized: \"{}\" (confidence={})", - transcript, confidence - ))?; + "speech recognized: \"{transcript}\" (confidence={confidence})" + )); client.send_client_event( "speech", @@ -356,13 +375,12 @@ pub async fn run() -> Result<(), JsValue> { )?; set_module_status(&format!( - "speech-recognition: result\n\"{}\"\nconfidence: {}", - transcript, confidence + "speech-recognition: result\n\"{transcript}\"\nconfidence: {confidence}" ))?; } Err(error) => { let message = describe_js_error(&error); - log(&format!("recognition error: {}", message))?; + log(&format!("recognition error: {message}")); // Sleep a bit before retrying to avoid tight error loops sleep_ms(1000).await?; } @@ -376,19 +394,18 @@ pub async fn run() -> Result<(), JsValue> { pub fn stop() -> Result<(), JsValue> { SPEECH_RECOGNITION_RUNTIME.with(|runtime| { if let Some(mut runtime) = runtime.borrow_mut().take() { - let _ = runtime.session.stop(); + drop(runtime.session.stop()); runtime.client.disconnect(); - log("speech-recognition stopped")?; + log("speech-recognition stopped"); } - Ok::<(), JsValue>(()) - })?; + }); set_module_status("speech-recognition: stopped")?; Ok(()) } -fn log(message: &str) -> Result<(), JsValue> { - let line = format!("[speech-recognition] {}", message); +fn log(message: &str) { + let line = format!("[speech-recognition] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); if let Some(window) = web_sys::window() @@ -399,12 +416,10 @@ fn log(message: &str) -> Result<(), JsValue> { let next = if current.is_empty() { line } else { - format!("{}\n{}", current, line) + format!("{current}\n{line}") }; log_el.set_text_content(Some(&next)); } - - Ok(()) } fn set_module_status(message: &str) -> Result<(), JsValue> { @@ -415,11 +430,11 @@ fn describe_js_error(error: &JsValue) -> String { error .as_string() .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{:?}", error)) + .unwrap_or_else(|| format!("{error:?}")) } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -433,13 +448,13 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - let _ = reject.call1(&JsValue::NULL, &error); + drop(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) @@ -455,5 +470,5 @@ fn websocket_url() -> Result { .as_string() .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{}//{}/ws", ws_protocol, host)) + Ok(format!("{ws_protocol}//{host}/ws")) } diff --git a/services/ws-modules/video1/Cargo.toml b/services/ws-modules/video1/Cargo.toml index 45b143b..9112d4a 100644 --- a/services/ws-modules/video1/Cargo.toml +++ b/services/ws-modules/video1/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] et-web.workspace = true -et-ws-wasm-agent = { path = "../../ws-wasm-agent" } +et-ws-wasm-agent.workspace = true js-sys = "0.3" serde.workspace = true serde-wasm-bindgen = "0.6" @@ -36,3 +37,6 @@ web-sys = { version = "0.3", features = [ [dev-dependencies] wasm-bindgen-test = "0.3" + +[lints] +workspace = true diff --git a/services/ws-modules/video1/src/lib.rs b/services/ws-modules/video1/src/lib.rs index 32ea27f..4e02103 100644 --- a/services/ws-modules/video1/src/lib.rs +++ b/services/ws-modules/video1/src/lib.rs @@ -1,6 +1,12 @@ +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" +)] + use std::cell::RefCell; -use et_web::{JsCastExt, get_media_devices}; +use et_web::{JsCastExt as _, get_media_devices}; use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; use js_sys::{Promise, Reflect}; use serde_json::json; @@ -17,7 +23,7 @@ pub struct VideoCapture { #[wasm_bindgen] impl VideoCapture { #[wasm_bindgen(js_name = request)] - pub async fn request() -> Result { + pub async fn request() -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let media_devices = get_media_devices(&window.navigator())?; @@ -34,14 +40,16 @@ impl VideoCapture { stream.get_video_tracks().length() ); - Ok(VideoCapture { stream }) + Ok(Self { stream }) } + #[must_use] #[wasm_bindgen(js_name = trackCount)] pub fn track_count(&self) -> u32 { self.stream.get_video_tracks().length() } + #[must_use] #[wasm_bindgen(js_name = rawStream)] pub fn raw_stream(&self) -> JsValue { self.stream.clone().into() @@ -69,10 +77,11 @@ thread_local! { #[wasm_bindgen(start)] pub fn init() { - let _ = tracing_wasm::try_set_as_global_default(); + drop(tracing_wasm::try_set_as_global_default()); info!("video-capture module initialized"); } +#[must_use] #[wasm_bindgen] pub fn is_running() -> bool { VIDEO_CAPTURE_RUNTIME.with(|runtime| runtime.borrow().is_some()) @@ -85,26 +94,26 @@ pub async fn run() -> Result<(), JsValue> { } set_module_status("video-capture: entered run()")?; - log("entered run()")?; + log("entered run()"); let outcome = async { let ws_url = websocket_url()?; let mut client = WsClient::new(WsClientConfig::new(ws_url)); client.connect()?; wait_for_connected(&client).await?; - log(&format!("websocket connected with agent_id={}", client.get_agent_id()))?; + log(&format!("websocket connected with agent_id={}", client.get_agent_id())); - log("requesting video capture access")?; + log("requesting video capture access"); let capture = VideoCapture::request().await?; let tracks = capture.track_count(); - log(&format!("video capture granted: {} tracks", tracks))?; + log(&format!("video capture granted: {tracks} tracks")); // Set up preview if let Some(window) = web_sys::window() && let Some(document) = window.document() && let Some(preview_el) = document.get_element_by_id("video-preview") { - Reflect::set(&preview_el, &JsValue::from_str("srcObject"), &capture.raw_stream())?; + let _: bool = Reflect::set(&preview_el, &JsValue::from_str("srcObject"), &capture.raw_stream())?; if let Some(html_el) = preview_el.dyn_ref::() { html_el.style().set_property("display", "block")?; } @@ -121,17 +130,19 @@ pub async fn run() -> Result<(), JsValue> { set_module_status("video-capture: running")?; VIDEO_CAPTURE_RUNTIME.with(|runtime| { - runtime.borrow_mut().replace(VideoCaptureRuntime { client, capture }); + let _previous: Option = + runtime.borrow_mut().replace(VideoCaptureRuntime { client, capture }); }); let stop_callback = Closure::once_into_js(move || { if is_running() { - let _ = log("workflow finished automatically after 10 seconds"); - let _ = stop(); + log("workflow finished automatically after 10 seconds"); + drop(stop()); } }); let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - window.set_timeout_with_callback_and_timeout_and_arguments_0(stop_callback.unchecked_ref(), 10000)?; + let _id: i32 = + window.set_timeout_with_callback_and_timeout_and_arguments_0(stop_callback.unchecked_ref(), 10000)?; Ok(()) } @@ -139,8 +150,8 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - let _ = set_module_status(&format!("video-capture: error\n{}", message)); - let _ = log(&format!("error: {}", message)); + drop(set_module_status(&format!("video-capture: error\n{message}"))); + log(&format!("error: {message}")); } outcome @@ -158,13 +169,13 @@ pub fn stop() -> Result<(), JsValue> { && let Some(document) = window.document() && let Some(preview_el) = document.get_element_by_id("video-preview") { - Reflect::set(&preview_el, &JsValue::from_str("srcObject"), &JsValue::NULL)?; + let _: bool = Reflect::set(&preview_el, &JsValue::from_str("srcObject"), &JsValue::NULL)?; if let Some(html_el) = preview_el.dyn_ref::() { html_el.style().set_property("display", "none")?; } } - log("video-capture stopped")?; + log("video-capture stopped"); } Ok::<(), JsValue>(()) })?; @@ -173,8 +184,8 @@ pub fn stop() -> Result<(), JsValue> { Ok(()) } -fn log(message: &str) -> Result<(), JsValue> { - let line = format!("[video-capture] {}", message); +fn log(message: &str) { + let line = format!("[video-capture] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); if let Some(window) = web_sys::window() @@ -185,12 +196,10 @@ fn log(message: &str) -> Result<(), JsValue> { let next = if current.is_empty() { line } else { - format!("{}\n{}", current, line) + format!("{current}\n{line}") }; log_el.set_text_content(Some(&next)); } - - Ok(()) } fn set_module_status(message: &str) -> Result<(), JsValue> { @@ -201,11 +210,11 @@ fn describe_js_error(error: &JsValue) -> String { error .as_string() .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{:?}", error)) + .unwrap_or_else(|| format!("{error:?}")) } async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { + for _ in 0_u32..100 { if client.get_state() == "connected" { return Ok(()); } @@ -219,13 +228,13 @@ async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let promise = Promise::new(&mut |resolve, reject| { let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); + drop(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - let _ = reject.call1(&JsValue::NULL, &error); + drop(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) @@ -241,5 +250,5 @@ fn websocket_url() -> Result { .as_string() .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{}//{}/ws", ws_protocol, host)) + Ok(format!("{ws_protocol}//{host}/ws")) } diff --git a/services/ws-modules/wasi-comm1/Cargo.toml b/services/ws-modules/wasi-comm1/Cargo.toml index 96c6b28..70fbe32 100644 --- a/services/ws-modules/wasi-comm1/Cargo.toml +++ b/services/ws-modules/wasi-comm1/Cargo.toml @@ -8,9 +8,13 @@ repository.workspace = true [lib] crate-type = ["cdylib"] +doctest = false # See wasi-data1/Cargo.toml for the rationale behind the wasi-only target # scope; the lib body is gated identically. [target.'cfg(target_os = "wasi")'.dependencies] serde_json = "1" wit-bindgen = "0.57" + +[lints] +workspace = true diff --git a/services/ws-modules/wasi-comm1/src/lib.rs b/services/ws-modules/wasi-comm1/src/lib.rs index 4f751ea..ab431da 100644 --- a/services/ws-modules/wasi-comm1/src/lib.rs +++ b/services/ws-modules/wasi-comm1/src/lib.rs @@ -4,15 +4,15 @@ //! be connected, then exchanges broadcast and direct messages with it. The //! integration test only spins up a single runner, so the WASI port instead //! exercises the message round-trip with the server itself: -//! 1. Connect and capture our agent_id. +//! 1. Connect and capture our `agent_id`. //! 2. Send `list_agents`, recv a `list_agents_response`, assert the list -//! contains our agent_id (we're at least in our own roster). +//! contains our `agent_id` (we're at least in our own roster). //! 3. Send a `broadcast_message` (fire-and-forget when no peer is online). //! 4. Disconnect cleanly. //! //! Wire-format messages are built with `serde_json::json!` and serialised //! before going through `ws.send-text`; recv'd frames are parsed with -//! `serde_json::Value`. This mirrors the WsMessage enum in +//! `serde_json::Value`. This mirrors the `WsMessage` enum in //! `libs/edge-toolkit/src/ws.rs` but avoids depending on that crate (its //! transitive deps don't all compile to wasm32-wasip2). @@ -22,6 +22,12 @@ // from the repo root produces an empty cdylib for the host target without // linker errors. #![cfg(target_os = "wasi")] +// wit_bindgen::generate! emits `unsafe fn` and `#[export_name]` items; +// `export!(Component)` does the same. Both trip workspace +// `unsafe_code = "deny"` lint; expect it at crate scope because outer +// `#[expect]` on the macro invocations themselves doesn't propagate to +// the items they expand into. +#![expect(unsafe_code)] wit_bindgen::generate!({ path: "../../ws-wasi-runner/wit", @@ -142,7 +148,7 @@ fn wait_for_agent_id() -> Option { fn sleep_ms(ms: u64) { let pollable = wasi::clocks::monotonic_clock::subscribe_duration(ms * 1_000_000); - wasi::io::poll::poll(&[&pollable]); + drop(wasi::io::poll::poll(&[&pollable])); } export!(Component); diff --git a/services/ws-modules/wasi-data1/Cargo.toml b/services/ws-modules/wasi-data1/Cargo.toml index 877ba30..c66a73b 100644 --- a/services/ws-modules/wasi-data1/Cargo.toml +++ b/services/ws-modules/wasi-data1/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [lib] crate-type = ["cdylib"] +doctest = false # Scope the wit-bindgen + serde_json deps to wasi targets so a host # `cargo check --workspace` doesn't pull them in. The lib body is gated @@ -16,3 +17,6 @@ crate-type = ["cdylib"] [target.'cfg(target_os = "wasi")'.dependencies] serde_json = "1" wit-bindgen = "0.57" + +[lints] +workspace = true diff --git a/services/ws-modules/wasi-data1/src/lib.rs b/services/ws-modules/wasi-data1/src/lib.rs index 7432af1..badbe5d 100644 --- a/services/ws-modules/wasi-data1/src/lib.rs +++ b/services/ws-modules/wasi-data1/src/lib.rs @@ -20,6 +20,12 @@ //! empty cdylib for the host target without linker errors. #![cfg(target_os = "wasi")] +// wit_bindgen::generate! emits `unsafe fn` and `#[export_name]` items; +// `export!(Component)` does the same. Both trip workspace +// `unsafe_code = "deny"` lint; expect it at crate scope because outer +// `#[expect]` on the macro invocations themselves doesn't propagate to +// the items they expand into. +#![expect(unsafe_code)] wit_bindgen::generate!({ path: "../../ws-wasi-runner/wit", @@ -106,7 +112,7 @@ fn wait_for_agent_id() -> Option { fn sleep_ms(ms: u64) { let pollable = wasi::clocks::monotonic_clock::subscribe_duration(ms * 1_000_000); - wasi::io::poll::poll(&[&pollable]); + drop(wasi::io::poll::poll(&[&pollable])); } export!(Component); diff --git a/services/ws-server/Cargo.toml b/services/ws-server/Cargo.toml index 801369d..bcf598e 100644 --- a/services/ws-server/Cargo.toml +++ b/services/ws-server/Cargo.toml @@ -5,16 +5,24 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +doctest = false + +[[bin]] +doctest = false +name = "et-ws-server" +path = "src/main.rs" + [dependencies] actix-rt.workspace = true actix-web.workspace = true chrono.workspace = true clap.workspace = true edge-toolkit.workspace = true -et-modules-service = { path = "../modules" } +et-modules-service.workspace = true et-otlp.workspace = true -et-storage-service = { path = "../storage" } -et-ws-service = { path = "../ws" } +et-storage-service.workspace = true +et-ws-service.workspace = true futures-util = "0.3" local-ip-address = "0.6" log.workspace = true @@ -34,3 +42,6 @@ tracing.workspace = true tracing-actix-web.workspace = true tracing-subscriber.workspace = true uuid.workspace = true + +[lints] +workspace = true diff --git a/services/ws-server/src/config.rs b/services/ws-server/src/config.rs index 804174d..7850e38 100644 --- a/services/ws-server/src/config.rs +++ b/services/ws-server/src/config.rs @@ -10,6 +10,7 @@ use serde_inline_default::serde_inline_default; /// TLS certificate and key paths. #[serde_inline_default] #[derive(Clone, Debug, DefaultFromSerde, Deserialize)] +#[non_exhaustive] pub struct TlsConfig { #[serde_inline_default(PathBuf::from("cert.pem"))] pub cert_file: PathBuf, @@ -19,6 +20,7 @@ pub struct TlsConfig { /// Application config shared across ws-server services. #[derive(Clone, Debug, DefaultFromSerde, Deserialize)] +#[non_exhaustive] pub struct Config { /// OpenTelemetry config. #[serde(default)] diff --git a/services/ws-server/src/lib.rs b/services/ws-server/src/lib.rs index 2e8b9e4..b4f43c1 100644 --- a/services/ws-server/src/lib.rs +++ b/services/ws-server/src/lib.rs @@ -17,7 +17,8 @@ pub async fn health() -> HttpResponse { } pub fn configure_app(cfg: &mut web::ServiceConfig, agent_registry: web::Data, config: &Config) { - cfg.app_data(agent_registry) + let _configured = cfg + .app_data(agent_registry) .app_data(web::Data::new(config.clone())) .app_data(web::Data::new(config.modules.clone())) .app_data(web::Data::new(config.storage.clone())) diff --git a/services/ws-server/src/main.rs b/services/ws-server/src/main.rs index 91e61e7..b8702cf 100644 --- a/services/ws-server/src/main.rs +++ b/services/ws-server/src/main.rs @@ -1,3 +1,10 @@ +#![expect( + clippy::print_stderr, + clippy::unwrap_used, + clippy::use_debug, + reason = "server entry point: bootstrap crashes are intentional; eprintln! + Debug env dump precede tracing setup" +)] + use std::path::PathBuf; use actix_web::middleware::{DefaultHeaders, Logger}; @@ -9,14 +16,14 @@ use et_ws_server::configure_app; use et_ws_service::load_registry; use tracing::{error, info}; use tracing_actix_web::TracingLogger; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _}; mod tls; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { - /// Path to agent registry YAML file + /// Path to agent registry YAML file. #[arg(short, long, default_value = "registry.yaml")] agent_registry: PathBuf, } @@ -29,6 +36,10 @@ async fn main() -> Result<(), std::io::Error> { eprintln!("Starting with env vars {env:#?}"); + #[expect( + clippy::option_if_let_else, + reason = "both branches log and configure distinct tracing subscribers; map_or_else hides the structure" + )] let otel_handles = if let Some(otlp_config) = &env.otlp { info!("OpenTelemetry configuration detected, initializing tracing..."); Some(et_otlp::init(otlp_config)) @@ -44,9 +55,7 @@ async fn main() -> Result<(), std::io::Error> { None }; - let network_ip = local_ip_address::local_ip() - .map(|ip| ip.to_string()) - .unwrap_or_else(|_| "127.0.0.1".to_string()); + let network_ip = local_ip_address::local_ip().map_or_else(|_unused| "127.0.0.1".to_string(), |ip| ip.to_string()); let cert_filename = &env.tls.cert_file; let key_filename = &env.tls.key_file; @@ -120,7 +129,7 @@ async fn main() -> Result<(), std::io::Error> { .run(); let handle = server.handle(); - tokio::spawn(async move { + let _shutdown_task = tokio::spawn(async move { tokio::signal::ctrl_c().await.unwrap(); info!("Shutdown signal received, saving registry..."); if let Err(e) = registry_clone.save(®istry_path) { diff --git a/services/ws-server/src/tls.rs b/services/ws-server/src/tls.rs index da2f195..91d0352 100644 --- a/services/ws-server/src/tls.rs +++ b/services/ws-server/src/tls.rs @@ -1,6 +1,12 @@ +#![expect( + clippy::single_call_fn, + clippy::unwrap_used, + reason = "TLS bootstrap helpers are single-use by main() and panic on invalid PEM / cert-gen failure intentionally" +)] + use std::path::Path; -use rustls::pki_types::pem::PemObject; +use rustls::pki_types::pem::PemObject as _; type CertKeyPair = ( rustls::pki_types::CertificateDer<'static>, diff --git a/services/ws-test-server/Cargo.toml b/services/ws-test-server/Cargo.toml index 0c321f5..6c0b32a 100644 --- a/services/ws-test-server/Cargo.toml +++ b/services/ws-test-server/Cargo.toml @@ -6,14 +6,20 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +doctest = false + [dependencies] actix.workspace = true actix-rt.workspace = true actix-web.workspace = true -et-modules-service = { path = "../modules" } -et-storage-service = { path = "../storage" } -et-ws-service = { path = "../ws" } +et-modules-service.workspace = true +et-storage-service.workspace = true +et-ws-service.workspace = true tempfile.workspace = true # Same TracingLogger setup as the real ws-server, so tests that init OTLP # in-process see server-side spans parented on the propagated traceparent. tracing-actix-web.workspace = true + +[lints] +workspace = true diff --git a/services/ws-test-server/src/lib.rs b/services/ws-test-server/src/lib.rs index e3b7ef4..517057c 100644 --- a/services/ws-test-server/src/lib.rs +++ b/services/ws-test-server/src/lib.rs @@ -1,3 +1,10 @@ +#![expect( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "in-process test ws-server; bind/startup failures should fail the test fast" +)] + use std::net::TcpListener; use actix_web::{App, HttpServer, web}; @@ -8,6 +15,7 @@ use tempfile::TempDir; use tracing_actix_web::TracingLogger; /// A running test server. The temporary storage directory is cleaned up on drop. +#[non_exhaustive] pub struct TestServer { pub base_url: String, pub ws_url: String, @@ -17,6 +25,7 @@ pub struct TestServer { /// Start an in-process ws-server on a free port with a temporary storage directory. /// /// Serves modules from the default module paths (same as production). +#[must_use] pub fn start() -> TestServer { let storage_dir = TempDir::new().expect("failed to create temp storage dir"); let storage_path = storage_dir.path().to_path_buf(); @@ -24,11 +33,11 @@ pub fn start() -> TestServer { // Bind to port 0 to get a free port, then drop the listener so the server can bind it. let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); - let storage_config = StorageConfig { path: storage_path }; + let storage_config = StorageConfig::new(storage_path); let modules_config = ModulesConfig::default(); let addr = format!("127.0.0.1:{port}"); - std::thread::spawn(move || { + let _server_thread = std::thread::spawn(move || { actix_rt::System::new().block_on(async move { let registry = web::Data::new(WsAgentRegistry::default()); let storage = web::Data::new(storage_config); @@ -53,7 +62,7 @@ pub fn start() -> TestServer { }); }); - for _ in 0..50 { + for _ in 0_u32..50 { if std::net::TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() { return TestServer { base_url: format!("http://127.0.0.1:{port}"), diff --git a/services/ws-wasi-runner/Cargo.toml b/services/ws-wasi-runner/Cargo.toml index 459b483..13f05e5 100644 --- a/services/ws-wasi-runner/Cargo.toml +++ b/services/ws-wasi-runner/Cargo.toml @@ -7,10 +7,12 @@ license.workspace = true repository.workspace = true [lib] +doctest = false name = "et_ws_wasi_runner" path = "src/lib.rs" [[bin]] +doctest = false name = "et-ws-wasi-runner" path = "src/main.rs" @@ -58,6 +60,9 @@ ort = { version = "=2.0.0-rc.10", default-features = false, features = ["copy-dy wgpu = { version = "29", default-features = false, features = ["dx12", "metal", "vulkan", "wgsl"] } [dev-dependencies] -et-ws-test-server = { path = "../ws-test-server" } -otlp-mock = { path = "../../libs/otlp-mock" } +et-ws-test-server.workspace = true +otlp-mock.workspace = true rstest.workspace = true + +[lints] +workspace = true diff --git a/services/ws-wasi-runner/src/bindings.rs b/services/ws-wasi-runner/src/bindings.rs index 95e29d7..1a3783c 100644 --- a/services/ws-wasi-runner/src/bindings.rs +++ b/services/ws-wasi-runner/src/bindings.rs @@ -4,6 +4,15 @@ //! crate root: the macro generates a `mod`-shaped tree of types, which //! would otherwise have to be wrapped in `pub mod bindings { ... }` at //! the `lib.rs` top level. +#![expect( + clippy::error_impl_error, + clippy::exhaustive_enums, + clippy::exhaustive_structs, + clippy::impl_trait_in_params, + clippy::integer_division_remainder_used, + clippy::missing_asserts_for_indexing, + reason = "wasmtime::component::bindgen! generates the API surface from WIT; we don't control its lints" +)] wasmtime::component::bindgen!({ path: "wit", diff --git a/services/ws-wasi-runner/src/host/error.rs b/services/ws-wasi-runner/src/host/error.rs index df701dd..d59433c 100644 --- a/services/ws-wasi-runner/src/host/error.rs +++ b/services/ws-wasi-runner/src/host/error.rs @@ -33,6 +33,13 @@ impl KvErrExt for Result { } } +/// Build a `wasi:keyvalue/store.error.other(" not implemented")` — the +/// closest thing the WIT-spec enum has to a `NotImplemented` variant. +#[must_use] +pub fn kv_not_implemented(operation: &str) -> KvError { + KvError::Other(format!("{operation} not implemented")) +} + /// Maps any `Display` error into `wasi:webgpu/webgpu`'s /// `request-device-error.operation-error` variant. pub trait RequestDeviceErrExt { diff --git a/services/ws-wasi-runner/src/host/log.rs b/services/ws-wasi-runner/src/host/log.rs index f41d2ac..c12532e 100644 --- a/services/ws-wasi-runner/src/host/log.rs +++ b/services/ws-wasi-runner/src/host/log.rs @@ -1,12 +1,16 @@ //! Implements `wasi:logging/logging`. Levels are routed into the `tracing` //! macros so log lines flow through whatever subscriber the runner installed -//! (stdout fmt layer in dev, OTel logs in production). `context` is attached +//! (stdout fmt layer in dev, `OTel` logs in production). `context` is attached //! as a structured field rather than baked into the message. use crate::HostState; use crate::bindings::wasi::logging::logging::{Host, Level}; impl Host for HostState { + #[expect( + clippy::cognitive_complexity, + reason = "match arm per WASI logging level — flat dispatch is the readable shape" + )] async fn log(&mut self, level: Level, context: String, message: String) { match level { Level::Trace => tracing::trace!(target: "wasi_logging", context = %context, "{message}"), @@ -17,7 +21,7 @@ impl Host for HostState { // `tracing` has no `critical` level. Route to error and tag the // attribute so a log processor can distinguish if it cares. Level::Critical => { - tracing::error!(target: "wasi_logging", context = %context, critical = true, "{message}") + tracing::error!(target: "wasi_logging", context = %context, critical = true, "{message}"); } } } diff --git a/services/ws-wasi-runner/src/host/mod.rs b/services/ws-wasi-runner/src/host/mod.rs index a833c9f..9b85c7a 100644 --- a/services/ws-wasi-runner/src/host/mod.rs +++ b/services/ws-wasi-runner/src/host/mod.rs @@ -18,9 +18,12 @@ pub mod wasi_nn; pub mod wasi_webgpu; mod ws; -pub use self::error::{KvErrExt, RequestDeviceErrExt, WitErrExt, WsProtocolErrExt, WsTransportErrExt}; +pub use self::error::{ + KvErrExt, RequestDeviceErrExt, WitErrExt, WsProtocolErrExt, WsTransportErrExt, kv_not_implemented, +}; pub use self::ws::WsBackend; +#[non_exhaustive] pub struct HostState { pub wasi_ctx: WasiCtx, pub resource_table: ResourceTable, @@ -38,7 +41,12 @@ pub struct HostState { } impl HostState { - pub async fn new(http_base: String, ws_url: String) -> Self { + #[must_use] + #[expect( + clippy::same_name_method, + reason = "convention: HostState::new mirrors WasiCtxBuilder/ResourceTable/Client constructors used here" + )] + pub fn new(http_base: String, ws_url: String) -> Self { let wasi_ctx = WasiCtxBuilder::new().inherit_stdio().inherit_env().build(); Self { diff --git a/services/ws-wasi-runner/src/host/wasi_keyvalue.rs b/services/ws-wasi-runner/src/host/wasi_keyvalue.rs index 254d750..79a9986 100644 --- a/services/ws-wasi-runner/src/host/wasi_keyvalue.rs +++ b/services/ws-wasi-runner/src/host/wasi_keyvalue.rs @@ -16,7 +16,7 @@ use wasmtime::component::Resource; use crate::HostState; use crate::bindings::wasi::keyvalue::store::{Error, Host, HostBucket, KeyResponse}; -use crate::host::KvErrExt; +use crate::host::{KvErrExt as _, kv_not_implemented}; pub struct Bucket { /// URL path-prefix on the ws-server, including the leading slash and @@ -33,6 +33,10 @@ impl Bucket { } /// Map a `store.open` identifier to a bucket prefix and writability. +#[expect( + clippy::single_call_fn, + reason = "named identifier parser; used once by ::open" +)] fn bucket_from_identifier(identifier: &str) -> Result { if let Some(module_name) = identifier.strip_prefix("modules/") { if module_name.is_empty() || module_name.contains('/') { @@ -63,8 +67,8 @@ impl Host for HostState { } impl HostBucket for HostState { - async fn get(&mut self, rep: Resource, key: String) -> Result>, Error> { - let bucket = self.resource_table.get(&rep).kv_context("bucket handle")?; + async fn get(&mut self, self_: Resource, key: String) -> Result>, Error> { + let bucket = self.resource_table.get(&self_).kv_context("bucket handle")?; let url = bucket.url(&self.http_base, &key); let resp = self.http.get(&url).send().await.kv_context(&format!("GET {url}"))?; if resp.status() == reqwest::StatusCode::NOT_FOUND { @@ -77,8 +81,8 @@ impl HostBucket for HostState { Ok(Some(bytes.to_vec())) } - async fn set(&mut self, rep: Resource, key: String, value: Vec) -> Result<(), Error> { - let bucket = self.resource_table.get(&rep).kv_context("bucket handle")?; + async fn set(&mut self, self_: Resource, key: String, value: Vec) -> Result<(), Error> { + let bucket = self.resource_table.get(&self_).kv_context("bucket handle")?; if !bucket.writable { return Err(Error::AccessDenied); } @@ -97,19 +101,19 @@ impl HostBucket for HostState { } async fn delete(&mut self, _rep: Resource, _key: String) -> Result<(), Error> { - Err(Error::Other("delete not implemented".into())) + Err(kv_not_implemented("delete")) } async fn exists(&mut self, _rep: Resource, _key: String) -> Result { - Err(Error::Other("exists not implemented".into())) + Err(kv_not_implemented("exists")) } async fn list_keys(&mut self, _rep: Resource, _cursor: Option) -> Result { - Err(Error::Other("list-keys not implemented".into())) + Err(kv_not_implemented("list-keys")) } async fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { - self.resource_table.delete(rep)?; + let _removed: Bucket = self.resource_table.delete(rep)?; Ok(()) } } diff --git a/services/ws-wasi-runner/src/host/wasi_nn.rs b/services/ws-wasi-runner/src/host/wasi_nn.rs index 512f4bf..82e01d1 100644 --- a/services/ws-wasi-runner/src/host/wasi_nn.rs +++ b/services/ws-wasi-runner/src/host/wasi_nn.rs @@ -7,8 +7,8 @@ //! //! Backend choice: the runner builds with `wasmtime-wasi-nn`'s `onnx` feature, //! which routes inference through `ort` (ONNX Runtime). On macOS that means -//! the system CoreML / Apple Accelerate paths; on Linux it picks up CUDA / -//! ROCm if the system ONNX Runtime was built with them, else CPU. +//! the system `CoreML` / Apple Accelerate paths; on Linux it picks up CUDA / +//! `ROCm` if the system ONNX Runtime was built with them, else CPU. //! //! Why no `wasi:webgpu` integration here: wasi-nn delegates to a host-chosen //! ML runtime which manages its own GPU access. The trimmed `wasi:webgpu` @@ -18,9 +18,11 @@ use wasmtime_wasi_nn::wit::{WasiNnCtx, WasiNnView}; -/// Build a `WasiNnCtx` configured with whatever backends the crate's feature -/// flags enabled (just `onnx` for us). Empty registry — guests load model -/// bytes directly via `graph.load`, so name-based lookup isn't needed. +/// Build a `WasiNnCtx` configured with whatever backends the crate's feature flags enabled (just `onnx` for us). +/// +/// Empty registry — guests load model bytes directly via `graph.load`, so +/// name-based lookup isn't needed. +#[must_use] pub fn new_ctx() -> WasiNnCtx { let backends = wasmtime_wasi_nn::backend::list(); let registry = wasmtime_wasi_nn::Registry::from(wasmtime_wasi_nn::InMemoryRegistry::new()); diff --git a/services/ws-wasi-runner/src/host/wasi_webgpu.rs b/services/ws-wasi-runner/src/host/wasi_webgpu.rs index 358972a..8069eb6 100644 --- a/services/ws-wasi-runner/src/host/wasi_webgpu.rs +++ b/services/ws-wasi-runner/src/host/wasi_webgpu.rs @@ -14,6 +14,30 @@ //! buffer pass commands on the encoder resource and replay them inside //! `end()`, so the real `ComputePass` lives only for the duration of one //! synchronous block. +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::clone_on_ref_ptr, + clippy::default_trait_access, + clippy::doc_markdown, + clippy::exhaustive_enums, + clippy::exhaustive_structs, + clippy::expect_used, + clippy::indexing_slicing, + clippy::let_underscore_must_use, + clippy::let_underscore_untyped, + clippy::min_ident_chars, + clippy::needless_pass_by_ref_mut, + clippy::redundant_clone, + clippy::renamed_function_params, + clippy::single_call_fn, + clippy::too_long_first_doc_paragraph, + clippy::unimplemented, + let_underscore_drop, + unused_results, + reason = "trimmed wasi-webgpu host: only matmul path is wired, others trap; to be replaced by upstream" +)] use std::collections::BTreeMap; use std::sync::Arc; @@ -33,7 +57,7 @@ use crate::bindings::wasi::webgpu::webgpu::{ MapAsyncError, MapAsyncErrorKind, RequestDeviceError, SetBindGroupError, SetBindGroupErrorKind, UnmapError, UnmapErrorKind, WriteBufferError, WriteBufferErrorKind, }; -use crate::host::{RequestDeviceErrExt, WitErrExt}; +use crate::host::{RequestDeviceErrExt as _, WitErrExt as _}; /// wgpu buffer-usage flags as the host wire-format. The WIT-side /// `gpu-buffer-usage.STORAGE()` style accessors return these constants and @@ -1182,7 +1206,7 @@ impl HostGpuComputePassEncoder for HostState { _dynamic_offsets_data_start: Option, _dynamic_offsets_data_length: Option, ) -> Result<(), SetBindGroupError> { - let bg = bind_group.ok_or(SetBindGroupError { + let bg = bind_group.ok_or_else(|| SetBindGroupError { kind: SetBindGroupErrorKind::RangeError, message: "set-bind-group with None not supported in matmul subset".into(), })?; @@ -1294,10 +1318,7 @@ impl HostGpuQueue for HostState { let queue = self.resource_table.get(&rep).expect("queue handle").queue.clone(); let buf = self.resource_table.get(&buffer).expect("buffer handle").buffer.clone(); let data_offset = data_offset.unwrap_or(0) as usize; - let end = match size { - Some(s) => data_offset + s as usize, - None => data.len(), - }; + let end = size.map_or(data.len(), |s| data_offset + s as usize); if end > data.len() { return Err(WriteBufferError { kind: WriteBufferErrorKind::OperationError, diff --git a/services/ws-wasi-runner/src/host/ws.rs b/services/ws-wasi-runner/src/host/ws.rs index 6138a82..44d6717 100644 --- a/services/ws-wasi-runner/src/host/ws.rs +++ b/services/ws-wasi-runner/src/host/ws.rs @@ -11,8 +11,8 @@ use std::sync::Arc; use std::time::Duration; use edge_toolkit::ws::WsMessage; -use futures_util::SinkExt; -use futures_util::stream::{SplitSink, StreamExt}; +use futures_util::SinkExt as _; +use futures_util::stream::{SplitSink, StreamExt as _}; use tokio::net::TcpStream; use tokio::sync::{Mutex, mpsc}; use tokio::task::JoinHandle; @@ -20,7 +20,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, tungstenite}; use crate::HostState; use crate::bindings::et::ws_wasi::ws::{Host, State, WsError}; -use crate::host::{WsProtocolErrExt, WsTransportErrExt}; +use crate::host::{WsProtocolErrExt as _, WsTransportErrExt as _}; type WsSink = SplitSink>, tungstenite::Message>; @@ -35,6 +35,10 @@ pub struct WsBackend { } impl WsBackend { + #[expect( + clippy::single_call_fn, + reason = "inherent constructor; used once by ::connect" + )] async fn connect(ws_url: &str) -> Result { let (stream, _) = tokio_tungstenite::connect_async(ws_url) .await @@ -55,8 +59,8 @@ impl WsBackend { // Reader pump: route ConnectAck into `agent_id` + `connection_state`, // forward all other text messages to the guest via `inbox`. - let agent_id_clone = agent_id.clone(); - let state_clone = connection_state.clone(); + let agent_id_clone = Arc::clone(&agent_id); + let state_clone = Arc::clone(&connection_state); let reader = tokio::spawn(async move { while let Some(msg) = stream.next().await { let Ok(msg) = msg else { @@ -65,7 +69,7 @@ impl WsBackend { let tungstenite::Message::Text(text) = msg else { continue; }; - let text = text.to_string(); + let text: String = text.as_str().to_owned(); if let Ok(parsed) = serde_json::from_str::(&text) && let WsMessage::ConnectAck { agent_id, .. } = &parsed { @@ -88,13 +92,6 @@ impl WsBackend { }) } - async fn send_text(&self, text: String) -> Result<(), WsError> { - let mut sink = self.sink.lock().await; - sink.send(tungstenite::Message::text(text)) - .await - .ws_transport("send text") - } - async fn current_state(&self) -> State { *self.connection_state.lock().await } @@ -102,40 +99,36 @@ impl WsBackend { async fn current_agent_id(&self) -> String { self.agent_id.lock().await.clone().unwrap_or_default() } - - async fn recv(&self, timeout_ms: u32) -> Result, WsError> { - let mut inbox = self.inbox.lock().await; - match tokio::time::timeout(Duration::from_millis(timeout_ms as u64), inbox.recv()).await { - Ok(Some(text)) => Ok(Some(text)), - Ok(None) => Err(WsError::InboxClosed), - Err(_) => Ok(None), - } - } } impl Host for HostState { async fn connect(&mut self) -> Result<(), WsError> { - let mut slot = self.ws.lock().await; - if slot.is_some() { - return Err(WsError::AlreadyConnected); + { + let slot = self.ws.lock().await; + if slot.is_some() { + return Err(WsError::AlreadyConnected); + } } let backend = WsBackend::connect(&self.ws_url).await?; // Wait briefly for ConnectAck before returning, so guests can call // agent_id() right after connect() and get a value. - for _ in 0..50 { + for _ in 0_u32..50 { if matches!(backend.current_state().await, State::Connected) { break; } tokio::time::sleep(Duration::from_millis(20)).await; } - *slot = Some(backend); + { + let mut slot = self.ws.lock().await; + *slot = Some(backend); + } Ok(()) } async fn get_state(&mut self) -> State { let slot = self.ws.lock().await; match slot.as_ref() { - Some(b) => b.current_state().await, + Some(bridge) => bridge.current_state().await, None => State::Closed, } } @@ -143,7 +136,7 @@ impl Host for HostState { async fn agent_id(&mut self) -> String { let slot = self.ws.lock().await; match slot.as_ref() { - Some(b) => b.current_agent_id().await, + Some(bridge) => bridge.current_agent_id().await, None => String::new(), } } @@ -164,7 +157,13 @@ impl Host for HostState { let Some(backend) = slot.as_ref() else { return Err(WsError::NotConnected); }; - backend.send_text(text).await + let sink = Arc::clone(&backend.sink); + drop(slot); + let mut sink_guard = sink.lock().await; + sink_guard + .send(tungstenite::Message::text(text)) + .await + .ws_transport("send text") } async fn recv(&mut self, timeout_ms: u32) -> Result, WsError> { @@ -172,14 +171,21 @@ impl Host for HostState { let Some(backend) = slot.as_ref() else { return Err(WsError::NotConnected); }; - backend.recv(timeout_ms).await + let inbox = Arc::clone(&backend.inbox); + drop(slot); + let mut inbox_guard = inbox.lock().await; + match tokio::time::timeout(Duration::from_millis(u64::from(timeout_ms)), inbox_guard.recv()).await { + Ok(Some(text)) => Ok(Some(text)), + Ok(None) => Err(WsError::InboxClosed), + Err(_unused) => Ok(None), + } } async fn disconnect(&mut self) { let mut slot = self.ws.lock().await; if let Some(backend) = slot.as_ref() { *backend.connection_state.lock().await = State::Closing; - let _ = backend.sink.lock().await.close().await; + let _closed: Result<(), _> = backend.sink.lock().await.close().await; } *slot = None; } diff --git a/services/ws-wasi-runner/src/lib.rs b/services/ws-wasi-runner/src/lib.rs index d8207bd..1fd46ed 100644 --- a/services/ws-wasi-runner/src/lib.rs +++ b/services/ws-wasi-runner/src/lib.rs @@ -10,8 +10,8 @@ use opentelemetry_http::HeaderInjector; use thiserror::Error; -use tracing::Instrument; -use tracing_opentelemetry::OpenTelemetrySpanExt; +use tracing::Instrument as _; +use tracing_opentelemetry::OpenTelemetrySpanExt as _; use wasmtime::component::{Component, HasSelf, Linker}; use wasmtime::{Config, Engine, Store}; @@ -19,11 +19,13 @@ pub mod bindings; use self::bindings::exports::et::ws_wasi::entry::RunError; -/// Errors `run_module` can fail with. `reqwest::Error` and -/// `wasmtime::Error` already carry enough context to be useful on -/// their own, so they're forwarded transparently. `RunError` is the -/// WIT-defined variant the guest returns from `entry.run`. +/// Errors `run_module` can fail with. +/// +/// `reqwest::Error` and `wasmtime::Error` already carry enough context to be +/// useful on their own, so they're forwarded transparently. `RunError` is +/// the WIT-defined variant the guest returns from `entry.run`. #[derive(Debug, Error)] +#[non_exhaustive] pub enum RunnerError { #[error("could not derive HTTP base from WS_SERVER_URL={ws_url}")] InvalidWsUrl { ws_url: String }, @@ -46,7 +48,7 @@ pub enum RunnerError { // being wit-bindgen-generated, doesn't implement `Error`. impl From for RunnerError { fn from(source: RunError) -> Self { - RunnerError::Guest(source) + Self::Guest(source) } } @@ -71,11 +73,12 @@ fn inject_traceparent(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { /// Convert a `ws://host[:port]/ws` URL to its `http://host[:port]` HTTP base /// (or `wss://` → `https://`). Returns `None` if `ws_url` is not a websocket /// URL. +#[must_use] pub fn derive_http_base(ws_url: &str) -> Option { - let (scheme, rest) = if let Some(r) = ws_url.strip_prefix("wss://") { - ("https", r) - } else if let Some(r) = ws_url.strip_prefix("ws://") { - ("http", r) + let (scheme, rest) = if let Some(suffix) = ws_url.strip_prefix("wss://") { + ("https", suffix) + } else if let Some(suffix) = ws_url.strip_prefix("ws://") { + ("http", suffix) } else { return None; }; @@ -86,6 +89,10 @@ pub fn derive_http_base(ws_url: &str) -> Option { /// Where to find the .wasm component for a given module. /// /// Resolved against `package.json`'s `main` field as served by the ws-server. +#[expect( + clippy::single_call_fn, + reason = "named step in the module-load pipeline; used once by run_module_inner" +)] async fn resolve_component_url(http_base: &str, module_name: &str) -> Result { let pkg_url = format!("{http_base}/modules/{module_name}/package.json"); let pkg: serde_json::Value = inject_traceparent(reqwest::Client::new().get(&pkg_url)) @@ -97,16 +104,18 @@ async fn resolve_component_url(http_base: &str, module_name: &str) -> Result Result<(), RunnerErr run_module_inner(module_name, ws_url).instrument(span).await } +#[expect( + clippy::single_call_fn, + reason = "span-instrumented body of run_module; the split is mandatory to scope the tracing span" +)] async fn run_module_inner(module_name: &str, ws_url: &str) -> Result<(), RunnerError> { let http_base = derive_http_base(ws_url).ok_or_else(|| RunnerError::InvalidWsUrl { ws_url: ws_url.to_string(), @@ -132,17 +145,23 @@ async fn run_module_inner(module_name: &str, ws_url: &str) -> Result<(), RunnerE .await?; let mut config = Config::new(); - config.wasm_component_model(true); + #[expect( + unused_results, + reason = "wasmtime::Config::wasm_component_model returns &mut Self for builder chaining; mutation is the intent" + )] + { + config.wasm_component_model(true); + } let engine = Engine::new(&config)?; let component = Component::from_binary(&engine, &wasm_bytes)?; let mut linker: Linker = Linker::new(&engine); wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - bindings::Runner::add_to_linker::>(&mut linker, |s| s)?; + bindings::Runner::add_to_linker::>(&mut linker, |state| state)?; wasmtime_wasi_nn::wit::add_to_linker(&mut linker, host::wasi_nn::view)?; - let host_state = HostState::new(http_base, ws_url.to_string()).await; + let host_state = HostState::new(http_base, ws_url.to_string()); let mut store = Store::new(&engine, host_state); let module = bindings::Runner::instantiate_async(&mut store, &component, &linker).await?; diff --git a/services/ws-wasi-runner/src/main.rs b/services/ws-wasi-runner/src/main.rs index 371720e..461fa1f 100644 --- a/services/ws-wasi-runner/src/main.rs +++ b/services/ws-wasi-runner/src/main.rs @@ -14,6 +14,10 @@ struct EnvConfig { async fn main() -> Result<(), Box> { let env_config = serde_env::from_env::().unwrap_or_default(); + #[expect( + clippy::option_if_let_else, + reason = "None branch installs an alternate tracing subscriber as a side effect; map_or_else hides it" + )] let otel_handles = if let Some(otlp_config) = &env_config.otlp { Some(et_otlp::init(otlp_config)) } else { diff --git a/services/ws-wasi-runner/tests/modules.rs b/services/ws-wasi-runner/tests/modules.rs index 34ade85..f2c00d9 100644 --- a/services/ws-wasi-runner/tests/modules.rs +++ b/services/ws-wasi-runner/tests/modules.rs @@ -4,6 +4,10 @@ //! components rather than browser-targeted JS. #![cfg(test)] +#![expect( + clippy::expect_used, + reason = "test code: process spawn failure should fail the test" +)] use rstest::rstest; diff --git a/services/ws-wasi-runner/tests/otel_propagation.rs b/services/ws-wasi-runner/tests/otel_propagation.rs index b6c5fb1..011d9f4 100644 --- a/services/ws-wasi-runner/tests/otel_propagation.rs +++ b/services/ws-wasi-runner/tests/otel_propagation.rs @@ -21,6 +21,13 @@ //! to exercise — no wgpu / wasi-nn work. #![cfg(test)] +#![expect( + clippy::expect_used, + clippy::non_ascii_literal, + clippy::uninlined_format_args, + clippy::needless_collect, + reason = "test code: assertions include captured span dumps in failure messages; em-dash matches existing style" +)] use std::collections::HashSet; use std::time::Duration; @@ -41,7 +48,7 @@ fn trace_ids_propagate_between_runner_and_server() { // OtlpConfig is `non_exhaustive`, so build via Default + field // assignment. let mut server_otlp = OtlpConfig::default(); - server_otlp.collector_url = mock.collector_url.clone(); + server_otlp.collector_url = mock.collector_url().to_owned(); server_otlp.protocol = OtlpProtocol::JSON; server_otlp.service_label = "et-ws-test".to_string(); server_otlp.auth = None; @@ -56,7 +63,7 @@ fn trace_ids_propagate_between_runner_and_server() { let status = std::process::Command::new(bin) .env("RUNNER_MODULE", "et-ws-wasi-data1") .env("WS_SERVER_URL", &server.ws_url) - .env("OTLP_COLLECTOR_URL", &mock.collector_url) + .env("OTLP_COLLECTOR_URL", mock.collector_url()) .env("OTLP_PROTOCOL", "JSON") .env("OTLP_SERVICE_LABEL", "et-ws-wasi-runner") .status() @@ -81,7 +88,8 @@ fn trace_ids_propagate_between_runner_and_server() { let trace_ids_by_service: std::collections::HashMap> = spans.iter().fold(std::collections::HashMap::new(), |mut acc, span| { - acc.entry(span.service_name.clone()) + let _inserted: bool = acc + .entry(span.service_name.clone()) .or_default() .insert(span.trace_id.clone()); acc @@ -121,14 +129,14 @@ fn trace_ids_propagate_between_runner_and_server() { // the propagation direction (runner → server). let server_with_parent = spans .iter() - .filter(|s| s.service_name == "et-ws-test" && !s.parent_span_id.is_empty()) + .filter(|span| span.service_name == "et-ws-test" && !span.parent_span_id.is_empty()) .count(); assert!( server_with_parent > 0, "no server span had a non-empty parentSpanId: TracingLogger didnt extract `traceparent`. server spans: {:#?}", spans .iter() - .filter(|s| s.service_name == "et-ws-test") + .filter(|span| span.service_name == "et-ws-test") .collect::>() ); } diff --git a/services/ws-wasi-runner/tests/url_helpers.rs b/services/ws-wasi-runner/tests/url_helpers.rs index db51b94..5069f83 100644 --- a/services/ws-wasi-runner/tests/url_helpers.rs +++ b/services/ws-wasi-runner/tests/url_helpers.rs @@ -4,7 +4,7 @@ //! parent workspace's `et-ws-test-server` can't be pulled in here — see //! `Cargo.toml`). When you want to run a module end-to-end: //! mise run ws-server # in one terminal -//! mise run ws-wasi-runner # in another, with RUNNER_MODULE=wasi-graphics-info +//! mise run ws-wasi-runner # in another, with RUNNER_MODULE=wasi-graphics-info. #![cfg(test)] diff --git a/services/ws-wasm-agent/Cargo.toml b/services/ws-wasm-agent/Cargo.toml index 322defe..8565b22 100644 --- a/services/ws-wasm-agent/Cargo.toml +++ b/services/ws-wasm-agent/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [lib] crate-type = ["cdylib", "rlib"] +doctest = false [dependencies] chrono.workspace = true @@ -36,6 +37,5 @@ web-sys = { version = "0.3", features = [ [dev-dependencies] wasm-bindgen-test = "0.3" -[profile.release] -lto = true -opt-level = "s" +[lints] +workspace = true diff --git a/services/ws-wasm-agent/src/lib.rs b/services/ws-wasm-agent/src/lib.rs index 1b3b58f..a8a168f 100644 --- a/services/ws-wasm-agent/src/lib.rs +++ b/services/ws-wasm-agent/src/lib.rs @@ -1,9 +1,18 @@ +#![expect( + clippy::single_call_fn, + reason = "load_/store_ localStorage helpers are a matched group; each is invoked once but kept named for symmetry" +)] +#![expect( + unused_results, + reason = "js_sys::Reflect::set's bool result is deliberately discarded for fire-and-forget UI updates" +)] + use std::cell::RefCell; use std::collections::VecDeque; use std::rc::Rc; use edge_toolkit::ws::{ConnectStatus, WsMessage}; -use et_web::JsResultExt; +use et_web::JsResultExt as _; use tracing::{error, info, warn}; use wasm_bindgen::prelude::*; use web_sys::{Event, MessageEvent, WebSocket}; @@ -27,7 +36,11 @@ pub fn init_tracing() { } // Connection state -#[derive(Debug, Clone, PartialEq)] +#[expect( + clippy::exhaustive_enums, + reason = "ConnectionState enumerates the WebSocket client's lifecycle; downstream code matches exhaustively" +)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ConnectionState { Disconnected, Connecting, @@ -35,18 +48,19 @@ pub enum ConnectionState { Reconnecting, } +#[must_use] pub fn js_number_field(value: &JsValue, field: &str) -> Option { - js_sys::Reflect::get(value, &JsValue::from_str(field)) - .ok() - .and_then(|field_value| field_value.as_f64()) + let field_value = js_sys::Reflect::get(value, &JsValue::from_str(field)).ok()?; + field_value.as_f64() } +#[must_use] pub fn js_bool_field(value: &JsValue, field: &str) -> Option { - js_sys::Reflect::get(value, &JsValue::from_str(field)) - .ok() - .and_then(|field_value| field_value.as_bool()) + let field_value = js_sys::Reflect::get(value, &JsValue::from_str(field)).ok()?; + field_value.as_bool() } +#[must_use] pub fn js_nested_object(value: &JsValue, field: &str) -> Option { js_sys::Reflect::get(value, &JsValue::from_str(field)) .ok() @@ -63,10 +77,15 @@ pub struct WsClientConfig { } #[wasm_bindgen] +#[expect( + clippy::missing_const_for_fn, + reason = "wasm_bindgen rejects const fns; methods cannot be marked const" +)] impl WsClientConfig { + #[must_use] #[wasm_bindgen(constructor)] - pub fn new(server_url: String) -> WsClientConfig { - WsClientConfig { + pub fn new(server_url: String) -> Self { + Self { server_url, alive_interval_ms: DEFAULT_ALIVE_INTERVAL_MS, max_reconnect_attempts: 10, @@ -115,7 +134,8 @@ pub struct WsClient { #[wasm_bindgen] impl WsClient { #[wasm_bindgen(constructor)] - pub fn new(config: WsClientConfig) -> WsClient { + #[must_use] + pub fn new(config: WsClientConfig) -> Self { let agent_id = load_stored_agent_id(); info!("Creating new WebSocket client with retained agent ID: {:?}", agent_id); @@ -132,7 +152,7 @@ impl WsClient { on_state_change_callback: None, })); - WsClient { + Self { config, agent_id: Rc::new(RefCell::new(agent_id)), shared, @@ -141,6 +161,11 @@ impl WsClient { /// Connect to the WebSocket server #[wasm_bindgen] + #[expect( + clippy::too_many_lines, + clippy::cognitive_complexity, + reason = "single-method connect+wire-up; on_message closure dispatches all WsMessage variants inline" + )] pub fn connect(&mut self) -> Result<(), JsValue> { info!("Connecting to WebSocket server: {}", self.config.server_url); @@ -152,26 +177,26 @@ impl WsClient { // Store the socket { - let mut s = self.shared.borrow_mut(); - s.socket = Some(socket.clone()); - s.state = ConnectionState::Connecting; - s.manual_disconnect = false; + let mut state = self.shared.borrow_mut(); + state.socket = Some(socket.clone()); + state.state = ConnectionState::Connecting; + state.manual_disconnect = false; } self.notify_state_change(); // Set up event handlers - let on_open = Closure::wrap(Box::new({ - let shared = self.shared.clone(); + let on_open_box: Box = Box::new({ + let shared = Rc::clone(&self.shared); let initial_delay = self.config.initial_reconnect_delay_ms; let cli_ptr = self.clone(); move |_event: Event| { info!("WebSocket connected"); { - let mut s = shared.borrow_mut(); - s.state = ConnectionState::Connected; - s.reconnect_attempts = 0; - s.reconnect_delay_ms = initial_delay; - if let Some(timeout_id) = s.reconnect_timeout_id.take() + let mut state = shared.borrow_mut(); + state.state = ConnectionState::Connected; + state.reconnect_attempts = 0; + state.reconnect_delay_ms = initial_delay; + if let Some(timeout_id) = state.reconnect_timeout_id.take() && let Some(window) = web_sys::window() { window.clear_timeout_with_handle(timeout_id); @@ -184,11 +209,12 @@ impl WsClient { cli_ptr.flush_offline_queue(); cli_ptr.start_alive_interval(); } - }) as Box); + }); + let on_open = Closure::wrap(on_open_box); - let on_message = Closure::wrap(Box::new({ - let shared = self.shared.clone(); - let retained_agent_id = self.agent_id.clone(); + let on_message_box: Box = Box::new({ + let shared = Rc::clone(&self.shared); + let retained_agent_id = Rc::clone(&self.agent_id); move |event: MessageEvent| { info!("WebSocket message received"); if let Some(data) = event.data().as_string() { @@ -269,31 +295,35 @@ impl WsClient { } } // Notify callback if set - let s = shared.borrow(); - if let Some(ref callback) = s.on_message_callback + let state = shared.borrow(); + if let Some(callback) = &state.on_message_callback && let Some(function) = callback.dyn_ref::() { - let _ = function.call1(&JsValue::NULL, &JsValue::from_str(&data)); + let _called: Result = + function.call1(&JsValue::NULL, &JsValue::from_str(&data)); } } } - }) as Box); + }); + let on_message = Closure::wrap(on_message_box); - let on_error = Closure::wrap(Box::new({ - let mut cli_ptr = self.clone(); + let on_error_box: Box = Box::new({ + let cli_ptr = self.clone(); move |_event: Event| { error!("WebSocket error occurred"); cli_ptr.handle_disconnect(); } - }) as Box); + }); + let on_error = Closure::wrap(on_error_box); - let on_close = Closure::wrap(Box::new({ - let mut cli_ptr = self.clone(); + let on_close_box: Box = Box::new({ + let cli_ptr = self.clone(); move |_event: Event| { info!("WebSocket closed"); cli_ptr.handle_disconnect(); } - }) as Box); + }); + let on_close = Closure::wrap(on_close_box); // Add event listeners socket.set_onopen(Some(on_open.as_ref().unchecked_ref())); @@ -318,13 +348,13 @@ impl WsClient { self.cancel_reconnect(); self.record_offline(); { - let mut s = self.shared.borrow_mut(); - s.manual_disconnect = true; - if let Some(ref socket) = s.socket { - let _ = socket.close(); + let mut state = self.shared.borrow_mut(); + state.manual_disconnect = true; + if let Some(socket) = &state.socket { + let _closed: Result<(), JsValue> = socket.close(); } - s.socket = None; - s.state = ConnectionState::Disconnected; + state.socket = None; + state.state = ConnectionState::Disconnected; } self.notify_state_change(); } @@ -332,8 +362,8 @@ impl WsClient { /// Send an alive message to the server #[wasm_bindgen] pub fn send_alive(&self) -> Result<(), JsValue> { - let s = self.shared.borrow(); - if s.state != ConnectionState::Connected { + let state = self.shared.borrow(); + if state.state != ConnectionState::Connected { return Err(JsValue::from_str("Not connected")); } @@ -342,7 +372,7 @@ impl WsClient { let json = serde_json::to_string(&msg).js_context("Failed to serialize message")?; - if let Some(ref socket) = s.socket { + if let Some(socket) = &state.socket { socket.send_with_str(&json).js_context("Failed to send message")?; info!("Alive message sent: {}", json); } @@ -354,8 +384,8 @@ impl WsClient { #[wasm_bindgen] pub fn send(&self, message: &str) -> Result<(), JsValue> { let should_queue = { - let s = self.shared.borrow(); - s.state != ConnectionState::Connected || s.socket.is_none() + let state = self.shared.borrow(); + state.state != ConnectionState::Connected || state.socket.is_none() }; if should_queue { @@ -364,8 +394,9 @@ impl WsClient { } let send_result = { - let s = self.shared.borrow(); - s.socket + let state = self.shared.borrow(); + state + .socket .as_ref() .ok_or_else(|| JsValue::from_str("No websocket available"))? .send_with_str(message) @@ -380,8 +411,7 @@ impl WsClient { warn!("Send failed while online, queueing message for retry: {:?}", error); self.enqueue_offline_message(message); Err(JsValue::from_str(&format!( - "Failed to send message immediately; queued for retry: {:?}", - error + "Failed to send message immediately; queued for retry: {error:?}" ))) } } @@ -389,6 +419,7 @@ impl WsClient { /// Get the current connection state #[wasm_bindgen] + #[must_use] pub fn get_state(&self) -> String { match self.shared.borrow().state { ConnectionState::Disconnected => "disconnected".to_string(), @@ -400,6 +431,7 @@ impl WsClient { /// Get the agent ID assigned by the server on connect. #[wasm_bindgen] + #[must_use] pub fn get_agent_id(&self) -> String { self.agent_id.borrow().clone().unwrap_or_default() } @@ -425,15 +457,34 @@ impl WsClient { warn!("No window available to start alive interval"); return; }; + let Ok(interval_ms) = i32::try_from(self.config.alive_interval_ms) else { + warn!( + "alive_interval_ms ({}) exceeds i32::MAX; skipping interval", + self.config.alive_interval_ms + ); + return; + }; + + let interval_closure = self.build_alive_closure(); + self.install_alive_interval(&window, interval_ms, interval_closure); + } - let interval_ms = self.config.alive_interval_ms as i32; + fn build_alive_closure(&self) -> Closure { let cli_ptr = self.clone(); - let interval_closure = Closure::wrap(Box::new(move || { + let interval_box: Box = Box::new(move || { if let Err(error) = cli_ptr.send_alive() { warn!("Failed to send alive keepalive: {:?}", error); } - }) as Box); + }); + Closure::wrap(interval_box) + } + fn install_alive_interval( + &self, + window: &web_sys::Window, + interval_ms: i32, + interval_closure: Closure, + ) { match window.set_interval_with_callback_and_timeout_and_arguments_0( interval_closure.as_ref().unchecked_ref(), interval_ms, @@ -450,8 +501,8 @@ impl WsClient { } fn stop_alive_interval(&self) { - let mut s = self.shared.borrow_mut(); - if let Some(interval_id) = s.alive_interval_id.take() { + let mut state = self.shared.borrow_mut(); + if let Some(interval_id) = state.alive_interval_id.take() { if let Some(window) = web_sys::window() { window.clear_interval_with_handle(interval_id); } @@ -459,13 +510,13 @@ impl WsClient { } } - fn handle_disconnect(&mut self) { + fn handle_disconnect(&self) { self.stop_alive_interval(); let manual_disconnect = { - let mut s = self.shared.borrow_mut(); - s.socket = None; - s.state = ConnectionState::Disconnected; - s.manual_disconnect + let mut state = self.shared.borrow_mut(); + state.socket = None; + state.state = ConnectionState::Disconnected; + state.manual_disconnect }; self.record_offline(); self.notify_state_change(); @@ -477,47 +528,48 @@ impl WsClient { // Attempt reconnection with exponential backoff let mut do_reconnect = false; - let mut next_delay = 0; - let mut curr_attempt = 0; + let mut next_delay = 0_u32; + let mut curr_attempt = 0_u32; { - let mut s = self.shared.borrow_mut(); - if s.reconnect_attempts < self.config.max_reconnect_attempts { - s.state = ConnectionState::Reconnecting; - next_delay = s.reconnect_delay_ms; - s.reconnect_delay_ms = (s.reconnect_delay_ms * 2).min(30000); - s.reconnect_attempts += 1; - curr_attempt = s.reconnect_attempts; + let mut state = self.shared.borrow_mut(); + if state.reconnect_attempts < self.config.max_reconnect_attempts { + state.state = ConnectionState::Reconnecting; + next_delay = state.reconnect_delay_ms; + state.reconnect_delay_ms = state.reconnect_delay_ms.saturating_mul(2).min(30_000); + state.reconnect_attempts = state.reconnect_attempts.saturating_add(1); + curr_attempt = state.reconnect_attempts; do_reconnect = true; } } if do_reconnect { self.notify_state_change(); info!("Attempting reconnection {} in {}ms", curr_attempt, next_delay); - self.schedule_reconnect(next_delay as i32); + let delay_i32 = i32::try_from(next_delay).unwrap_or(i32::MAX); + self.schedule_reconnect(delay_i32); } else { error!("Max reconnection attempts reached"); } } fn notify_state_change(&self) { - let state = self.get_state(); - let s = self.shared.borrow(); - if let Some(ref callback) = s.on_state_change_callback + let state_label = self.get_state(); + let state = self.shared.borrow(); + if let Some(callback) = &state.on_state_change_callback && let Some(function) = callback.dyn_ref::() { - let _ = function.call1(&JsValue::NULL, &JsValue::from_str(&state)); + let _called: Result = function.call1(&JsValue::NULL, &JsValue::from_str(&state_label)); } } fn send_connect_message(&self) -> Result<(), JsValue> { - let s = self.shared.borrow(); + let state = self.shared.borrow(); let msg = WsMessage::Connect { agent_id: self.agent_id.borrow().clone(), }; let json = serde_json::to_string(&msg).js_context("Failed to serialize connect message")?; - if let Some(ref socket) = s.socket { + if let Some(socket) = &state.socket { socket .send_with_str(&json) .js_context("Failed to send connect message")?; @@ -528,18 +580,18 @@ impl WsClient { } fn enqueue_offline_message(&self, message: &str) { - let mut s = self.shared.borrow_mut(); - if s.offline_queue.len() == MAX_OFFLINE_QUEUE_LEN { - s.offline_queue.pop_front(); + let mut state = self.shared.borrow_mut(); + if state.offline_queue.len() == MAX_OFFLINE_QUEUE_LEN { + let _dropped: Option = state.offline_queue.pop_front(); warn!( "Offline websocket queue reached {} messages; dropping oldest entry", MAX_OFFLINE_QUEUE_LEN ); } - s.offline_queue.push_back(message.to_string()); + state.offline_queue.push_back(message.to_string()); info!( "Queued websocket message while offline (queue_len={}): {}", - s.offline_queue.len(), + state.offline_queue.len(), message ); } @@ -547,33 +599,35 @@ impl WsClient { fn flush_offline_queue(&self) { loop { let next_message = { - let mut s = self.shared.borrow_mut(); - if s.state != ConnectionState::Connected || s.socket.is_none() { + let mut state = self.shared.borrow_mut(); + if state.state != ConnectionState::Connected || state.socket.is_none() { return; } - s.offline_queue.pop_front() + state.offline_queue.pop_front() }; let Some(message) = next_message else { return; }; - let send_result = { - let s = self.shared.borrow(); - s.socket - .as_ref() - .ok_or_else(|| JsValue::from_str("No websocket available")) - .and_then(|socket| { - socket - .send_with_str(&message) - .js_context("Failed to flush queued message") - }) + let send_result: Result<(), JsValue> = { + let state = self.shared.borrow(); + #[expect( + clippy::option_if_let_else, + reason = "map_or_else inverts reading order (None-branch first) for two Result-returning closures" + )] + match state.socket.as_ref() { + Some(socket) => socket + .send_with_str(&message) + .js_context("Failed to flush queued message"), + None => Err(JsValue::from_str("No websocket available")), + } }; if let Err(error) = send_result { warn!("Failed to flush queued websocket message; re-queueing: {:?}", error); - let mut s = self.shared.borrow_mut(); - s.offline_queue.push_front(message); + let mut state = self.shared.borrow_mut(); + state.offline_queue.push_front(message); return; } @@ -581,6 +635,10 @@ impl WsClient { } } + #[expect( + clippy::unused_self, + reason = "kept on &self to mirror the other client lifecycle methods; recorded state lives in localStorage" + )] fn record_offline(&self) { let timestamp = chrono::Utc::now().to_rfc3339(); match store_last_offline_at(×tamp) { @@ -598,11 +656,12 @@ impl WsClient { }; let mut cli_ptr = self.clone(); - let reconnect_closure = Closure::once(Box::new(move || { + let reconnect_box: Box = Box::new(move || { if let Err(error) = cli_ptr.connect() { error!("Reconnect attempt failed: {:?}", error); } - }) as Box); + }); + let reconnect_closure = Closure::once(reconnect_box); match window .set_timeout_with_callback_and_timeout_and_arguments_0(reconnect_closure.as_ref().unchecked_ref(), delay_ms) @@ -618,8 +677,8 @@ impl WsClient { } fn cancel_reconnect(&self) { - let mut s = self.shared.borrow_mut(); - if let Some(timeout_id) = s.reconnect_timeout_id.take() + let mut state = self.shared.borrow_mut(); + if let Some(timeout_id) = state.reconnect_timeout_id.take() && let Some(window) = web_sys::window() { window.clear_timeout_with_handle(timeout_id); @@ -627,6 +686,10 @@ impl WsClient { } } +#[expect( + clippy::multiple_inherent_impl, + reason = "second block holds methods that wasm_bindgen can't export (generics, serde_json::Value)" +)] impl WsClient { pub fn request_list_agents(&self) -> Result<(), JsValue> { let payload = serde_json::to_string(&WsMessage::ListAgents).js_context("Failed to serialize list_agents")?; @@ -639,9 +702,9 @@ impl WsClient { self.send(&payload) } - pub fn send_agent_message( + pub fn send_agent_message>( &self, - to_agent_id: impl Into, + to_agent_id: T, message: serde_json::Value, ) -> Result<(), JsValue> { let payload = serde_json::to_string(&WsMessage::SendAgentMessage { @@ -652,10 +715,10 @@ impl WsClient { self.send(&payload) } - pub fn send_client_event( + pub fn send_client_event, A: Into>( &self, - capability: impl Into, - action: impl Into, + capability: C, + action: A, details: serde_json::Value, ) -> Result<(), JsValue> { let message = WsMessage::ClientEvent { @@ -670,16 +733,16 @@ impl WsClient { // Implement Clone for WsClient (required for closures) impl Clone for WsClient { - fn clone(&self) -> WsClient { - WsClient { + fn clone(&self) -> Self { + Self { config: WsClientConfig { server_url: self.config.server_url.clone(), alive_interval_ms: self.config.alive_interval_ms, max_reconnect_attempts: self.config.max_reconnect_attempts, initial_reconnect_delay_ms: self.config.initial_reconnect_delay_ms, }, - agent_id: self.agent_id.clone(), - shared: self.shared.clone(), + agent_id: Rc::clone(&self.agent_id), + shared: Rc::clone(&self.shared), } } } diff --git a/services/ws/Cargo.toml b/services/ws/Cargo.toml index 2bcb301..29408be 100644 --- a/services/ws/Cargo.toml +++ b/services/ws/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +doctest = false + [dependencies] actix-web = "4" actix-ws = "0.3" @@ -18,3 +21,6 @@ serde_yaml = "0.9" tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } tracing.workspace = true uuid.workspace = true + +[lints] +workspace = true diff --git a/services/ws/src/lib.rs b/services/ws/src/lib.rs index e972981..0c10b03 100644 --- a/services/ws/src/lib.rs +++ b/services/ws/src/lib.rs @@ -1,5 +1,4 @@ use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use actix_web::{Error, HttpRequest, HttpResponse, web}; @@ -10,7 +9,7 @@ use edge_toolkit::ws_server::{AgentRecord, AgentRegistry, PendingDirectMessage, use futures_util::StreamExt as _; use opentelemetry::{ global, - trace::{Span, Tracer}, + trace::{Span, Tracer as _}, }; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tracing::{error, info, warn}; @@ -22,41 +21,38 @@ pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1); pub type AgentSession = UnboundedSender; pub type WsAgentRegistry = AgentRegistry; +// Deserialize using a session-less record type, then convert. +#[derive(serde::Deserialize)] +struct BareRecord { + state: edge_toolkit::ws::AgentConnectionState, + last_known_ip: Option, + #[serde(default)] + pending_direct_messages: BTreeMap, +} + /// Load a registry from disk. Sessions are not persisted, so they are initialised to `None`. pub fn load_registry(path: &std::path::Path) -> Result { - use edge_toolkit::ws::AgentConnectionState; if !path.exists() { - warn!("Registry file {:?} does not exist, starting with empty registry", path); + warn!( + "Registry file {} does not exist, starting with empty registry", + path.display() + ); return Ok(WsAgentRegistry::default()); } let yaml = std::fs::read_to_string(path)?; - // Deserialize using a session-less record type, then convert. - #[derive(serde::Deserialize)] - struct BareRecord { - state: AgentConnectionState, - last_known_ip: Option, - #[serde(default)] - pending_direct_messages: BTreeMap, - } let bare: BTreeMap = serde_yaml::from_str(&yaml)?; let agents = bare .into_iter() - .map(|(id, r)| { + .map(|(id, record)| { ( id, - AgentRecord { - state: r.state, - last_known_ip: r.last_known_ip, - session: None, - pending_direct_messages: r.pending_direct_messages, - }, + AgentRecord::new(record.state, record.last_known_ip, None) + .with_pending_direct_messages(record.pending_direct_messages), ) }) .collect(); - info!("Loaded registry from {:?}", path); - Ok(WsAgentRegistry { - agents: Arc::new(Mutex::new(agents)), - }) + info!("Loaded registry from {}", path.display()); + Ok(WsAgentRegistry::from_agents(agents)) } struct Connection { @@ -69,6 +65,7 @@ struct Connection { } impl Connection { + #[expect(clippy::single_call_fn, reason = "inherent constructor; used once by ws_handler")] fn new(registry: WsAgentRegistry, client_ip: String, session: Session, outbox: AgentSession) -> Self { info!("New WebSocket connection for client IP {}", client_ip); Self { @@ -183,17 +180,17 @@ impl Connection { "Direct message {} delivered from {} to {}", message_id, from_agent_id, to_agent_id ); - let _ = recipient.send(WsMessage::AgentMessage { + drop(recipient.send(WsMessage::AgentMessage { message_id: message_id.clone(), from_agent_id, scope: MessageScope::Direct, server_received_at: pending.server_received_at, message: pending.message, - }); + })); self.send_status( Some(message_id), MessageDeliveryStatus::Delivered, - format!("message delivered to agent {}", to_agent_id), + format!("message delivered to agent {to_agent_id}"), ) .await; } else { @@ -204,7 +201,7 @@ impl Connection { self.send_status( Some(message_id), MessageDeliveryStatus::Queued, - format!("message queued for agent {}", to_agent_id), + format!("message queued for agent {to_agent_id}"), ) .await; } @@ -212,16 +209,18 @@ impl Connection { } /// Returns `false` when the connection should terminate. + #[expect( + clippy::cognitive_complexity, + clippy::too_many_lines, + reason = "single dispatcher for inbound WsMessage variants; splitting scatters handlers into trivial helpers" + )] async fn handle_inbound(&mut self, msg: AggregatedMessage) -> bool { match msg { AggregatedMessage::Ping(ping) => { self.mark_activity(); - let _ = self.session.pong(&ping).await; - } - AggregatedMessage::Pong(_) => { - self.mark_activity(); + let _pong: Result<(), actix_ws::Closed> = self.session.pong(&ping).await; } - AggregatedMessage::Binary(_) => { + AggregatedMessage::Pong(_) | AggregatedMessage::Binary(_) => { self.mark_activity(); } AggregatedMessage::Close(reason) => { @@ -234,7 +233,7 @@ impl Connection { let tracer = global::tracer("ws-server"); let mut span = tracer.start("ws.disconnect"); span.end(); - let _ = self.session.clone().close(reason).await; + let _closed: Result<(), actix_ws::Closed> = self.session.clone().close(reason).await; return false; } AggregatedMessage::Text(text) => { @@ -299,8 +298,13 @@ impl Connection { return true; } - if !self.registry.list_agents().iter().any(|a| a.agent_id == to_agent_id) { - self.send_invalid(None, format!("unknown target agent {}", to_agent_id)) + if !self + .registry + .list_agents() + .iter() + .any(|agent| agent.agent_id == to_agent_id) + { + self.send_invalid(None, format!("unknown target agent {to_agent_id}")) .await; span.end(); return true; @@ -326,13 +330,13 @@ impl Connection { "Broadcast message {} from {} to {}", message_id, from_agent_id, recipient_id ); - let _ = recipient.send(WsMessage::AgentMessage { + drop(recipient.send(WsMessage::AgentMessage { message_id: message_id.clone(), from_agent_id: from_agent_id.clone(), scope: MessageScope::Broadcast, server_received_at: server_received_at.clone(), message: message.clone(), - }); + })); } self.send_status( Some(message_id), @@ -362,11 +366,11 @@ impl Connection { ) .await; if let Some(sender) = sender_session { - let _ = sender.send(WsMessage::MessageStatus { + drop(sender.send(WsMessage::MessageStatus { message_id: Some(message_id), status: MessageDeliveryStatus::Acknowledged, - detail: format!("agent {} acknowledged receipt", recipient_agent_id), - }); + detail: format!("agent {recipient_agent_id} acknowledged receipt"), + })); } } Err(error) => { @@ -383,12 +387,15 @@ impl Connection { if capability == "video_cv" && action == "inference" { let detected_class = details .get("detected_class") - .and_then(|v| v.as_str()) + .and_then(|value| value.as_str()) .unwrap_or("unknown"); - let confidence = details.get("confidence").and_then(|v| v.as_f64()).unwrap_or_default(); + let confidence = details + .get("confidence") + .and_then(serde_json::Value::as_f64) + .unwrap_or_default(); let processed_at = details .get("processed_at") - .and_then(|v| v.as_str()) + .and_then(|value| value.as_str()) .unwrap_or("unknown"); info!( "Video inference received from {}: class={} confidence={:.4} processed_at={}", @@ -412,15 +419,15 @@ impl Connection { span.end(); return true; }; - let url = format!("/storage/{}/{}", agent_id, filename); + let url = format!("/storage/{agent_id}/{filename}"); info!("Agent {} requested storage URL for {}: {}", agent_id, filename, url); self.send_json(&WsMessage::Response { - message: format!("PUT to {}", url), + message: format!("PUT to {url}"), }) .await; } WsMessage::FetchFile { agent_id, filename } => { - let url = format!("/storage/{}/{}", agent_id, filename); + let url = format!("/storage/{agent_id}/{filename}"); info!( "Agent {} requested fetch URL for {}/{}", self.current_agent_id(), @@ -428,7 +435,7 @@ impl Connection { filename ); self.send_json(&WsMessage::Response { - message: format!("GET from {}", url), + message: format!("GET from {url}"), }) .await; } @@ -457,6 +464,12 @@ impl Connection { true } + #[expect( + clippy::cognitive_complexity, + clippy::future_not_send, + clippy::integer_division_remainder_used, + reason = "actix-ws AggregatedMessageStream is Rc-backed and !Send; tokio::select! macro uses % internally" + )] async fn run(mut self, mut stream: AggregatedMessageStream, mut outbound: UnboundedReceiver) { let tracer = global::tracer("ws-server"); let mut connect_span = tracer.start("ws.connect"); @@ -499,11 +512,10 @@ impl Connection { self.current_agent_id(), idle_for ); - let _ = self.session.clone().close(Some(CloseReason { + let _closed: Result<(), actix_ws::Closed> = self.session.clone().close(Some(CloseReason { code: CloseCode::Policy, description: Some(format!( - "connection timed out after {:?} of inactivity", - CONNECTION_TIMEOUT + "connection timed out after {CONNECTION_TIMEOUT:?} of inactivity" )), })).await; break; @@ -524,6 +536,10 @@ impl Connection { } } +#[expect( + clippy::future_not_send, + reason = "actix-web HttpRequest and Payload are Rc-backed and !Send; handler runs on actix's single thread" +)] pub async fn ws_handler( req: HttpRequest, body: web::Payload, @@ -548,7 +564,7 @@ pub async fn ws_handler( let (tx, rx) = mpsc::unbounded_channel::(); let conn = Connection::new(registry.get_ref().clone(), client_ip, session, tx); - actix_web::rt::spawn(async move { + let _join = actix_web::rt::spawn(async move { conn.run(stream, rx).await; }); @@ -557,5 +573,5 @@ pub async fn ws_handler( } pub fn configure(cfg: &mut web::ServiceConfig) { - cfg.route("/ws", web::get().to(ws_handler)); + let _routed = cfg.route("/ws", web::get().to(ws_handler)); } diff --git a/utilities/cli/Cargo.toml b/utilities/cli/Cargo.toml index cf84b96..e12f877 100644 --- a/utilities/cli/Cargo.toml +++ b/utilities/cli/Cargo.toml @@ -6,6 +6,14 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lib] +doctest = false + +[[bin]] +doctest = false +name = "et-cli" +path = "src/main.rs" + [dependencies] clap.workspace = true edge-toolkit.workspace = true @@ -18,3 +26,6 @@ toml.workspace = true [dev-dependencies] tempfile = "3" + +[lints] +workspace = true diff --git a/utilities/cli/src/deployment_types/docker_compose.rs b/utilities/cli/src/deployment_types/docker_compose.rs index 302128a..27239f8 100644 --- a/utilities/cli/src/deployment_types/docker_compose.rs +++ b/utilities/cli/src/deployment_types/docker_compose.rs @@ -98,7 +98,7 @@ pub fn generate_docker_compose_deployment(cluster: &ClusterInput, output_dir: &P pub fn docker_image_module_paths(module_names: &[String]) -> Result, CliError> { let project_root = edge_toolkit::config::get_project_root(); let ws_server_dir = project_root.join("services/ws-server"); - let mut paths = Vec::with_capacity(module_names.len() + 2); + let mut paths = Vec::with_capacity(module_names.len().saturating_add(2)); paths.push("/app/services/ws-server/static".to_string()); paths.push("/app/services/ws-wasm-agent".to_string()); let registry = module_registry(&project_root, &ws_server_dir); @@ -250,8 +250,9 @@ impl ComposeRenderer { ComposeValue::WrappedDoubleQuoted(parts) => { if let Some((first, rest)) = parts.split_first() { self.push_line(3, &format!("{key}: \"{first},\\")); + let last_index = rest.len().saturating_sub(1); for (index, part) in rest.iter().enumerate() { - let suffix = if index + 1 == rest.len() { "\"" } else { ",\\" }; + let suffix = if index == last_index { "\"" } else { ",\\" }; self.push_line(4, &format!("{part}{suffix}")); } } else { diff --git a/utilities/cli/src/deployment_types/mise.rs b/utilities/cli/src/deployment_types/mise.rs index af7b1a4..e1727d1 100644 --- a/utilities/cli/src/deployment_types/mise.rs +++ b/utilities/cli/src/deployment_types/mise.rs @@ -18,7 +18,7 @@ pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Re let module_paths = scenario_module_paths(&ws_server_dir, &module_names)?; let module_paths_lines = module_paths .iter() - .map(|p| format!(" {p}")) + .map(|path| format!(" {path}")) .collect::>() .join(",\\\n"); let ws_server_run = format!("export MODULES_PATHS=\"\\\n{module_paths_lines}\"\ncargo run\n"); @@ -27,21 +27,24 @@ pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Re let mut root = Table::new(); let mut tasks = Table::new(); - tasks.insert( + let _previous: Option = tasks.insert( "openobserve".to_string(), Value::Table(mise_task( Some("o2"), None, Some(&workspace_rel), Some(&format!( - "docker run --rm -it --name openobserve -p 5080:5080 --env-file {} openobserve/openobserve:v0.70.3", + concat!( + "docker run --rm -it --name openobserve -p 5080:5080 ", + "--env-file {} openobserve/openobserve:v0.70.3", + ), openobserve_env_file_rel )), None, None, )), ); - tasks.insert( + let _previous: Option = tasks.insert( "ws-server".to_string(), Value::Table(mise_task( None, @@ -52,7 +55,7 @@ pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Re Some(mise_env()), )), ); - tasks.insert( + let _previous: Option = tasks.insert( "generated-scenario".to_string(), Value::Table(mise_task( None, @@ -63,7 +66,7 @@ pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Re None, )), ); - tasks.insert( + let _previous: Option = tasks.insert( "open-o2".to_string(), Value::Table(mise_task( None, @@ -75,9 +78,9 @@ pub fn generate_mise_deployment(cluster: &ClusterInput, output_dir: &Path) -> Re )), ); - root.insert("tasks".to_string(), Value::Table(tasks)); + let _previous: Option = root.insert("tasks".to_string(), Value::Table(tasks)); - let content = format_mise_toml(toml::to_string(&Value::Table(root))?, openobserve_env_file_rel); + let content = format_mise_toml(&toml::to_string(&Value::Table(root))?, openobserve_env_file_rel); fs::write(&output_path, content)?; Ok(()) @@ -100,11 +103,11 @@ pub fn scenario_module_paths(ws_server_dir: &Path, module_names: &[String]) -> R Ok(paths) } -fn format_mise_toml(content: String, openobserve_env_file_rel: &str) -> String { +fn format_mise_toml(content: &str, openobserve_env_file_rel: &str) -> String { let openobserve_run = format!( concat!( - "run = \"docker run --rm -it --name openobserve -p 5080:5080 --env-file {} ", - "openobserve/openobserve:v0.70.3\"" + "run = \"docker run --rm -it --name openobserve -p 5080:5080 ", + "--env-file {0} openobserve/openobserve:v0.70.3\"", ), openobserve_env_file_rel ); @@ -131,32 +134,32 @@ fn mise_task( ) -> Table { let mut task = Table::new(); if let Some(alias) = alias { - task.insert("alias".to_string(), Value::String(alias.to_string())); + let _previous: Option = task.insert("alias".to_string(), Value::String(alias.to_string())); } if let Some(description) = description { - task.insert("description".to_string(), Value::String(description.to_string())); + let _previous: Option = task.insert("description".to_string(), Value::String(description.to_string())); } if let Some(dir) = dir { - task.insert("dir".to_string(), Value::String(dir.to_string())); + let _previous: Option = task.insert("dir".to_string(), Value::String(dir.to_string())); } if let Some(run) = run { - task.insert("run".to_string(), Value::String(run.to_string())); + let _previous: Option = task.insert("run".to_string(), Value::String(run.to_string())); } if let Some(extra) = extra { for (key, value) in extra { - task.insert(key, value); + let _previous: Option = task.insert(key, value); } } if let Some(env) = env { - task.insert("env".to_string(), Value::Table(env)); + let _previous: Option = task.insert("env".to_string(), Value::Table(env)); } task } fn mise_env() -> Table { let mut env = Table::new(); - env.insert("OTLP_AUTH_PASSWORD".to_string(), Value::String("1234".to_string())); - env.insert( + let _previous: Option = env.insert("OTLP_AUTH_PASSWORD".to_string(), Value::String("1234".to_string())); + let _previous: Option = env.insert( "OTLP_AUTH_USERNAME".to_string(), Value::String("root@example.com".to_string()), ); @@ -165,7 +168,7 @@ fn mise_env() -> Table { fn mise_depends(depends: [&str; N]) -> Table { let mut extra = Table::new(); - extra.insert( + let _previous: Option = extra.insert( "depends".to_string(), Value::Array( depends diff --git a/utilities/cli/src/error.rs b/utilities/cli/src/error.rs index e593ef7..5c4c5b1 100644 --- a/utilities/cli/src/error.rs +++ b/utilities/cli/src/error.rs @@ -2,12 +2,15 @@ use std::path::{Path, PathBuf}; use thiserror::Error; -/// Errors returned by `et-cli` operations. Variants carry the path or -/// value they failed on so users can see *what* went wrong, not just the -/// underlying error text. `Io` is `#[from]`-forwarded — the inner -/// `std::io::Error` arrives from `fs_err`, which already embeds the -/// failing path in its `Display`, so we don't need a path field here. +/// Errors returned by `et-cli` operations. +/// +/// Variants carry the path or value they failed on so users can see *what* +/// went wrong, not just the underlying error text. `Io` is +/// `#[from]`-forwarded — the inner `std::io::Error` arrives from `fs_err`, +/// which already embeds the failing path in its `Display`, so we don't need +/// a path field here. #[derive(Debug, Error)] +#[non_exhaustive] pub enum CliError { #[error(transparent)] Io(#[from] std::io::Error), @@ -81,7 +84,7 @@ pub enum CliError { /// Parse `src` as TOML into `T`, attaching `path` to the error on failure. /// Replaces a `.map_err(...)` at every call site. -pub fn parse_toml(path: impl AsRef, src: &str) -> Result +pub fn parse_toml>(path: P, src: &str) -> Result where T: for<'de> serde::Deserialize<'de>, { @@ -95,7 +98,7 @@ where } /// Parse `src` as JSON into `T`, attaching `path` to the error on failure. -pub fn parse_json(path: impl AsRef, src: &str) -> Result +pub fn parse_json>(path: P, src: &str) -> Result where T: for<'de> serde::Deserialize<'de>, { diff --git a/utilities/cli/src/lib.rs b/utilities/cli/src/lib.rs index e5be400..82ae9b7 100644 --- a/utilities/cli/src/lib.rs +++ b/utilities/cli/src/lib.rs @@ -1,3 +1,8 @@ +#![expect( + clippy::single_call_fn, + reason = "et-cli decomposes scenario generation into named pipeline stages; each invoked once for readability" +)] + use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::ffi::OsString; use std::path::{Component, Path, PathBuf}; @@ -17,6 +22,10 @@ pub use self::deployment_types::{ pub use self::error::CliError; pub use self::module_package_json::generate_module_package_json; +#[expect( + clippy::exhaustive_enums, + reason = "OutputType enumerates the supported deployment formats; downstream code matches exhaustively" +)] #[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq, ValueEnum)] #[serde(rename_all = "lowercase")] pub enum OutputType { @@ -29,6 +38,7 @@ pub enum OutputType { impl OutputType { pub const ALL: &'static [Self] = &[Self::Mise, Self::DockerCompose]; + #[must_use] pub const fn output_file_name(self) -> &'static str { match self { Self::Mise => "mise.toml", @@ -46,6 +56,7 @@ fn generated_output_files(output_types: &[OutputType]) -> Vec<&'static str> { } #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub struct DeploymentSummary { pub cluster_name: String, pub agent_templates: usize, @@ -53,6 +64,7 @@ pub struct DeploymentSummary { } #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub struct RegeneratedScenario { pub input_file: PathBuf, pub output_dir: PathBuf, @@ -60,6 +72,7 @@ pub struct RegeneratedScenario { } #[derive(Debug, Clone, Default, Deserialize)] +#[non_exhaustive] pub struct PackageJson { pub name: Option, #[serde(default)] @@ -113,6 +126,7 @@ struct CargoWsModule { } #[derive(Debug, Clone)] +#[non_exhaustive] pub struct ModuleRegistryEntry { pub mise_path: String, pub docker_path: String, @@ -164,10 +178,7 @@ pub fn regenerate_verification( let cluster = load_cluster_input(&input_file)?; let module_names = cluster_module_names(&cluster); - let output_types = match &output_type { - Some(output_type) => std::slice::from_ref(output_type), - None => OutputType::ALL, - }; + let output_types = output_type.as_ref().map_or(OutputType::ALL, std::slice::from_ref); generate_deployment_outputs(&cluster, &output_dir, output_types)?; let summary = deployment_summary(cluster.cluster_name, cluster.agents.len(), module_names); @@ -191,7 +202,11 @@ pub fn output_type_from_input(value: &str) -> Result { } } -fn deployment_summary(cluster_name: String, agent_templates: usize, module_names: Vec) -> DeploymentSummary { +const fn deployment_summary( + cluster_name: String, + agent_templates: usize, + module_names: Vec, +) -> DeploymentSummary { DeploymentSummary { cluster_name, agent_templates, @@ -243,7 +258,12 @@ fn discover_verification_scenarios(verification_root: &Path) -> Result>() .join(", "); format!( @@ -355,6 +375,7 @@ fn generated_run_instructions(output_type: OutputType) -> String { } } +#[must_use] pub fn module_registry(project_root: &Path, ws_server_dir: &Path) -> BTreeMap { let mut registry = BTreeMap::new(); @@ -419,9 +440,9 @@ fn register_modules_under( .unwrap_or_default(), }; - registry.insert(directory_name.to_string(), entry.clone()); + let _previous: Option = registry.insert(directory_name.to_string(), entry.clone()); if let Some(package_name) = package.and_then(|package| package.name) { - registry.insert(package_name, entry); + let _previous: Option = registry.insert(package_name, entry); } } } @@ -432,7 +453,7 @@ fn register_external_module( mise_path: &str, docker_path: &str, ) { - registry.insert( + let _previous: Option = registry.insert( package_name.to_string(), ModuleRegistryEntry { mise_path: mise_path.to_string(), @@ -442,6 +463,7 @@ fn register_external_module( ); } +#[must_use] pub fn module_package_json(module_path: &Path) -> Option { let pkg_package = read_package_json(&module_path.join("pkg/package.json")); let root_package = read_package_json(&module_path.join("package.json")); @@ -523,6 +545,7 @@ where Ok(paths) } +#[must_use] pub fn absolute_from(base: &Path, path: &Path) -> PathBuf { if path.is_absolute() { normalize_path(path) @@ -531,6 +554,7 @@ pub fn absolute_from(base: &Path, path: &Path) -> PathBuf { } } +#[must_use] pub fn relative_path_from(from_dir: &Path, target: &Path) -> PathBuf { let from_components = normal_components(&normalize_path(from_dir)); let target_components = normal_components(&normalize_path(target)); @@ -559,7 +583,7 @@ fn normal_components(path: &Path) -> Vec { path.components() .filter_map(|component| match component { Component::Normal(value) => Some(value.to_os_string()), - _ => None, + Component::Prefix(_) | Component::RootDir | Component::CurDir | Component::ParentDir => None, }) .collect() } @@ -572,7 +596,7 @@ fn normalize_path(path: &Path) -> PathBuf { Component::RootDir => normalized.push(Path::new("/")), Component::CurDir => {} Component::ParentDir => { - normalized.pop(); + let _popped = normalized.pop(); } Component::Normal(value) => normalized.push(value), } @@ -580,6 +604,7 @@ fn normalize_path(path: &Path) -> PathBuf { normalized } +#[must_use] pub fn cluster_module_names(cluster: &ClusterInput) -> Vec { cluster .agents diff --git a/utilities/cli/src/main.rs b/utilities/cli/src/main.rs index 2b64028..726e7c5 100644 --- a/utilities/cli/src/main.rs +++ b/utilities/cli/src/main.rs @@ -1,3 +1,5 @@ +#![expect(clippy::print_stdout, reason = "CLI tool: println! is the intended UX")] + use std::path::PathBuf; use clap::{Parser, Subcommand}; @@ -41,26 +43,32 @@ fn main() -> Result<(), CliError> { output_dir, output_type, } => { - println!("Reading cluster input from: {:?}", input_file); + println!("Reading cluster input from: {}", input_file.display()); let summary = generate_deployment(input_file, output_dir, Some(*output_type))?; println!( - "Scenario summary: input={:?}, cluster={}, agents={}, resources={}", - input_file, + "Scenario summary: input={}, cluster={}, agents={}, resources={}", + input_file.display(), summary.cluster_name, summary.agent_templates, summary.module_names.join(", ") ); - println!("Generated: {:?}", output_dir.join(output_type.output_file_name())); - println!("See the generated README.md in {:?} for instructions.", output_dir); + println!( + "Generated: {}", + output_dir.join(output_type.output_file_name()).display() + ); + println!( + "See the generated README.md in {} for instructions.", + output_dir.display() + ); } Commands::RegenVerification { verification_root } => { - println!("Reading verification scenarios from: {:?}", verification_root); + println!("Reading verification scenarios from: {}", verification_root.display()); let regenerated = regenerate_verification(verification_root, None)?; for scenario in ®enerated { println!( - "Regenerated: input={:?}, output={:?}, cluster={}, agents={}, resources={}", - scenario.input_file, - scenario.output_dir, + "Regenerated: input={}, output={}, cluster={}, agents={}, resources={}", + scenario.input_file.display(), + scenario.output_dir.display(), scenario.summary.cluster_name, scenario.summary.agent_templates, scenario.summary.module_names.join(", ") diff --git a/utilities/cli/src/module_package_json/mod.rs b/utilities/cli/src/module_package_json/mod.rs index dde2145..85439bb 100644 --- a/utilities/cli/src/module_package_json/mod.rs +++ b/utilities/cli/src/module_package_json/mod.rs @@ -1,3 +1,8 @@ +#![expect( + unused_results, + reason = "serde_json::Map::insert discards the prior Value at each key, which is the intended overwrite" +)] + use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -82,7 +87,10 @@ struct WorkspacePackage { enum MaybeInherited { Direct(String), Workspace { - #[allow(dead_code)] + #[expect( + dead_code, + reason = "field exists only to make serde's untagged deserializer pick this variant" + )] workspace: bool, }, } @@ -111,22 +119,25 @@ pub fn generate_module_package_json(module_dir: &Path) -> Result Result { let pyproject_path = module_dir.join("pyproject.toml"); let pyproject: Pyproject = read_toml(&pyproject_path)?; - let p = &pyproject.project; - let ws_module = pyproject.tool.map(|t| t.ws_module).unwrap_or_default(); + let project = &pyproject.project; + let ws_module = pyproject.tool.map(|tool| tool.ws_module).unwrap_or_default(); let pkg_dir = module_dir.join("pkg"); let kind = detect_python_kind(module_dir); - let main = resolve_main(&pkg_dir, &p.name, kind, ws_module.main.as_deref())?; + let main = resolve_main(&pkg_dir, &project.name, kind, ws_module.main.as_deref())?; let mut pkg = Map::from_iter([ - ("name".to_string(), json!(p.name)), + ("name".to_string(), json!(project.name)), ("type".to_string(), json!("module")), - ("description".to_string(), json!(p.description.as_deref().unwrap_or(""))), - ("version".to_string(), json!(p.version)), - ("license".to_string(), json!(p.license.as_deref().unwrap_or(""))), + ( + "description".to_string(), + json!(project.description.as_deref().unwrap_or("")), + ), + ("version".to_string(), json!(project.version)), + ("license".to_string(), json!(project.license.as_deref().unwrap_or(""))), ("main".to_string(), json!(main)), ]); - if let Some(repo) = project_repository(&p.urls) { + if let Some(repo) = project_repository(&project.urls) { pkg.insert("repository".to_string(), repository_json(repo)); } if !ws_module.dependencies.is_empty() { @@ -166,8 +177,8 @@ fn package_json_from_cargo(module_dir: &Path, out_path: &Path) -> Result Result Result, workspace: Option<&str>) -> Option { match direct { - Some(MaybeInherited::Direct(s)) => Some(s.clone()), + Some(MaybeInherited::Direct(value)) => Some(value.clone()), Some(MaybeInherited::Workspace { .. }) => workspace.map(str::to_string), None => None, } @@ -250,10 +261,10 @@ enum ModuleKind { } impl ModuleKind { - fn extension(self) -> &'static str { + const fn extension(self) -> &'static str { match self { - ModuleKind::Wasi => "wasm", - ModuleKind::Js => "js", + Self::Wasi => "wasm", + Self::Js => "js", } } } diff --git a/utilities/cli/tests/module_package_json.rs b/utilities/cli/tests/module_package_json.rs index ac13ead..5fbb16d 100644 --- a/utilities/cli/tests/module_package_json.rs +++ b/utilities/cli/tests/module_package_json.rs @@ -1,4 +1,9 @@ #![cfg(test)] +#![expect( + clippy::unwrap_used, + clippy::indexing_slicing, + reason = "test code: setup failures and missing JSON fields should fail the test" +)] use std::fs; diff --git a/utilities/cli/tests/scenario_generation.rs b/utilities/cli/tests/scenario_generation.rs index 9e3b4b5..3fc4161 100644 --- a/utilities/cli/tests/scenario_generation.rs +++ b/utilities/cli/tests/scenario_generation.rs @@ -1,4 +1,9 @@ #![cfg(test)] +#![expect( + clippy::unwrap_used, + clippy::indexing_slicing, + reason = "test code: setup failures and missing JSON fields should fail the test" +)] use std::fs; diff --git a/utilities/onnx/Cargo.toml b/utilities/onnx/Cargo.toml index 6f0130c..e865c52 100644 --- a/utilities/onnx/Cargo.toml +++ b/utilities/onnx/Cargo.toml @@ -6,6 +6,14 @@ edition.workspace = true license.workspace = true repository.workspace = true +[[bin]] +doctest = false +name = "et-onnx" +path = "src/main.rs" + [dependencies] clap.workspace = true onnx-extractor.workspace = true + +[lints] +workspace = true diff --git a/utilities/onnx/src/main.rs b/utilities/onnx/src/main.rs index 42263d6..c6cb421 100644 --- a/utilities/onnx/src/main.rs +++ b/utilities/onnx/src/main.rs @@ -10,11 +10,12 @@ struct Args { filename: PathBuf, } -fn main() { +fn main() -> Result<(), Box> { let args = Args::parse(); - let model = onnx_extractor::OnnxModel::load_from_file(&args.filename.to_string_lossy()).unwrap(); + let model = onnx_extractor::OnnxModel::load_from_file(&args.filename.to_string_lossy())?; model.print_summary(); model.print_model_info(); + Ok(()) }