diff --git a/.mise/config.coverage.toml b/.mise/config.coverage.toml index 0f7c3485..8e0afebc 100644 --- a/.mise/config.coverage.toml +++ b/.mise/config.coverage.toml @@ -47,6 +47,29 @@ coreutils rm -rf target/debug target/release # `--sh` is the current flag (the old `--export-prefix` alias is deprecated). This sets the rustc wrapper + # LLVM_PROFILE_FILE + profile dir so nextest and the regens instrument into one profile set that `report` merges. eval "$(cargo llvm-cov show-env --sh)" +# nextest SIGKILLs the spawned et-ws-pyo3-runner (services/ws-pyo3-runner/tests/modules.rs) after each exchange. +# LLVM flushes a process's counters only at exit, so a SIGKILLed process contributes nothing: the runner's async +# run/drive/worker paths reported 0% while its in-process initialize() was covered. Continuous mode (%c) memory-maps +# the counter file so increments persist live and survive the kill. %c is incompatible with the merge-pool (%Nm) +# specifier show-env emits, so use a per-process (%p) continuous file in the same profile dir -- `cargo llvm-cov +# report` globs every *.profraw there regardless of name. On Linux continuous mode additionally needs relocatable +# counters (`-Cllvm-args=-runtime-counter-relocation`). The wrapper flags env is CARGO_ENCODED_RUSTFLAGS format +# (ASCII unit separator 0x1f between flags), not space-separated -- a space append glues the llvm-arg onto the +# last existing flag and rustc rejects `--cfg=coverage_nightly -Cllvm-args=...` as one invalid --cfg value. +cov_dir="$(coreutils dirname "$LLVM_PROFILE_FILE")" +export LLVM_PROFILE_FILE="$cov_dir/core-cont-%p%c.profraw" +if [ "$(coreutils uname -s)" = "Linux" ]; then + reloc="-Cllvm-args=-runtime-counter-relocation" + flags="${__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS:-}" + # Append the reloc flag with a TOML-injected unit separator (ASCII 31). + # Bash dollar-quote hex is not a valid TOML escape, so the separator has to be introduced by the + # TOML parser into the task body rather than written as a shell escape. + if [ -n "$flags" ]; then + export __CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS="${flags}\u001f${reloc}" + else + export __CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS="${reloc}" + fi +fi # The --features flags compile each runner's coverage code, off by default so non-coverage builds omit it. # et-ws-web-runner/coverage adds the browser-module capture (shims + the __et_capture_coverage PUT); # et-ws-wasi-runner/coverage adds the guest `/cov` preopen. ET_TEST_COVERAGE (set by the coverage job) still diff --git a/config/otelcol-hostmetrics.yaml b/config/otelcol-hostmetrics.yaml index a2a9eead..63897af0 100644 --- a/config/otelcol-hostmetrics.yaml +++ b/config/otelcol-hostmetrics.yaml @@ -2,6 +2,13 @@ # Scrapes host resource usage and ships it to the local OpenObserve (o2) over OTLP/HTTP -- the same OTLP-to-o2 # path the ws-server uses. receivers: + # GPU (NVIDIA) is not collected here. + # Use the dedicated `o2-nvidia` / `o2-nvidia-native` mise tasks (native nvidia_gpu_exporter + prometheus + # scrape). DCGM remains available if you prefer in-collector GPU metrics: it needs `--gpus all` on the docker + # run and a DCGM-capable host; uncomment this receiver and add `dcgm` to the metrics pipeline receivers below + # to enable it. + # dcgm: + # collection_interval: 10s hostmetrics: # Read the host's /proc and /sys rather than the collector container's own. # `/hostfs` is the host root the task bind-mounts read-only. @@ -15,13 +22,6 @@ receivers: filesystem: {} network: {} paging: {} - # GPU (NVIDIA) is not collected here. - # Use the dedicated `o2-nvidia` / `o2-nvidia-native` mise tasks (native nvidia_gpu_exporter + prometheus - # scrape). DCGM remains available if you prefer in-collector GPU metrics: it needs `--gpus all` on the docker - # run and a DCGM-capable host; uncomment this receiver and add `dcgm` to the metrics pipeline receivers below - # to enable it. - # dcgm: - # collection_interval: 10s extensions: # o2 ingests OTLP over HTTP with HTTP Basic auth; the credentials come from config/o2.env (passed via --env-file). diff --git a/config/ryl.yaml b/config/ryl.yaml index 9e7a3dbc..8aa543c8 100644 --- a/config/ryl.yaml +++ b/config/ryl.yaml @@ -3,6 +3,9 @@ extends: default rules: + # A misindented comment reads as attached to the wrong block, so treat it as an error, not a warning. + comments-indentation: + level: error # dprint (the YAML formatter) writes one space before an inline `#`. # yamllint defaults to two. Defer to dprint so the two can't fight. comments: diff --git a/libs/path/tests/find.rs b/libs/path/tests/find.rs index 08a4f005..a9653f85 100644 --- a/libs/path/tests/find.rs +++ b/libs/path/tests/find.rs @@ -1,9 +1,20 @@ #![cfg(test)] -use et_path::find_project_root; +use et_path::{find_project_root, find_project_root_from_manifest}; use fs_err as fs; use tempfile::tempdir; +#[test] +fn manifest_dir_resolves_to_an_existing_root() { + // cargo sets `CARGO_MANIFEST_DIR` in the test process env, so the helper reads this crate's + // directory and walks up to the workspace root (which carries project-root markers). + let root = find_project_root_from_manifest(); + assert!( + root.is_dir(), + "resolved project root {root:?} should be an existing directory" + ); +} + #[test] fn finds_marker_in_an_ancestor() { let root = tempdir().unwrap(); diff --git a/libs/web/src/lib.rs b/libs/web/src/lib.rs index 2f93885b..3c898e45 100644 --- a/libs/web/src/lib.rs +++ b/libs/web/src/lib.rs @@ -6,6 +6,14 @@ pub use self::error::{JsCastExt, JsFunctionExt, JsPromiseExt, JsResultExt}; pub const SENSOR_PERMISSION_GRANTED: &str = "granted"; +/// Discard `value`, marking a `Result` (or other `#[must_use]`) as intentionally ignored. +/// +/// The workspace denies `let_underscore*` and `unused_results`, and `DeepSource`'s RS-E1021 flags `drop()` on a +/// non-`Drop` type (e.g. `Result`), so neither `let _ = expr` nor `drop(expr)` is available for discarding one. +/// Passing the value here consumes it -- satisfying `must_use` / `unused_results` -- via neither. Intended for +/// best-effort JS DOM calls in `()`-returning closures and event handlers where the error is deliberately dropped. +pub fn ignore(_value: T) {} + /// Return this module's raw minicov coverage buffer (a `.profraw`), or empty on failure. /// /// Present only in the `coverage` build. `wasm-bindgen` collects this export into every dependent browser diff --git a/services/ws-modules/audio1/src/lib.rs b/services/ws-modules/audio1/src/lib.rs index e3730511..0ade96fe 100644 --- a/services/ws-modules/audio1/src/lib.rs +++ b/services/ws-modules/audio1/src/lib.rs @@ -77,7 +77,7 @@ thread_local! { #[wasm_bindgen(start)] pub fn init() { - drop(tracing_wasm::try_set_as_global_default()); + et_web::ignore(tracing_wasm::try_set_as_global_default()); info!("audio-capture module initialized"); } @@ -126,7 +126,7 @@ pub async fn run() -> Result<(), JsValue> { let stop_callback = Closure::once_into_js(move || { if is_running() { log("workflow finished automatically after 5 seconds"); - drop(stop()); + et_web::ignore(stop()); } }); let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; @@ -139,7 +139,7 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - drop(set_module_status(&format!("audio-capture: error\n{message}"))); + et_web::ignore(set_module_status(&format!("audio-capture: error\n{message}"))); log(&format!("error: {message}")); } @@ -204,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 || { - drop(resolve.call0(&JsValue::NULL)); + et_web::ignore(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - drop(reject.call1(&JsValue::NULL, &error)); + et_web::ignore(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/bluetooth/src/lib.rs b/services/ws-modules/bluetooth/src/lib.rs index 4c50c8a8..75dc4e45 100644 --- a/services/ws-modules/bluetooth/src/lib.rs +++ b/services/ws-modules/bluetooth/src/lib.rs @@ -96,7 +96,7 @@ impl BluetoothAccess { #[wasm_bindgen(start)] pub fn init() { - drop(tracing_wasm::try_set_as_global_default()); + et_web::ignore(tracing_wasm::try_set_as_global_default()); info!("bluetooth module initialized"); } @@ -146,7 +146,7 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - drop(set_module_status(&format!("bluetooth: error\n{message}"))); + et_web::ignore(set_module_status(&format!("bluetooth: error\n{message}"))); log(&format!("error: {message}")); } @@ -197,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 || { - drop(resolve.call0(&JsValue::NULL)); + et_web::ignore(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - drop(reject.call1(&JsValue::NULL, &error)); + et_web::ignore(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/comm1/src/lib.rs b/services/ws-modules/comm1/src/lib.rs index 3e67fa67..2e5771d8 100644 --- a/services/ws-modules/comm1/src/lib.rs +++ b/services/ws-modules/comm1/src/lib.rs @@ -133,7 +133,7 @@ fn handle_incoming_message( "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)); + let _status = set_module_status(&line); } ServerMessage::MessageStatus { message_id, @@ -142,12 +142,12 @@ fn handle_incoming_message( } => { 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)); + let _status = set_module_status(&line); } ServerMessage::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)); + let _status = set_module_status(&line); } ServerMessage::ConnectAck { .. } | ServerMessage::Response { .. } @@ -192,13 +192,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 || { - drop(resolve.call0(&JsValue::NULL)); + let _resolved = resolve.call0(&JsValue::NULL); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - drop(reject.call1(&JsValue::NULL, &error)); + let _rejected = reject.call1(&JsValue::NULL, &error); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/data1/src/lib.rs b/services/ws-modules/data1/src/lib.rs index 0798a0cb..c73e4f31 100644 --- a/services/ws-modules/data1/src/lib.rs +++ b/services/ws-modules/data1/src/lib.rs @@ -36,7 +36,7 @@ pub async fn run() -> Result<(), JsValue> { let Some(data) = value.as_string() else { return; }; - drop(serde_json::from_str::(&data)); + et_web::ignore(serde_json::from_str::(&data)); }) as Box); client.set_on_message(on_message.as_ref().clone()); @@ -134,7 +134,7 @@ 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 || { - drop(resolve.call0(&JsValue::NULL)); + et_web::ignore(resolve.call0(&JsValue::NULL)); }); let _id: Result = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms); diff --git a/services/ws-modules/geolocation/src/lib.rs b/services/ws-modules/geolocation/src/lib.rs index c6a1ca93..6caa761b 100644 --- a/services/ws-modules/geolocation/src/lib.rs +++ b/services/ws-modules/geolocation/src/lib.rs @@ -44,12 +44,12 @@ impl GeolocationReading { let promise = js_sys::Promise::new(&mut |resolve, reject| { let reject_for_callback = reject.clone(); let success_box: Box = Box::new(move |position: JsValue| { - drop(resolve.call1(&JsValue::NULL, &position)); + et_web::ignore(resolve.call1(&JsValue::NULL, &position)); }); let success = Closure::once(success_box); let failure_box: Box = Box::new(move |error: JsValue| { - drop(reject_for_callback.call1(&JsValue::NULL, &error)); + et_web::ignore(reject_for_callback.call1(&JsValue::NULL, &error)); }); let failure = Closure::once(failure_box); @@ -57,7 +57,7 @@ impl GeolocationReading { .and_then(|value| value.into_function("navigator.geolocation.getCurrentPosition")) { Ok(get_current_position) => { - drop(get_current_position.call3( + et_web::ignore(get_current_position.call3( &geolocation, success.as_ref().unchecked_ref(), failure.as_ref().unchecked_ref(), @@ -65,7 +65,7 @@ impl GeolocationReading { )); } Err(err) => { - drop(reject.call1(&JsValue::NULL, &err)); + et_web::ignore(reject.call1(&JsValue::NULL, &err)); } } @@ -116,7 +116,7 @@ impl GeolocationReading { #[wasm_bindgen(start)] pub fn init() { - drop(tracing_wasm::try_set_as_global_default()); + et_web::ignore(tracing_wasm::try_set_as_global_default()); info!("geolocation module initialized"); } @@ -170,7 +170,7 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - drop(set_module_status(&format!("geolocation: error\n{message}"))); + et_web::ignore(set_module_status(&format!("geolocation: error\n{message}"))); log(&format!("error: {message}")); } @@ -221,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 || { - drop(resolve.call0(&JsValue::NULL)); + et_web::ignore(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - drop(reject.call1(&JsValue::NULL, &error)); + et_web::ignore(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/graphics-info/src/lib.rs b/services/ws-modules/graphics-info/src/lib.rs index 818e63e4..ec4e2487 100644 --- a/services/ws-modules/graphics-info/src/lib.rs +++ b/services/ws-modules/graphics-info/src/lib.rs @@ -674,7 +674,7 @@ fn string_or_unknown(value: String) -> String { #[wasm_bindgen(start)] pub fn init() { - drop(tracing_wasm::try_set_as_global_default()); + et_web::ignore(tracing_wasm::try_set_as_global_default()); info!("graphics-info module initialized"); } @@ -800,7 +800,7 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - drop(set_module_status(&format!("graphics-info: error\n{message}"))); + et_web::ignore(set_module_status(&format!("graphics-info: error\n{message}"))); log(&format!("error: {message}")); } @@ -851,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 || { - drop(resolve.call0(&JsValue::NULL)); + et_web::ignore(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - drop(reject.call1(&JsValue::NULL, &error)); + et_web::ignore(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/har1/src/lib.rs b/services/ws-modules/har1/src/lib.rs index d9708108..0f848a9c 100644 --- a/services/ws-modules/har1/src/lib.rs +++ b/services/ws-modules/har1/src/lib.rs @@ -332,7 +332,7 @@ impl DeviceSensors { #[wasm_bindgen(start)] pub fn init() { - drop(tracing_wasm::try_set_as_global_default()); + et_web::ignore(tracing_wasm::try_set_as_global_default()); info!("har1 workflow module initialized"); } @@ -371,7 +371,7 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - drop(set_har_status(&format!("har1: error\n{message}"))); + et_web::ignore(set_har_status(&format!("har1: error\n{message}"))); log(&format!("error: {message}")); } @@ -874,13 +874,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 || { - drop(resolve.call0(&JsValue::NULL)); + et_web::ignore(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - drop(reject.call1(&JsValue::NULL, &error)); + et_web::ignore(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/nfc/src/lib.rs b/services/ws-modules/nfc/src/lib.rs index 535d7ca7..d1d03e9f 100644 --- a/services/ws-modules/nfc/src/lib.rs +++ b/services/ws-modules/nfc/src/lib.rs @@ -51,7 +51,7 @@ impl NfcScanResult { )] let timeout_seconds = timeout_ms / 1000_i32; let timeout_box: Box = Box::new(move || { - drop(reject_for_timeout.call1( + et_web::ignore(reject_for_timeout.call1( &JsValue::NULL, &JsValue::from_str(&format!("NFC scan timed out after {timeout_seconds} seconds")), )); @@ -59,7 +59,7 @@ impl NfcScanResult { let timeout_closure = Closure::once(timeout_box); if let Some(window) = web_sys::window() { - drop(window.set_timeout_with_callback_and_timeout_and_arguments_0( + et_web::ignore(window.set_timeout_with_callback_and_timeout_and_arguments_0( timeout_closure.as_ref().unchecked_ref(), timeout_ms, )); @@ -87,7 +87,7 @@ impl NfcScanResult { &JsValue::from_str(&record_summary), ) .unwrap_or(false); - drop(resolve.call1(&JsValue::NULL, &payload)); + et_web::ignore(resolve.call1(&JsValue::NULL, &payload)); }); let on_reading = Closure::once(on_reading_box); @@ -96,7 +96,7 @@ impl NfcScanResult { .ok() .and_then(|value| value.as_string()) .unwrap_or_else(|| "NFC reading failed".to_string()); - drop(reject_for_error.call1(&JsValue::NULL, &JsValue::from_str(&message))); + et_web::ignore(reject_for_error.call1(&JsValue::NULL, &JsValue::from_str(&message))); }); let on_reading_error = Closure::once(on_reading_error_box); @@ -208,7 +208,7 @@ fn summarize_ndef_records(event: &JsValue) -> String { #[wasm_bindgen(start)] pub fn init() { - drop(tracing_wasm::try_set_as_global_default()); + et_web::ignore(tracing_wasm::try_set_as_global_default()); info!("nfc module initialized"); } @@ -268,7 +268,7 @@ pub async fn run() -> Result<(), JsValue> { } else { format!("nfc: Error\n\n{message}") }; - drop(set_module_status(&error_display)); + et_web::ignore(set_module_status(&error_display)); log(&format!("error: {message}")); } @@ -330,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 || { - drop(resolve.call0(&JsValue::NULL)); + et_web::ignore(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - drop(reject.call1(&JsValue::NULL, &error)); + et_web::ignore(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/sensor1/src/lib.rs b/services/ws-modules/sensor1/src/lib.rs index 9f1e9af5..f4526e1a 100644 --- a/services/ws-modules/sensor1/src/lib.rs +++ b/services/ws-modules/sensor1/src/lib.rs @@ -321,7 +321,7 @@ thread_local! { #[wasm_bindgen(start)] pub fn init() { - drop(tracing_wasm::try_set_as_global_default()); + et_web::ignore(tracing_wasm::try_set_as_global_default()); info!("sensor stream workflow module initialized"); } @@ -349,7 +349,7 @@ pub async fn run() -> Result<(), JsValue> { return; }; - drop(render_sensor_output(&runtime.sensors)); + et_web::ignore(render_sensor_output(&runtime.sensors)); }); }); let render_closure = Closure::wrap(render_boxed); @@ -370,8 +370,8 @@ pub async fn run() -> Result<(), JsValue> { let stop_callback = Closure::once_into_js(move || { if is_running() { - drop(stop()); - drop(set_sensor_status( + et_web::ignore(stop()); + et_web::ignore(set_sensor_status( "sensor stream: finished automatically after 15 seconds", )); } diff --git a/services/ws-modules/speech-recognition/src/lib.rs b/services/ws-modules/speech-recognition/src/lib.rs index 0b34a110..5782ff92 100644 --- a/services/ws-modules/speech-recognition/src/lib.rs +++ b/services/ws-modules/speech-recognition/src/lib.rs @@ -111,17 +111,17 @@ impl SpeechRecognitionSession { if has_final && !settled_for_result.replace(true) { let payload = js_sys::Object::new(); - drop(js_sys::Reflect::set( + et_web::ignore(js_sys::Reflect::set( &payload, &JsValue::from_str("transcript"), &JsValue::from_str(&transcript), )); - drop(js_sys::Reflect::set( + et_web::ignore(js_sys::Reflect::set( &payload, &JsValue::from_str("confidence"), &JsValue::from_f64(confidence), )); - drop(resolve_for_result.call1(&JsValue::NULL, &payload)); + et_web::ignore(resolve_for_result.call1(&JsValue::NULL, &payload)); } } }); @@ -135,7 +135,7 @@ impl SpeechRecognitionSession { .ok() .and_then(|value| value.as_string()) .unwrap_or_else(|| "speech recognition failed".to_string()); - drop(reject_for_error.call1(&JsValue::NULL, &JsValue::from_str(&message))); + et_web::ignore(reject_for_error.call1(&JsValue::NULL, &JsValue::from_str(&message))); }); let on_error = Closure::wrap(on_error_box); @@ -145,24 +145,24 @@ impl SpeechRecognitionSession { } if let Some((transcript, confidence)) = transcript_state_for_end.borrow().clone() { let payload = js_sys::Object::new(); - drop(js_sys::Reflect::set( + et_web::ignore(js_sys::Reflect::set( &payload, &JsValue::from_str("transcript"), &JsValue::from_str(&transcript), )); - drop(js_sys::Reflect::set( + et_web::ignore(js_sys::Reflect::set( &payload, &JsValue::from_str("confidence"), &JsValue::from_f64(confidence), )); - drop(resolve_for_end.call1(&JsValue::NULL, &payload)); + et_web::ignore(resolve_for_end.call1(&JsValue::NULL, &payload)); } else if stop_requested_for_end.get() { - drop(reject_for_end.call1( + et_web::ignore(reject_for_end.call1( &JsValue::NULL, &JsValue::from_str("speech recognition stopped before any transcript was captured"), )); } else { - drop(reject_for_end.call1( + et_web::ignore(reject_for_end.call1( &JsValue::NULL, &JsValue::from_str("speech recognition ended without a transcript"), )); @@ -170,17 +170,17 @@ impl SpeechRecognitionSession { }); let on_end = Closure::wrap(on_end_box); - drop(js_sys::Reflect::set( + et_web::ignore(js_sys::Reflect::set( &recognition, &JsValue::from_str("onresult"), on_result.as_ref().unchecked_ref(), )); - drop(js_sys::Reflect::set( + et_web::ignore(js_sys::Reflect::set( &recognition, &JsValue::from_str("onerror"), on_error.as_ref().unchecked_ref(), )); - drop(js_sys::Reflect::set( + et_web::ignore(js_sys::Reflect::set( &recognition, &JsValue::from_str("onend"), on_end.as_ref().unchecked_ref(), @@ -190,10 +190,10 @@ impl SpeechRecognitionSession { .and_then(|value| value.into_function("SpeechRecognition.start")) { Ok(start) => { - drop(start.call0(&recognition)); + et_web::ignore(start.call0(&recognition)); } Err(err) => { - drop(reject.call1(&JsValue::NULL, &err)); + et_web::ignore(reject.call1(&JsValue::NULL, &err)); } } @@ -296,7 +296,7 @@ thread_local! { #[wasm_bindgen(start)] pub fn init() { - drop(tracing_wasm::try_set_as_global_default()); + et_web::ignore(tracing_wasm::try_set_as_global_default()); info!("speech-recognition module initialized"); } @@ -340,12 +340,12 @@ pub async fn run() -> Result<(), JsValue> { let elapsed_ms = js_sys::Date::now() - start_time; if elapsed_ms > 30_000_f64 { log("workflow finished automatically after 30 seconds"); - drop(stop()); + et_web::ignore(stop()); break; } if result_count >= 3 { log("workflow finished automatically after 3 recognition results"); - drop(stop()); + et_web::ignore(stop()); break; } @@ -394,7 +394,7 @@ 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() { - drop(runtime.session.stop()); + et_web::ignore(runtime.session.stop()); runtime.client.disconnect(); log("speech-recognition stopped"); } @@ -448,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 || { - drop(resolve.call0(&JsValue::NULL)); + et_web::ignore(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - drop(reject.call1(&JsValue::NULL, &error)); + et_web::ignore(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/video1/src/lib.rs b/services/ws-modules/video1/src/lib.rs index 4e021030..6ac6177e 100644 --- a/services/ws-modules/video1/src/lib.rs +++ b/services/ws-modules/video1/src/lib.rs @@ -77,7 +77,7 @@ thread_local! { #[wasm_bindgen(start)] pub fn init() { - drop(tracing_wasm::try_set_as_global_default()); + et_web::ignore(tracing_wasm::try_set_as_global_default()); info!("video-capture module initialized"); } @@ -137,7 +137,7 @@ pub async fn run() -> Result<(), JsValue> { let stop_callback = Closure::once_into_js(move || { if is_running() { log("workflow finished automatically after 10 seconds"); - drop(stop()); + et_web::ignore(stop()); } }); let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; @@ -150,7 +150,7 @@ pub async fn run() -> Result<(), JsValue> { if let Err(error) = &outcome { let message = describe_js_error(error); - drop(set_module_status(&format!("video-capture: error\n{message}"))); + et_web::ignore(set_module_status(&format!("video-capture: error\n{message}"))); log(&format!("error: {message}")); } @@ -228,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 || { - drop(resolve.call0(&JsValue::NULL)); + et_web::ignore(resolve.call0(&JsValue::NULL)); }); if let Err(error) = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) { - drop(reject.call1(&JsValue::NULL, &error)); + et_web::ignore(reject.call1(&JsValue::NULL, &error)); } }); JsFuture::from(promise).await.map(|_| ()) diff --git a/services/ws-modules/wasi-comm1/src/lib.rs b/services/ws-modules/wasi-comm1/src/lib.rs index 9c6a6aab..d2ac12c1 100644 --- a/services/ws-modules/wasi-comm1/src/lib.rs +++ b/services/ws-modules/wasi-comm1/src/lib.rs @@ -143,7 +143,7 @@ fn wait_for_agent_id() -> Option { fn sleep_ms(ms: u64) { let pollable = wasi::clocks::monotonic_clock::subscribe_duration(ms * 1_000_000); - drop(wasi::io::poll::poll(&[&pollable])); + let _ready = wasi::io::poll::poll(&[&pollable]); } export!(Component); diff --git a/services/ws-modules/wasi-data1/src/lib.rs b/services/ws-modules/wasi-data1/src/lib.rs index 8985ef7f..9817bc64 100644 --- a/services/ws-modules/wasi-data1/src/lib.rs +++ b/services/ws-modules/wasi-data1/src/lib.rs @@ -123,7 +123,7 @@ fn wait_for_agent_id() -> Option { fn sleep_ms(ms: u64) { let pollable = wasi::clocks::monotonic_clock::subscribe_duration(ms * 1_000_000); - drop(wasi::io::poll::poll(&[&pollable])); + let _ready = wasi::io::poll::poll(&[&pollable]); } export!(Component); diff --git a/services/ws-pyo3-runner/src/agent.rs b/services/ws-pyo3-runner/src/agent.rs index fd1c6794..0f39b617 100644 --- a/services/ws-pyo3-runner/src/agent.rs +++ b/services/ws-pyo3-runner/src/agent.rs @@ -13,7 +13,7 @@ use std::time::Duration; use futures_util::{SinkExt as _, StreamExt as _}; use tokio::net::TcpStream; -use tokio::sync::mpsc; +use tokio::sync::{Notify, mpsc}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, tungstenite}; use tracing::{info, warn}; @@ -116,7 +116,7 @@ pub fn initialize( /// Spawns the storage worker and the Python dispatch thread, completes the /// `et-connect` handshake, then runs the WS loop. Returns once the socket /// closes or `drive` errors. -pub async fn run(agent: InitializedAgent) -> Result<(), RunnerError> { +pub async fn run(agent: InitializedAgent, shutdown: &Notify) -> Result<(), RunnerError> { let InitializedAgent { config, dispatcher, @@ -158,7 +158,7 @@ pub async fn run(agent: InitializedAgent) -> Result<(), RunnerError> { *agent_id_slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(agent_id.clone()); let _connect_sent = inbound_tx.send(InboundEvent::Connect(agent_id)); - let result = drive(&mut socket, &inbound_tx, &mut outbound_rx).await; + let result = drive(&mut socket, &inbound_tx, &mut outbound_rx, shutdown).await; // Queue `on_shutdown` (the worker drains any frames ahead of it first), // then drop our sender so the worker's recv loop ends. Join before aborting @@ -265,13 +265,23 @@ async fn drive( socket: &mut WebSocketStream>, inbound_tx: &mpsc::UnboundedSender, outbound_rx: &mut mpsc::UnboundedReceiver, + shutdown: &Notify, ) -> Result<(), RunnerError> { // Keepalive: the server closes idle connections and never pings us, so a // module that only waits for inbound frames would be timed out. Ping on a // cadence well inside the server's timeout to stay registered. let mut heartbeat = et_ws_runner_common::heartbeat_interval().await; + // Fires when main trips `shutdown` (ctrl_c / RUNNER_TIMEOUT). Created once before the loop so no + // notification is lost, and returning here lets `run` fall through to its teardown (queue on_shutdown, + // join the worker, close the socket) rather than the run future being dropped mid-flight. + let shutdown_requested = shutdown.notified(); + tokio::pin!(shutdown_requested); loop { tokio::select! { + () = &mut shutdown_requested => { + info!("shutdown requested; closing connection"); + return Ok(()); + } // Inbound: hand the frame to the dispatch worker and keep looping. // The worker emits any reply onto the same outbound queue Python // pushes to via WsSender, so multi-send + reply compose in order. diff --git a/services/ws-pyo3-runner/src/main.rs b/services/ws-pyo3-runner/src/main.rs index 7cbcd1ac..76313369 100644 --- a/services/ws-pyo3-runner/src/main.rs +++ b/services/ws-pyo3-runner/src/main.rs @@ -43,24 +43,27 @@ async fn main() -> Result<(), Box> { }, )?; - let driven = async { - tokio::select! { - result = run_agent(agent) => result, - _ = tokio::signal::ctrl_c() => { - info!("interrupted; shutting down"); - Ok(()) + // Graceful shutdown: ctrl_c or the optional RUNNER_TIMEOUT trips `shutdown`, which `run_agent`'s `drive` + // loop selects on. That returns the run loop so `run_agent` still executes its teardown (queue on_shutdown, + // join the Python worker, close the socket) instead of the run future being dropped mid-flight. The watcher + // runs concurrently and is abandoned when `run_agent` returns and the process exits. + let shutdown = std::sync::Arc::new(tokio::sync::Notify::new()); + let limit = config.runner.timeout; + let _watcher = tokio::spawn({ + let shutdown = std::sync::Arc::clone(&shutdown); + async move { + tokio::select! { + _ = tokio::signal::ctrl_c() => info!("interrupted; shutting down"), + () = async { + match limit { + Some(dur) => tokio::time::sleep(dur).await, + None => std::future::pending::<()>().await, + } + } => info!("run timeout {limit:?} elapsed; shutting down"), } + shutdown.notify_one(); } - }; - - let Some(limit) = config.runner.timeout else { - driven.await?; - return Ok(()); - }; - let Ok(result) = tokio::time::timeout(limit, driven).await else { - info!("run timeout {limit:?} elapsed; shutting down"); - return Ok(()); - }; - result?; + }); + run_agent(agent, &shutdown).await?; Ok(()) } diff --git a/services/ws-pyo3-runner/tests/modules.rs b/services/ws-pyo3-runner/tests/modules.rs index 7f8fd7b6..65b326fc 100644 --- a/services/ws-pyo3-runner/tests/modules.rs +++ b/services/ws-pyo3-runner/tests/modules.rs @@ -50,6 +50,7 @@ const POLL_BACKOFF_MIN: Duration = Duration::from_millis(50); const POLL_BACKOFF_MAX: Duration = Duration::from_millis(500); /// How long each poll round drains inbound frames looking for the peer before backing off. const POLL_DRAIN_WINDOW: Duration = Duration::from_millis(250); +const RUST_LOG: &str = "RUST_LOG"; /// When a case may not be runnable, the condition under which it self-skips. enum Gate { @@ -149,6 +150,44 @@ fn no_hooks_fails_to_load() -> Result<(), Box> { Ok(()) } +/// `RUNNER_TIMEOUT` must trigger a graceful shutdown, not an abrupt drop: `drive`'s shutdown arm returns so +/// `run` still executes its teardown (queue `on_shutdown`, join the Python worker, close the socket). `echo` +/// loads without any gated toolchain, so the runner reaches its connected run loop before the short timeout +/// trips; a clean (zero) exit code then confirms the teardown path ran to completion. +#[tokio::test(flavor = "current_thread")] +async fn shuts_down_gracefully_on_timeout() -> Result<(), Box> { + let server = et_ws_test_server::start(); + let rust_log = std::env::var(RUST_LOG).unwrap_or_else(|_| "warn".to_string()); + let mut runner = Command::new(env!("CARGO_BIN_EXE_et-ws-pyo3-runner")) + .env("RUNNER_MODULE", "echo") + .env("PYO3_PYTHONPATH", python_dir()) + .env("WS_SERVER_URL", &server.ws_url) + .env("RUNNER_TIMEOUT", "3s") + .env("RUST_LOG", rust_log) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn()?; + + // The runner self-exits shortly after the 3s timeout; the outer bound only stops a regression from hanging + // the suite. `try_wait` polls without blocking the current-thread runtime. + let start = std::time::Instant::now(); + let status = loop { + if let Some(status) = runner.try_wait()? { + break status; + } + if start.elapsed() >= Duration::from_secs(30) { + runner.kill()?; + let _reaped = runner.wait()?; + return Err("runner did not shut down gracefully within 30s".into()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + }; + if !status.success() { + return Err(format!("runner should exit cleanly after graceful timeout shutdown, got {status:?}").into()); + } + Ok(()) +} + /// True if `gate` isn't satisfied on this host (emitting a skip note for torch). fn skipped(module: &str, gate: &Gate) -> bool { match gate { @@ -248,7 +287,7 @@ fn python_dir() -> PathBuf { /// Spawn the runner subprocess for `module`, pointed at `ws_url`. fn spawn_runner(module: &str, ws_url: &str) -> Child { // Silence the runner unless invoked with --nocapture and RUST_LOG opted in. - let rust_log = std::env::var("RUST_LOG").unwrap_or_else(|_| "warn".to_string()); + let rust_log = std::env::var(RUST_LOG).unwrap_or_else(|_| "warn".to_string()); Command::new(env!("CARGO_BIN_EXE_et-ws-pyo3-runner")) .env("RUNNER_MODULE", module) .env("PYO3_PYTHONPATH", python_dir()) diff --git a/services/ws-test-server/tests/direct_message_ack.rs b/services/ws-test-server/tests/direct_message_ack.rs new file mode 100644 index 00000000..64b2ae50 --- /dev/null +++ b/services/ws-test-server/tests/direct_message_ack.rs @@ -0,0 +1,67 @@ +//! Acknowledging a direct message must notify the original sender: when the recipient sends `et-message-ack`, +//! the hub pushes an `et-message-status` (Acknowledged) back to the sender's still-connected session. +#![cfg(test)] + +use std::time::Duration; + +use edge_toolkit::ws::{MessageDeliveryStatus, ServerMessage}; +use et_ws_test_server::{connect_agent, next_payload}; +use futures_util::{SinkExt as _, StreamExt as _}; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ack_notifies_original_sender() { + let server = et_ws_test_server::start(); + let (mut sender, _sender_id) = connect_agent(&server.ws_url).await; + let (mut recipient, recipient_id) = connect_agent(&server.ws_url).await; + + // Sender -> direct message -> recipient. + let send = serde_json::json!({ + "type": "et-send-agent-message", + "to_agent_id": recipient_id, + "message": {"ping": 1_u32}, + }); + sender.send(Message::text(send.to_string())).await.unwrap(); + + // The recipient receives the delivered message; capture its id so it can acknowledge. + let delivered = next_payload(&mut recipient).await; + let Message::Text(text) = delivered else { + panic!("expected an et-agent-message text frame, got {delivered:?}"); + }; + let ServerMessage::AgentMessage { message_id, .. } = serde_json::from_str::(&text).unwrap() else { + panic!("expected ServerMessage::AgentMessage, got {text}"); + }; + + // Recipient acknowledges receipt. + let ack = serde_json::json!({ "type": "et-message-ack", "message_id": message_id }); + recipient.send(Message::text(ack.to_string())).await.unwrap(); + + // The sender's stream carries a Delivered status first; read on until the Acknowledged one arrives. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + // Three unwraps mirror `next_payload`: timeout elapsed, stream ended, stream error. + let msg = tokio::time::timeout(remaining, sender.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let Message::Text(text) = msg else { continue }; + let Ok(ServerMessage::MessageStatus { + message_id: acked_id, + status, + .. + }) = serde_json::from_str::(&text) + else { + continue; + }; + if matches!(status, MessageDeliveryStatus::Acknowledged) { + assert_eq!( + acked_id.as_deref(), + Some(message_id.as_str()), + "acknowledged status must carry the original message id" + ); + return; + } + } +}