From e96e9dea4a9b7f020a210857bdcfcb3b2f124f84 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 17:36:45 +0300 Subject: [PATCH 01/25] feat(bus): own native recording and bounded audio outputs Co-authored-by: Medulla --- .github/workflows/release.yml | 4 + AGENTS.md | 8 +- README.md | 8 + crates/tinyvoice-bus/src/capture/mod.rs | 71 ++ crates/tinyvoice-bus/src/capture/mod_tests.rs | 31 + crates/tinyvoice-bus/src/lib.rs | 1 + crates/tinyvoice-bus/src/names/mod.rs | 23 + crates/tinyvoice-bus/src/version/mod.rs | 2 +- crates/tinyvoice-module/Cargo.lock | 713 +++++++++++++++++- crates/tinyvoice-module/Cargo.toml | 5 +- .../examples/verify_module.rs | 24 + .../src/service/capture/README.md | 24 + .../src/service/capture/device_native.rs | 24 + .../src/service/capture/mod.rs | 202 +++++ .../src/service/capture/mod_tests.rs | 288 +++++++ crates/tinyvoice-module/src/service/mod.rs | 65 +- .../tinyvoice-module/src/service/mod_tests.rs | 125 +++ docs/specs/module-capture.md | 54 ++ 18 files changed, 1662 insertions(+), 10 deletions(-) create mode 100644 crates/tinyvoice-bus/src/capture/mod.rs create mode 100644 crates/tinyvoice-bus/src/capture/mod_tests.rs create mode 100644 crates/tinyvoice-module/src/service/capture/README.md create mode 100644 crates/tinyvoice-module/src/service/capture/device_native.rs create mode 100644 crates/tinyvoice-module/src/service/capture/mod.rs create mode 100644 crates/tinyvoice-module/src/service/capture/mod_tests.rs create mode 100644 docs/specs/module-capture.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3763138..a5b5f1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -266,6 +266,10 @@ jobs: actual_target="$(rustc -vV | sed -n 's/^host: //p')" [[ "$actual_target" == "$EXPECTED_TARGET" ]] + - name: Install native capture development libraries + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes libasound2-dev + - name: Build installable module run: cargo build --manifest-path crates/tinyvoice-module/Cargo.toml --locked --release --package tinyvoice-module diff --git a/AGENTS.md b/AGENTS.md index daecc5b..ec28954 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,11 @@ which decides where the *next* piece of code goes: > **A crate owns what is identical for every host; the host owns what depends > on its own runtime, config, or threat model.** -`tinyvoice` is therefore synchronous, I/O-free and runtime-free. Before adding -anything here, check it against that rule. Device capture, STT/TTS transport, -hotkeys, credentials, and config shapes all fail it and belong to the host. +`tinyvoice` keeps its pure primitives synchronous and runtime-free. Native +capture and hotkeys execute inside the separately compiled module. +The library retains optional reusable native primitives; hosts use only the +minimal bus contract. Credentials, permission decisions (through the computer +module), product configuration and STT/TTS orchestration stay with the host. ## Project Structure diff --git a/README.md b/README.md index 53ba09c..0563fe0 100644 --- a/README.md +++ b/README.md @@ -122,3 +122,11 @@ CI additionally requires 90% line coverage in every source file and a clean ## License GPL-3.0-only. See [`LICENSE`](LICENSE). + +## Module-owned recording + +Contract 1.1 adds device enumeration and one-shot recording through opaque +handles. The host supplies a microphone permission grant obtained through +TinyComputer; the compiled module owns the device, audio preparation, bounded +WAV reads, cancellation and release. See [capture contract](docs/specs/module-capture.md). +Continuous capture and hotkey bus lifecycles remain a separate migration. diff --git a/crates/tinyvoice-bus/src/capture/mod.rs b/crates/tinyvoice-bus/src/capture/mod.rs new file mode 100644 index 0000000..414f847 --- /dev/null +++ b/crates/tinyvoice-bus/src/capture/mod.rs @@ -0,0 +1,71 @@ +//! Native capture lifecycle vocabulary; no devices or audio processing live here. +use serde::{Deserialize, Serialize}; +/// Permission decision obtained by the host through the computer module. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MicrophonePermission { + /// Capture may proceed. + Granted, + /// Capture is refused, including unknown or unsupported permission states. + #[default] + Denied, +} +/// Opaque module-owned recording or audio output handle. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CaptureHandle(pub String); +/// Start a recording using an explicit permission decision. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RecordingStartRequest { + /// Permission granted through the computer module. + pub permission: MicrophonePermission, +} +/// Finish capture and run the module's existing preparation pipeline. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordingFinishRequest { + /// Recording lease. + pub handle: CaptureHandle, + /// Existing silence-gate threshold; zero disables gating. + pub gate_threshold: f32, +} +/// Prepared WAV held inside the module for bounded reads. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AudioOutput { + /// Lease released with `ReleaseAudioOutput`. + pub handle: CaptureHandle, + /// WAV bytes available. + pub length: usize, +} +/// Read a bounded slice of a held audio output. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReadAudioRequest { + /// Output lease. + pub handle: CaptureHandle, + /// Byte offset. + pub offset: usize, + /// Maximum bytes to read. + pub length: usize, +} +/// Capture failure for product presentation, never a telemetry payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "code", content = "detail", rename_all = "snake_case")] +pub enum CaptureError { + /// Permission was not explicitly granted. + PermissionDenied, + /// A recording or finishing operation already owns the device slot. + Busy, + /// The recording or output handle is unknown or released. + UnknownHandle, + /// Output storage or read bounds were exceeded. + LimitExceeded, + /// Invalid processing parameters. + InvalidParameters, + /// Device or processing failure; contains product-facing native detail. + Device(String), +} +/// Typed terminal result; provider detail must not enter telemetry. +pub type CaptureResult = Result; + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-bus/src/capture/mod_tests.rs b/crates/tinyvoice-bus/src/capture/mod_tests.rs new file mode 100644 index 0000000..4054c8a --- /dev/null +++ b/crates/tinyvoice-bus/src/capture/mod_tests.rs @@ -0,0 +1,31 @@ +//! Capture vocabulary preserves explicit permission and opaque lease shapes. +use super::*; +#[test] +fn permission_defaults_to_denial_and_handle_is_opaque() -> Result<(), serde_json::Error> { + assert_eq!( + MicrophonePermission::default(), + MicrophonePermission::Denied + ); + assert_eq!( + serde_json::to_value(MicrophonePermission::Granted)?, + serde_json::json!("granted") + ); + let handle = CaptureHandle("opaque".into()); + assert_eq!(serde_json::to_value(&handle)?, serde_json::json!("opaque")); + assert_eq!( + serde_json::from_value::(serde_json::json!("opaque"))?, + handle + ); + Ok(()) +} +#[test] +fn native_faults_are_structured_product_results() -> Result<(), serde_json::Error> { + let fault = CaptureError::Device("fixture".into()); + let wire = serde_json::to_value(&fault)?; + assert_eq!( + wire, + serde_json::json!({"code":"device","detail":"fixture"}) + ); + assert_eq!(serde_json::from_value::(wire)?, fault); + Ok(()) +} diff --git a/crates/tinyvoice-bus/src/lib.rs b/crates/tinyvoice-bus/src/lib.rs index 8f08ab3..c4c44df 100644 --- a/crates/tinyvoice-bus/src/lib.rs +++ b/crates/tinyvoice-bus/src/lib.rs @@ -10,6 +10,7 @@ //! behavior. Hosts own their connection and policies; `tinyvoice-module` owns //! the adapter; and the root `tinyvoice` crate remains host-agnostic. +pub mod capture; pub mod intent; pub mod names; pub mod transcript; diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index 7b62710..3b3ea4f 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -38,6 +38,23 @@ pub mod methods { pub const ENCODE_WAV_PCM16: &str = "EncodeWavPcm16"; /// Runs the capture preparation pipeline and produces a WAV file. pub const PREPARE_CAPTURE: &str = "PrepareCapture"; + /// Native capture operation executed inside the compiled module. + pub const LIST_INPUT_DEVICES: &str = "ListInputDevices"; + + /// Native capture operation executed inside the compiled module. + pub const RECORDING_START: &str = "RecordingStart"; + + /// Native capture operation executed inside the compiled module. + pub const RECORDING_FINISH: &str = "RecordingFinish"; + + /// Native capture operation executed inside the compiled module. + pub const RECORDING_CANCEL: &str = "RecordingCancel"; + + /// Native capture operation executed inside the compiled module. + pub const READ_AUDIO_OUTPUT: &str = "ReadAudioOutput"; + + /// Native capture operation executed inside the compiled module. + pub const RELEASE_AUDIO_OUTPUT: &str = "ReleaseAudioOutput"; } /// Every member of [`BUS_NAME`], in the interface's sorted dispatch order. @@ -47,8 +64,14 @@ pub const METHODS: &[&str] = &[ methods::EXTRACT_COMMAND, methods::FRAME_ENERGIES, methods::IS_HALLUCINATED, + methods::LIST_INPUT_DEVICES, methods::PREPARE_CAPTURE, methods::PREPARE_FRAMES, + methods::READ_AUDIO_OUTPUT, + methods::RECORDING_CANCEL, + methods::RECORDING_FINISH, + methods::RECORDING_START, + methods::RELEASE_AUDIO_OUTPUT, methods::ROUTE, methods::SEGMENT, methods::VAD_CLOSE, diff --git a/crates/tinyvoice-bus/src/version/mod.rs b/crates/tinyvoice-bus/src/version/mod.rs index d872369..478a2bc 100644 --- a/crates/tinyvoice-bus/src/version/mod.rs +++ b/crates/tinyvoice-bus/src/version/mod.rs @@ -1,7 +1,7 @@ //! The `TinyVoice` `TinyBus` contract version and its binding rule. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 0); +pub const CONTRACT_VERSION: (u32, u32) = (1, 1); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// diff --git a/crates/tinyvoice-module/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index 15cb390..c934a09 100644 --- a/crates/tinyvoice-module/Cargo.lock +++ b/crates/tinyvoice-module/Cargo.lock @@ -8,6 +8,28 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -28,6 +50,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" @@ -55,6 +83,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -83,6 +120,60 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" +dependencies = [ + "alsa", + "block2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "web-sys", + "windows", + "windows-core", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -117,6 +208,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -138,6 +235,16 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -197,6 +304,30 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -267,6 +398,75 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -279,12 +479,27 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "memchr" version = "2.8.3" @@ -301,12 +516,196 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -319,6 +718,21 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -343,6 +757,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "ring" version = "0.17.14" @@ -357,6 +780,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -405,6 +837,33 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -480,6 +939,34 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + [[package]] name = "subtle" version = "2.6.1" @@ -532,13 +1019,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -563,7 +1070,7 @@ dependencies = [ "sha2", "tar", "tempfile", - "thiserror", + "thiserror 2.0.20", "tinybus-macros", "tokio", "toml", @@ -597,8 +1104,12 @@ dependencies = [ name = "tinyvoice" version = "0.1.12" dependencies = [ - "thiserror", + "cpal", + "log", + "parking_lot", + "thiserror 2.0.20", "tinyvoice-bus", + "tokio", ] [[package]] @@ -613,6 +1124,7 @@ name = "tinyvoice-module" version = "0.1.12" dependencies = [ "base64 0.22.1", + "getrandom 0.4.3", "serde", "serde_json", "tinybus", @@ -668,6 +1180,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -773,12 +1297,77 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.3", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -788,12 +1377,116 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -828,6 +1521,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -881,6 +1583,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "xattr" @@ -911,7 +1616,7 @@ dependencies = [ "flate2", "indexmap", "memchr", - "thiserror", + "thiserror 2.0.20", "zopfli", ] diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index b1c66f2..28821c4 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -17,11 +17,14 @@ crate-type = ["rlib", "cdylib"] static-link = [] [dependencies] +# Opaque native recording/output leases must not rebind after a module reload. +getrandom = "0.4" + # The transport-free TinyBus wire contract: names, payload types, and version. tinyvoice-bus = { version = "0.1.2", path = "../tinyvoice-bus" } # The pure voice library stays independently usable and bus-agnostic; this # crate exists so the vendored TinyBus dependency never reaches it. -tinyvoice = { version = "0.1.3", path = "../tinyvoice" } +tinyvoice = { version = "0.1.3", path = "../tinyvoice", features = ["capture"] } # TinyBus provides the typed service interface and the dynamic module host ABI. tinybus = { version = "=0.1.2", default-features = false, features = ["macros", "modules"] } # The module-side SDK owns its runtime and exports the stable C entrypoints. diff --git a/crates/tinyvoice-module/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index 2e468dd..9741ba6 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -55,6 +55,30 @@ async fn main() -> Result<(), Box> { ); } + let declared: Vec<_> = info + .manifest + .provides + .iter() + .flat_map(|interface| interface.methods.iter()) + .map(ToString::to_string) + .collect(); + let mut declared = declared; + declared.sort_unstable(); + if declared != tinyvoice_bus::METHODS { + return Err(io::Error::other("capture manifest differs from contract").into()); + } + // Default-denied permission must reject before any native device is opened. + let denied: tinyvoice_bus::capture::CaptureResult = + proxy + .call( + "RecordingStart", + (tinyvoice_bus::capture::RecordingStartRequest::default(),), + ) + .await?; + if denied != Err(tinyvoice_bus::capture::CaptureError::PermissionDenied) { + return Err(io::Error::other("module did not enforce permission decision").into()); + } + println!( "verified {} as TinyBus module `{}`", module.display(), diff --git a/crates/tinyvoice-module/src/service/capture/README.md b/crates/tinyvoice-module/src/service/capture/README.md new file mode 100644 index 0000000..adb435c --- /dev/null +++ b/crates/tinyvoice-module/src/service/capture/README.md @@ -0,0 +1,24 @@ +# Native recording ownership + +The capture manager owns a single active device lease, including finish and +preparation, and up to four WAV outputs totaling 32 MiB. Permission must be an +explicit host grant obtained through TinyComputer before the native backend is +called. The backend preserves the library's dedicated-thread CPAL lifecycle. + +RecordingStart returns a random opaque handle. RecordingFinish consumes it, +waits for device shutdown, downmixes/resamples/gates inside the module, and +returns an output handle. RecordingCancel consumes the recording and waits for +its device thread to finish. Dropping the module stops remaining handles through +the existing library Drop implementation. Invalid processing arguments retain +the recording so the host can cancel it. Device/setup faults release capacity. + +ReadAudioOutput accepts at most 256 KiB per call and returns base64 WAV bytes; +ReleaseAudioOutput frees the held output. Unknown, consumed and released handles +fail explicitly. Output size is checked before resampling to bound allocation +and again against the aggregate store budget. No calls occur per audio sample. + +The native bridge in device_native.rs touches real devices and follows the +repository's existing device-file coverage exception. Backend fixtures exercise +permission admission, exclusivity, cancellation, preparation, bounded reads, +release and faults without hardware. Native fault detail is for product +presentation only; host telemetry must use the safe reason code without detail. diff --git a/crates/tinyvoice-module/src/service/capture/device_native.rs b/crates/tinyvoice-module/src/service/capture/device_native.rs new file mode 100644 index 0000000..fd88b1f --- /dev/null +++ b/crates/tinyvoice-module/src/service/capture/device_native.rs @@ -0,0 +1,24 @@ +//! Native device I/O bridge. Fixtures exercise the manager through its backend seam. +//! Real device enumeration/open/stop require hardware and permission and use +//! the repository's established device-file coverage exclusion. +use super::{Backend, Recording, RecordingFuture}; +use tinyvoice::capture::RecordingHandle; +impl Recording for RecordingHandle { + fn finish(self: Box) -> RecordingFuture { + Box::pin(async move { self.stop().await.map_err(|error| error.to_string()) }) + } +} +#[derive(Debug)] +pub(super) struct Native; +impl Backend for Native { + fn devices(&self) -> Result, String> { + tinyvoice::capture::list_input_devices().map_err(|error| error.to_string()) + } + fn start(&self) -> Result, String> { + // The manager refuses anything except the host's computer-module grant + // before entering this backend. No permission decision is made locally. + tinyvoice::capture::start_recording(|| Ok(())) + .map(|recording| Box::new(recording) as Box) + .map_err(|error| error.to_string()) + } +} diff --git a/crates/tinyvoice-module/src/service/capture/mod.rs b/crates/tinyvoice-module/src/service/capture/mod.rs new file mode 100644 index 0000000..35f7699 --- /dev/null +++ b/crates/tinyvoice-module/src/service/capture/mod.rs @@ -0,0 +1,202 @@ +//! Native recording ownership and bounded prepared audio outputs. +use super::BASE64; +use base64::Engine as _; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use tinyvoice::capture::RawRecording; +use tinyvoice_bus::capture::{ + AudioOutput, CaptureError, CaptureHandle, CaptureResult, MicrophonePermission, + ReadAudioRequest, RecordingFinishRequest, +}; + +const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024; +const MAX_OUTPUTS: usize = 4; +const MAX_READ_BYTES: usize = 256 * 1024; +type RecordingFuture = Pin> + Send>>; +pub(super) trait Recording: std::fmt::Debug + Send { + fn finish(self: Box) -> RecordingFuture; +} +pub(super) trait Backend: std::fmt::Debug + Send + Sync { + fn devices(&self) -> Result, String>; + fn start(&self) -> Result, String>; +} +#[derive(Debug)] +struct BusyGuard(Arc); +impl Drop for BusyGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::SeqCst); + } +} +#[derive(Debug)] +struct Lease { + recording: Box, + busy: BusyGuard, +} +#[derive(Debug)] +pub(super) struct Capture { + backend: Arc, + busy: Arc, + recordings: Mutex>, + outputs: Mutex>>, +} +impl Default for Capture { + fn default() -> Self { + Self::new(Arc::new(device_native::Native)) + } +} +impl Capture { + pub(super) fn new(backend: Arc) -> Self { + Self { + backend, + busy: Arc::new(AtomicBool::new(false)), + recordings: Mutex::new(HashMap::new()), + outputs: Mutex::new(HashMap::new()), + } + } + pub(super) fn devices(&self) -> CaptureResult> { + self.backend.devices().map_err(CaptureError::Device) + } + pub(super) fn start(&self, permission: MicrophonePermission) -> CaptureResult { + if permission != MicrophonePermission::Granted { + return Err(CaptureError::PermissionDenied); + } + self.busy + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .map_err(|_| CaptureError::Busy)?; + let busy = BusyGuard(self.busy.clone()); + let handle = handle()?; + let recording = self.backend.start().map_err(CaptureError::Device)?; + self.recordings + .lock() + .map_err(|_| CaptureError::Device("capture state unavailable".into()))? + .insert(handle.clone(), Lease { recording, busy }); + Ok(handle) + } + fn take(&self, handle: &CaptureHandle) -> CaptureResult { + self.recordings + .lock() + .map_err(|_| CaptureError::Device("capture state unavailable".into()))? + .remove(handle) + .ok_or(CaptureError::UnknownHandle) + } + pub(super) async fn finish( + &self, + request: RecordingFinishRequest, + ) -> CaptureResult { + if !request.gate_threshold.is_finite() || request.gate_threshold < 0.0 { + return Err(CaptureError::InvalidParameters); + } + let Lease { recording, busy } = self.take(&request.handle)?; + let raw = recording.finish().await.map_err(CaptureError::Device)?; + let wav = tokio::task::spawn_blocking(move || prepare(&raw, request.gate_threshold)) + .await + .map_err(|_| CaptureError::Device("audio preparation worker failed".into()))??; + let mut outputs = self + .outputs + .lock() + .map_err(|_| CaptureError::Device("audio output state unavailable".into()))?; + let total: usize = outputs.values().map(Vec::len).sum(); + if outputs.len() >= MAX_OUTPUTS || wav.len() > MAX_OUTPUT_BYTES.saturating_sub(total) { + return Err(CaptureError::LimitExceeded); + } + let handle = handle()?; + let length = wav.len(); + outputs.insert(handle.clone(), wav); + drop(busy); + Ok(AudioOutput { handle, length }) + } + pub(super) async fn cancel(&self, handle: &CaptureHandle) -> CaptureResult<()> { + let Lease { recording, busy } = self.take(handle)?; + // A canceled empty/failed recording is discarded, but the finish await + // still waits for its device thread to drop the native stream. + let _ = recording.finish().await; + drop(busy); + Ok(()) + } + pub(super) fn read(&self, request: &ReadAudioRequest) -> CaptureResult { + if request.length == 0 || request.length > MAX_READ_BYTES { + return Err(CaptureError::LimitExceeded); + } + let outputs = self + .outputs + .lock() + .map_err(|_| CaptureError::Device("audio output state unavailable".into()))?; + let wav = outputs + .get(&request.handle) + .ok_or(CaptureError::UnknownHandle)?; + if request.offset > wav.len() { + return Err(CaptureError::InvalidParameters); + } + let end = request.offset.saturating_add(request.length).min(wav.len()); + Ok(BASE64.encode(&wav[request.offset..end])) + } + pub(super) fn release(&self, handle: &CaptureHandle) -> CaptureResult<()> { + self.outputs + .lock() + .map_err(|_| CaptureError::Device("audio output state unavailable".into()))? + .remove(handle) + .map(|_| ()) + .ok_or(CaptureError::UnknownHandle) + } +} +fn handle() -> CaptureResult { + let mut bytes = [0_u8; 16]; + getrandom::fill(&mut bytes) + .map_err(|_| CaptureError::Device("capture entropy unavailable".into()))?; + Ok(CaptureHandle(format!( + "{:032x}", + u128::from_le_bytes(bytes) + ))) +} +fn prepare(raw: &RawRecording, threshold: f32) -> CaptureResult> { + use tinyvoice::audio::{self, SilenceGateConfig}; + let channels = u16::try_from(raw.channels).map_err(|_| CaptureError::InvalidParameters)?; + let mono = audio::to_mono(&raw.samples, channels) + .map_err(|error| CaptureError::Device(error.to_string()))?; + if raw.source_rate == 0 { + return Err(CaptureError::InvalidParameters); + } + let target_samples = mono + .len() + .checked_mul(audio::STT_SAMPLE_RATE as usize) + .ok_or(CaptureError::LimitExceeded)? + .div_ceil(raw.source_rate as usize); + if target_samples > (MAX_OUTPUT_BYTES - 44) / 2 { + return Err(CaptureError::LimitExceeded); + } + let samples = audio::resample(&mono, raw.source_rate, audio::STT_SAMPLE_RATE) + .map_err(|error| CaptureError::Device(error.to_string()))?; + let samples = if threshold > 0.0 { + let mut gate = audio::SilenceGate::new( + SilenceGateConfig { + threshold, + ..Default::default() + }, + audio::STT_SAMPLE_RATE, + ) + .map_err(|error| CaptureError::Device(error.to_string()))?; + gate.push(&samples) + } else { + samples + }; + let wav = audio::f32_mono_to_wav(&samples, audio::STT_SAMPLE_RATE) + .map_err(|error| CaptureError::Device(error.to_string()))?; + if wav.len() > MAX_OUTPUT_BYTES { + return Err(CaptureError::LimitExceeded); + } + Ok(wav) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; + +#[cfg(test)] +pub(super) fn fixture_capture() -> Arc { + Arc::new(tests::fixture_capture()) +} + +mod device_native; diff --git a/crates/tinyvoice-module/src/service/capture/mod_tests.rs b/crates/tinyvoice-module/src/service/capture/mod_tests.rs new file mode 100644 index 0000000..ef03725 --- /dev/null +++ b/crates/tinyvoice-module/src/service/capture/mod_tests.rs @@ -0,0 +1,288 @@ +//! Native capture lifecycle fixtures do not open devices or request permissions. +use super::*; + +#[derive(Debug)] +struct Fixture { + starts: AtomicUsize, + drops: Arc, + fail: bool, +} +use std::sync::atomic::AtomicUsize; +#[derive(Debug)] +struct RecordingFixture(Arc); +impl Drop for RecordingFixture { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} +impl Recording for RecordingFixture { + fn finish(self: Box) -> RecordingFuture { + Box::pin(async move { + let _owned = self; + Ok(RawRecording { + samples: vec![0.25; 320], + source_rate: 16_000, + channels: 1, + }) + }) + } +} +impl Backend for Fixture { + fn devices(&self) -> Result, String> { + if self.fail { + Err("fixture device failure".into()) + } else { + Ok(vec!["fixture".into()]) + } + } + fn start(&self) -> Result, String> { + self.starts.fetch_add(1, Ordering::SeqCst); + if self.fail { + Err("fixture setup failure".into()) + } else { + Ok(Box::new(RecordingFixture(self.drops.clone()))) + } + } +} +fn fixture(fail: bool) -> (Capture, Arc) { + let backend = Arc::new(Fixture { + starts: AtomicUsize::new(0), + drops: Arc::new(AtomicUsize::new(0)), + fail, + }); + (Capture::new(backend.clone()), backend) +} +fn read(handle: &CaptureHandle, offset: usize, length: usize) -> ReadAudioRequest { + ReadAudioRequest { + handle: handle.clone(), + offset, + length, + } +} + +#[tokio::test] +async fn permission_busy_cancel_and_drop_release_device_ownership() -> CaptureResult<()> { + let (capture, backend) = fixture(false); + assert_eq!(capture.devices()?, vec!["fixture"]); + assert_eq!( + capture.start(MicrophonePermission::Denied), + Err(CaptureError::PermissionDenied) + ); + assert_eq!(backend.starts.load(Ordering::SeqCst), 0); + let handle = capture.start(MicrophonePermission::Granted)?; + assert_eq!( + capture.start(MicrophonePermission::Granted), + Err(CaptureError::Busy) + ); + capture.cancel(&handle).await?; + assert_eq!(backend.drops.load(Ordering::SeqCst), 1); + assert_eq!( + capture.cancel(&handle).await, + Err(CaptureError::UnknownHandle) + ); + let _handle = capture.start(MicrophonePermission::Granted)?; + drop(capture); + assert_eq!(backend.drops.load(Ordering::SeqCst), 2); + Ok(()) +} + +#[tokio::test] +async fn finish_holds_prepared_wav_for_bounded_reads_and_explicit_release() -> CaptureResult<()> { + let (capture, _) = fixture(false); + let handle = capture.start(MicrophonePermission::Granted)?; + assert_eq!( + capture + .finish(RecordingFinishRequest { + handle: handle.clone(), + gate_threshold: f32::NAN + }) + .await, + Err(CaptureError::InvalidParameters) + ); + let output = capture + .finish(RecordingFinishRequest { + handle: handle.clone(), + gate_threshold: 0.0, + }) + .await?; + assert_eq!(output.length, 44 + 320 * 2); + let encoded = capture.read(&read(&output.handle, 0, MAX_READ_BYTES))?; + let wav = BASE64 + .decode(encoded) + .map_err(|error| CaptureError::Device(error.to_string()))?; + assert_eq!(&wav[..4], b"RIFF"); + assert_eq!(capture.read(&read(&output.handle, output.length, 1))?, ""); + assert_eq!( + capture.read(&read(&output.handle, output.length + 1, 1)), + Err(CaptureError::InvalidParameters) + ); + assert_eq!( + capture.read(&read(&output.handle, 0, 0)), + Err(CaptureError::LimitExceeded) + ); + assert_eq!( + capture.read(&read(&output.handle, 0, MAX_READ_BYTES + 1)), + Err(CaptureError::LimitExceeded) + ); + assert_eq!( + capture + .finish(RecordingFinishRequest { + handle, + gate_threshold: 0.0 + }) + .await, + Err(CaptureError::UnknownHandle) + ); + capture.release(&output.handle)?; + assert_eq!( + capture.read(&read(&output.handle, 0, 1)), + Err(CaptureError::UnknownHandle) + ); + assert_eq!( + capture.release(&output.handle), + Err(CaptureError::UnknownHandle) + ); + Ok(()) +} + +#[tokio::test] +async fn output_capacity_and_setup_faults_do_not_leak_recording_slots() -> CaptureResult<()> { + let (capture, _) = fixture(false); + for _ in 0..MAX_OUTPUTS { + let handle = capture.start(MicrophonePermission::Granted)?; + capture + .finish(RecordingFinishRequest { + handle, + gate_threshold: 0.01, + }) + .await?; + } + let handle = capture.start(MicrophonePermission::Granted)?; + assert_eq!( + capture + .finish(RecordingFinishRequest { + handle, + gate_threshold: 0.0 + }) + .await, + Err(CaptureError::LimitExceeded) + ); + let handle = capture.start(MicrophonePermission::Granted)?; + capture.cancel(&handle).await?; + let (failed, backend) = fixture(true); + assert_eq!( + failed.devices(), + Err(CaptureError::Device("fixture device failure".into())) + ); + for _ in 0..2 { + assert_eq!( + failed.start(MicrophonePermission::Granted), + Err(CaptureError::Device("fixture setup failure".into())) + ); + } + assert_eq!(backend.starts.load(Ordering::SeqCst), 2); + Ok(()) +} + +#[test] +fn invalid_device_formats_and_expansion_are_rejected_before_resampling() { + assert_eq!( + prepare( + &RawRecording { + samples: vec![0.0; 10], + source_rate: 0, + channels: 1 + }, + 0.0 + ), + Err(CaptureError::InvalidParameters) + ); + assert_eq!( + prepare( + &RawRecording { + samples: vec![0.0; 10], + source_rate: 16_000, + channels: usize::MAX + }, + 0.0 + ), + Err(CaptureError::InvalidParameters) + ); + assert_eq!( + prepare( + &RawRecording { + samples: vec![0.0; 2_000], + source_rate: 1, + channels: 1 + }, + 0.0 + ), + Err(CaptureError::LimitExceeded) + ); + assert!(matches!( + prepare( + &RawRecording { + samples: vec![0.0; 10], + source_rate: 16_000, + channels: 0 + }, + 0.0 + ), + Err(CaptureError::Device(_)) + )); +} + +pub(super) fn fixture_capture() -> Capture { + fixture(false).0 +} + +#[test] +fn poisoned_resource_tables_return_closed_errors_without_recovery() { + let (capture, _) = fixture(false); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = capture.recordings.lock(); + std::panic::resume_unwind(Box::new("fixture poison")); + })); + assert!(matches!( + capture.start(MicrophonePermission::Granted), + Err(CaptureError::Device(_)) + )); + assert!(matches!( + capture.take(&CaptureHandle("unknown".into())), + Err(CaptureError::Device(_)) + )); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = capture.outputs.lock(); + std::panic::resume_unwind(Box::new("fixture poison")); + })); + assert!(matches!( + capture.read(&read(&CaptureHandle("unknown".into()), 0, 1)), + Err(CaptureError::Device(_)) + )); + assert!(matches!( + capture.release(&CaptureHandle("unknown".into())), + Err(CaptureError::Device(_)) + )); +} + +#[tokio::test] +async fn preparing_with_a_poisoned_output_table_releases_the_recording() -> CaptureResult<()> { + let (capture, _) = fixture(false); + let handle = capture.start(MicrophonePermission::Granted)?; + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = capture.outputs.lock(); + std::panic::resume_unwind(Box::new("fixture poison")); + })); + assert!(matches!( + capture + .finish(RecordingFinishRequest { + handle, + gate_threshold: 0.0 + }) + .await, + Err(CaptureError::Device(_)) + )); + let restarted = capture.start(MicrophonePermission::Granted)?; + capture.cancel(&restarted).await?; + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 95ae7fd..3175ca3 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -60,6 +60,7 @@ pub struct VoiceService { /// async mutex would add a scheduling hop to a lock that is never contended /// for long. It is deliberately never held across an `.await`. sessions: std::sync::Mutex, + capture: std::sync::Arc, } /// The session table and the counter that names its entries. @@ -167,9 +168,62 @@ fn to_json(value: &T) -> TinyBusResult { // work for them to wait on. // `#[tinybus::interface]` requires `async fn`; all work under this boundary is // synchronous and changing these signatures would break the generated adapter. -#[allow(clippy::unused_async, clippy::unused_async_trait_impl)] +#[allow( + clippy::unused_async, + reason = "TinyBus interface methods require async signatures" +)] #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.Voice")] impl VoiceService { + /// Enumerate devices inside the module without opening a recording. + async fn list_input_devices( + &self, + ) -> TinyBusResult>> { + let capture = self.capture.clone(); + tokio::task::spawn_blocking(move || capture.devices()) + .await + .map_err(|_| tinybus::Error::failed("capture worker failed")) + } + /// Open a native recording after an explicit permission decision. + async fn recording_start( + &self, + request: tinyvoice_bus::capture::RecordingStartRequest, + ) -> TinyBusResult> + { + let capture = self.capture.clone(); + tokio::task::spawn_blocking(move || capture.start(request.permission)) + .await + .map_err(|_| tinybus::Error::failed("capture worker failed")) + } + /// Finish and prepare the recording inside the module. + async fn recording_finish( + &self, + request: tinyvoice_bus::capture::RecordingFinishRequest, + ) -> TinyBusResult> + { + Ok(self.capture.finish(request).await) + } + /// Stop and discard a native recording. + async fn recording_cancel( + &self, + handle: tinyvoice_bus::capture::CaptureHandle, + ) -> TinyBusResult> { + Ok(self.capture.cancel(&handle).await) + } + /// Read at most one bounded base64 WAV batch. + async fn read_audio_output( + &self, + request: tinyvoice_bus::capture::ReadAudioRequest, + ) -> TinyBusResult> { + Ok(self.capture.read(&request)) + } + /// Release a held WAV, including when the host abandons it. + async fn release_audio_output( + &self, + handle: tinyvoice_bus::capture::CaptureHandle, + ) -> TinyBusResult> { + Ok(self.capture.release(&handle)) + } + /// Classify a command transcript, returning a JSON `VoiceIntent`. /// /// The transcript should already have had any wake word removed by @@ -514,9 +568,18 @@ tinybus_module::module_export_optional_static! { "EncodeWav", "EncodeWavPcm16", "PrepareCapture", + "ListInputDevices", + "RecordingStart", + "RecordingFinish", + "RecordingCancel", + "ReadAudioOutput", + "ReleaseAudioOutput", + ], signals = [], requires = [], optional = [], lazy = false, } + +mod capture; diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index 3990187..0ecdea2 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -665,3 +665,128 @@ async fn a_truncated_pcm16_buffer_is_refused() -> tinybus::Result<()> { assert!(error.to_string().contains("i16 samples"), "got: {error}"); Ok(()) } + +#[tokio::test] +async fn native_capture_members_own_resources_and_return_bounded_wav_batches() -> tinybus::Result<()> +{ + use tinyvoice_bus::capture::{ + AudioOutput, CaptureError, CaptureHandle, CaptureResult, MicrophonePermission, + ReadAudioRequest, RecordingFinishRequest, RecordingStartRequest, + }; + let bus = MemoryBus::new(); + let task = Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await?).await?; + service + .serve_at( + OBJECT_PATH.try_into()?, + VoiceService { + capture: super::capture::fixture_capture(), + ..Default::default() + }, + ) + .await?; + service.request_name(BUS_NAME).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME)?; + let devices: CaptureResult> = + proxy.call(names::methods::LIST_INPUT_DEVICES, ()).await?; + assert_eq!(devices, Ok(vec!["fixture".into()])); + let denied: CaptureResult = proxy + .call( + names::methods::RECORDING_START, + (RecordingStartRequest::default(),), + ) + .await?; + assert_eq!(denied, Err(CaptureError::PermissionDenied)); + let handle: CaptureResult = proxy + .call( + names::methods::RECORDING_START, + (RecordingStartRequest { + permission: MicrophonePermission::Granted, + },), + ) + .await?; + let handle = handle.unwrap(); + let output: CaptureResult = proxy + .call( + names::methods::RECORDING_FINISH, + (RecordingFinishRequest { + handle, + gate_threshold: 0.0, + },), + ) + .await?; + let output = output.unwrap(); + let bytes: CaptureResult = proxy + .call( + names::methods::READ_AUDIO_OUTPUT, + (ReadAudioRequest { + handle: output.handle.clone(), + offset: 0, + length: 256, + },), + ) + .await?; + assert_eq!(BASE64.decode(bytes.unwrap()).unwrap().len(), 256); + let released: CaptureResult<()> = proxy + .call( + names::methods::RELEASE_AUDIO_OUTPUT, + (output.handle.clone(),), + ) + .await?; + assert_eq!(released, Ok(())); + let missing: CaptureResult = proxy + .call( + names::methods::READ_AUDIO_OUTPUT, + (ReadAudioRequest { + handle: output.handle, + offset: 0, + length: 1, + },), + ) + .await?; + assert_eq!(missing, Err(CaptureError::UnknownHandle)); + let handle: CaptureResult = proxy + .call( + names::methods::RECORDING_START, + (RecordingStartRequest { + permission: MicrophonePermission::Granted, + },), + ) + .await?; + let canceled: CaptureResult<()> = proxy + .call(names::methods::RECORDING_CANCEL, (handle.unwrap(),)) + .await?; + assert_eq!(canceled, Ok(())); + task.abort(); + Ok(()) +} + +#[tokio::test] +async fn prepare_capture_gates_inside_the_module_and_rejects_invalid_formats() -> tinybus::Result<()> +{ + let proxy = connect().await?; + let wav: String = proxy + .call( + names::methods::PREPARE_CAPTURE, + (encode_samples(&[0.25; 320]), 16_000_u32, 1_u16, 0.01_f32), + ) + .await?; + assert_eq!(&BASE64.decode(wav).unwrap()[..4], b"RIFF"); + for (samples, rate, channels) in [ + ("invalid".to_string(), 16_000_u32, 1_u16), + (encode_samples(&[0.25]), 0, 1), + (encode_samples(&[0.25]), 16_000, 0), + ] { + assert!( + proxy + .call::( + names::methods::PREPARE_CAPTURE, + (samples, rate, channels, 0.0_f32) + ) + .await + .is_err() + ); + } + Ok(()) +} diff --git a/docs/specs/module-capture.md b/docs/specs/module-capture.md new file mode 100644 index 0000000..0bc3524 --- /dev/null +++ b/docs/specs/module-capture.md @@ -0,0 +1,54 @@ +# Module-owned recording contract + +TinyVoice contract 1.1 adds six members while preserving every existing method's +arity and wire format: + +| Member | One argument | Result | +| --- | --- | --- | +| ListInputDevices | None | CaptureResult of device names | +| RecordingStart | RecordingStartRequest | CaptureResult of CaptureHandle | +| RecordingFinish | RecordingFinishRequest | CaptureResult of AudioOutput | +| RecordingCancel | CaptureHandle | CaptureResult of unit | +| ReadAudioOutput | ReadAudioRequest | CaptureResult of base64 WAV bytes | +| ReleaseAudioOutput | CaptureHandle | CaptureResult of unit | + +ListInputDevices has zero arguments. Each other new member takes exactly one. +The contract contains serialized vocabulary only; CPAL, native device threads, +processing, storage and entropy remain in the implementation module. + +The host obtains the microphone decision through TinyComputer and passes an +explicit Granted value. Denied is the default and fails before device access. +The module preserves the library's dedicated-thread native capture behavior. + +Only one recording or finishing operation can own the device slot. Start returns +a random opaque handle. Finish consumes it and waits for capture to stop before +downmixing, resampling to 16 kHz, optional silence gating and PCM16 WAV encoding. +Cancel consumes the recording and waits for device shutdown, discarding its +audio and capture errors. Invalid gate thresholds retain the lease so it can +be canceled. Setup, processing and storage failures release device capacity. +Dropping the module drops active library handles, signaling their device threads +to stop. + +Prepared outputs have distinct opaque handles and require explicit release. +There are at most four outputs totaling 32 MiB. Reads return at most 256 KiB +per call; EOF returns an empty string. Unknown, consumed and released handles +fail explicitly. Preparation checks expanded sample count before resampling +and checks encoded and aggregate size afterward. No transport call occurs per +audio sample. + +CaptureError is a tagged serialized result. Device detail supports product +presentation only. Host telemetry must use safe reason codes and omit device +names, error detail, handles, audio, paths and request arguments. + +Local fixtures exercise lifecycle, bounds, faults and in-memory bus dispatch +without hardware or external services. The actual compiled artifact verifier +checks all 21 declared members and permission denial without opening a device. +The native device bridge follows the repository's existing physical-device +coverage exception; manager and processing code remain subject to the 90% +per-file threshold. + +The release workflow installs ALSA development headers for Linux module builds. +OpenHuman must pin a released artifact compatible with contract 1.1 and verify +its digest before switching callers. Continuous capture and hotkey lifecycle +operations are follow-up work; this change covers enumeration and one-shot +recording only. From 39ead47a502d6e0796790c6e6ef4c08f9129314d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 17:57:23 +0300 Subject: [PATCH 02/25] feat(capture): own bounded continuous streams and shutdown Co-authored-by: Medulla --- MODULE.md | 19 +- README.md | 20 +- crates/tinyvoice-bus/src/capture/mod.rs | 39 ++++ crates/tinyvoice-bus/src/capture/mod_tests.rs | 24 +++ crates/tinyvoice-bus/src/names/mod.rs | 9 + crates/tinyvoice-bus/src/version/mod.rs | 2 +- .../examples/verify_module.rs | 16 ++ .../src/service/capture/README.md | 38 ++-- .../src/service/capture/device_native.rs | 25 ++- .../src/service/capture/mod.rs | 79 +++++++- .../src/service/capture/mod_tests.rs | 191 ++++++++++++++++++ crates/tinyvoice-module/src/service/mod.rs | 38 +++- .../tinyvoice-module/src/service/mod_tests.rs | 53 +++++ crates/tinyvoice/src/capture/chunks.rs | 128 +++++++++--- crates/tinyvoice/src/capture/chunks_tests.rs | 126 +++++++++++- crates/tinyvoice/src/capture/device_stream.rs | 14 +- crates/tinyvoice/src/capture/mod.rs | 5 +- docs/specs/module-capture.md | 27 ++- 18 files changed, 759 insertions(+), 94 deletions(-) diff --git a/MODULE.md b/MODULE.md index ba8324a..8618462 100644 --- a/MODULE.md +++ b/MODULE.md @@ -4,7 +4,7 @@ This package contains the native `tinyvoice` module for TinyBus module ABI v1. Install only the archive matching the host operating system and architecture. The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at -`/ai/tinyhumans/tinyvoice/Voice`, and provides seven methods. +`/ai/tinyhumans/tinyvoice/Voice`, and provides 24 methods under contract 1.2. ## Methods @@ -25,6 +25,15 @@ The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at | `EncodeWav` | `samples` (`f32` mono), `sample_rate` | base64 WAV | | `EncodeWavPcm16` | `samples` (`i16`), `sample_rate`, `channels` | base64 WAV, samples unchanged | | `PrepareCapture` | `samples`, `source_rate`, `channels`, `gate_threshold` | base64 WAV | +| `ListInputDevices` | none | structured result of device names | +| `RecordingStart` | permission request | opaque recording handle | +| `RecordingFinish` | handle, gate threshold in one request | bounded WAV output handle and length | +| `RecordingCancel` | handle | structured result of unit | +| `ReadAudioOutput` | handle, offset, length in one request | base64 WAV slice | +| `ReleaseAudioOutput` | handle | structured result of unit | +| `CaptureStart` | permission request | opaque stream handle and native format | +| `CapturePoll` | handle, max_chunks in one request | ordered raw chunks and closed flag | +| `CaptureStop` | handle | structured result after native thread shutdown | Notes on the contract: @@ -52,6 +61,14 @@ Notes on the contract: **not** an error, so teardown cannot itself fail. - `VadPush` frame indices are relative to *that call*, not a running total. +Native capture operations return `CaptureResult` values rather than transport +errors. They require an explicit microphone grant from the host; denial never +opens a device. One active recording or stream owns the microphone slot. +Continuous polling returns at most two chunks from an eight-chunk queue, each +containing at most 32,768 interleaved f32 samples. Call `CaptureStop` even after +the queue closes to release the lease and observe terminal device errors. See +[the capture specification](docs/specs/module-capture.md) for all resource bounds. + ## Installing The archive contains one `.so`, `.dylib`, or `.dll` plus `modules.toml`. Keep diff --git a/README.md b/README.md index 0563fe0..03f105b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ permission check. | Here | With the host | | --- | --- | -| WAV framing, RMS, resampling, downmix, silence gate | Device capture (`cpal`) — a stream is `!Send` and needs the host's thread and permission model | +| WAV framing, RMS, resampling, downmix, silence gate; module-owned capture threads | Microphone permission decisions through the computer module | | VAD segmentation | The capture loop that drives it | | Wake-word gate, intent routing | What to *do* with an intent | | Hallucination detection | The STT transport, credentials, and retry policy | @@ -64,8 +64,8 @@ Run it: `cargo run -p tinyvoice --example basic`. `ActivationMode`, `HotkeyEvent`). Off by default so the library, and the loadable module built from it, carry no OS input hook. - `capture` (off by default): `tinyvoice::capture`, microphone input on `cpal`: - `start_recording` for a one-shot recording and `spawn_capture_thread` for a - continuous chunk stream, both returning the device's raw samples for + `start_recording` for a one-shot recording and `start_capture_stream` for a + continuous chunk stream with a stoppable thread handle, both returning the device's raw samples for `tinyvoice::audio` to process. Microphone permission is a `PermissionCheck` the host passes in. Off by default because it links the platform audio stack. @@ -85,10 +85,10 @@ session methods (`VadOpen` / `VadPush` / `VadReset` / `VadClose`) exist for it. An earlier version of this README claimed otherwise, on an assumption rather than a measurement. -The one thing that should stay on the host's side is whatever runs **inside the -audio callback**: `cpal` delivers on a realtime thread where blocking is a -dropout. Forward raw interleaved samples out of the callback and call -`PrepareFrames` from a worker — less work in the callback, not more. +The compiled module owns native capture threads and callbacks. `cpal` delivers +on a realtime thread where blocking causes dropouts, so callbacks forward raw +interleaved samples into a bounded queue. Hosts poll batches and call +`PrepareFrames` from a worker. ## Layout @@ -125,8 +125,8 @@ GPL-3.0-only. See [`LICENSE`](LICENSE). ## Module-owned recording -Contract 1.1 adds device enumeration and one-shot recording through opaque +Contract 1.2 adds device enumeration, one-shot recording and continuous capture through opaque handles. The host supplies a microphone permission grant obtained through TinyComputer; the compiled module owns the device, audio preparation, bounded -WAV reads, cancellation and release. See [capture contract](docs/specs/module-capture.md). -Continuous capture and hotkey bus lifecycles remain a separate migration. +WAV reads, bounded raw chunk polling, cancellation and release. See [capture contract](docs/specs/module-capture.md). +Hotkey bus lifecycles remain a separate migration. diff --git a/crates/tinyvoice-bus/src/capture/mod.rs b/crates/tinyvoice-bus/src/capture/mod.rs index 414f847..689f0cd 100644 --- a/crates/tinyvoice-bus/src/capture/mod.rs +++ b/crates/tinyvoice-bus/src/capture/mod.rs @@ -69,3 +69,42 @@ pub type CaptureResult = Result; #[cfg(test)] #[path = "mod_tests.rs"] mod tests; + +/// Native device format, reported once on stream startup. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CaptureFormat { + /// Native sample rate. + pub source_rate: u32, + /// Interleaved channel count. + pub channels: u16, +} +/// Bounded native callback buffer, processed through module audio operations. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawChunk { + /// Interleaved native f32 samples. + pub samples: Vec, +} +/// Opaque continuous capture lease and its native format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureStream { + /// Lease consumed by `CaptureStop`. + pub handle: CaptureHandle, + /// Native device format. + pub format: CaptureFormat, +} +/// Bounded batch read request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapturePollRequest { + /// Continuous capture lease. + pub handle: CaptureHandle, + /// At most two callback buffers per call. + pub max_chunks: usize, +} +/// Ordered bounded chunks, with terminal channel status. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CaptureBatch { + /// Retained native chunks in capture order. + pub chunks: Vec, + /// Native capture ended; stop releases the lease and reports its result. + pub closed: bool, +} diff --git a/crates/tinyvoice-bus/src/capture/mod_tests.rs b/crates/tinyvoice-bus/src/capture/mod_tests.rs index 4054c8a..4e5b414 100644 --- a/crates/tinyvoice-bus/src/capture/mod_tests.rs +++ b/crates/tinyvoice-bus/src/capture/mod_tests.rs @@ -29,3 +29,27 @@ fn native_faults_are_structured_product_results() -> Result<(), serde_json::Erro assert_eq!(serde_json::from_value::(wire)?, fault); Ok(()) } + +#[test] +fn continuous_format_and_batches_preserve_native_samples() -> Result<(), serde_json::Error> { + let stream = CaptureStream { + handle: CaptureHandle("lease".into()), + format: CaptureFormat { + source_rate: 48_000, + channels: 2, + }, + }; + let wire = serde_json::to_value(&stream)?; + let decoded: CaptureStream = serde_json::from_value(wire)?; + assert_eq!(decoded.format, stream.format); + let batch = CaptureBatch { + chunks: vec![RawChunk { + samples: vec![0.25, -0.25], + }], + closed: false, + }; + let decoded: CaptureBatch = serde_json::from_value(serde_json::to_value(&batch)?)?; + assert_eq!(decoded.chunks, batch.chunks); + assert!(!decoded.closed); + Ok(()) +} diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index 3b3ea4f..58a412c 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -8,6 +8,12 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { + /// Start native continuous capture. + pub const CAPTURE_START: &str = "CaptureStart"; + /// Read a bounded native chunk batch. + pub const CAPTURE_POLL: &str = "CapturePoll"; + /// Stop native continuous capture and release its lease. + pub const CAPTURE_STOP: &str = "CaptureStop"; /// Routes a wake-word-stripped transcript. pub const ROUTE: &str = "Route"; /// Removes a leading wake word from a transcript. @@ -59,6 +65,9 @@ pub mod methods { /// Every member of [`BUS_NAME`], in the interface's sorted dispatch order. pub const METHODS: &[&str] = &[ + methods::CAPTURE_POLL, + methods::CAPTURE_START, + methods::CAPTURE_STOP, methods::ENCODE_WAV, methods::ENCODE_WAV_PCM16, methods::EXTRACT_COMMAND, diff --git a/crates/tinyvoice-bus/src/version/mod.rs b/crates/tinyvoice-bus/src/version/mod.rs index 478a2bc..fb39774 100644 --- a/crates/tinyvoice-bus/src/version/mod.rs +++ b/crates/tinyvoice-bus/src/version/mod.rs @@ -1,7 +1,7 @@ //! The `TinyVoice` `TinyBus` contract version and its binding rule. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 1); +pub const CONTRACT_VERSION: (u32, u32) = (1, 2); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// diff --git a/crates/tinyvoice-module/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index 9741ba6..f0b05c0 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -79,6 +79,22 @@ async fn main() -> Result<(), Box> { return Err(io::Error::other("module did not enforce permission decision").into()); } + let denied: tinyvoice_bus::capture::CaptureResult = + proxy + .call( + tinyvoice_bus::names::methods::CAPTURE_START, + (tinyvoice_bus::capture::RecordingStartRequest::default(),), + ) + .await?; + if !matches!( + denied, + Err(tinyvoice_bus::capture::CaptureError::PermissionDenied) + ) { + return Err( + std::io::Error::other("continuous capture did not refuse missing permission").into(), + ); + } + println!( "verified {} as TinyBus module `{}`", module.display(), diff --git a/crates/tinyvoice-module/src/service/capture/README.md b/crates/tinyvoice-module/src/service/capture/README.md index adb435c..605c7c9 100644 --- a/crates/tinyvoice-module/src/service/capture/README.md +++ b/crates/tinyvoice-module/src/service/capture/README.md @@ -1,24 +1,22 @@ -# Native recording ownership +# Native capture ownership -The capture manager owns a single active device lease, including finish and -preparation, and up to four WAV outputs totaling 32 MiB. Permission must be an -explicit host grant obtained through TinyComputer before the native backend is -called. The backend preserves the library's dedicated-thread CPAL lifecycle. +`Capture` owns one microphone slot shared by recording and continuous streams. +Each live resource holds a `BusyGuard`; failures and drop release the slot. +`Backend`, `Recording` and `Stream` allow lifecycle fixtures without hardware. +The production bridge in `device_native.rs` opens CPAL through the library's +existing dedicated thread behavior only after the host supplies permission. -RecordingStart returns a random opaque handle. RecordingFinish consumes it, -waits for device shutdown, downmixes/resamples/gates inside the module, and -returns an output handle. RecordingCancel consumes the recording and waits for -its device thread to finish. Dropping the module stops remaining handles through -the existing library Drop implementation. Invalid processing arguments retain -the recording so the host can cancel it. Device/setup faults release capacity. +Recording leases become bounded prepared WAV outputs when finished. Cancel +waits for device cleanup and discards samples. Invalid processing parameters +retain the recording lease so callers can retry or cancel. -ReadAudioOutput accepts at most 256 KiB per call and returns base64 WAV bytes; -ReleaseAudioOutput frees the held output. Unknown, consumed and released handles -fail explicitly. Output size is checked before resampling to bound allocation -and again against the aggregate store budget. No calls occur per audio sample. +Continuous leases use an eight-chunk native queue. Polling drains at most two +chunks without waiting, preserving native channels, sample rate and ordering. +The library drops callbacks larger than 32,768 samples and drops the newest +callback when the queue is full. Stop removes the lease, awaits thread cleanup +and reports terminal failures; drop requests shutdown without waiting. -The native bridge in device_native.rs touches real devices and follows the -repository's existing device-file coverage exception. Backend fixtures exercise -permission admission, exclusivity, cancellation, preparation, bounded reads, -release and faults without hardware. Native fault detail is for product -presentation only; host telemetry must use the safe reason code without detail. +Mutex poisoning fails closed. Unknown and consumed handles fail explicitly. +Opaque random handles prevent accidental rebinding across module reloads. +Tests exercise permission denial, exclusivity, bounds, restart, failed setup, +failed stop, manager drop, serialization and poisoned tables. diff --git a/crates/tinyvoice-module/src/service/capture/device_native.rs b/crates/tinyvoice-module/src/service/capture/device_native.rs index fd88b1f..5a647c7 100644 --- a/crates/tinyvoice-module/src/service/capture/device_native.rs +++ b/crates/tinyvoice-module/src/service/capture/device_native.rs @@ -1,7 +1,7 @@ //! Native device I/O bridge. Fixtures exercise the manager through its backend seam. //! Real device enumeration/open/stop require hardware and permission and use //! the repository's established device-file coverage exclusion. -use super::{Backend, Recording, RecordingFuture}; +use super::{Backend, Recording, RecordingFuture, Stream, StreamFuture}; use tinyvoice::capture::RecordingHandle; impl Recording for RecordingHandle { fn finish(self: Box) -> RecordingFuture { @@ -14,6 +14,12 @@ impl Backend for Native { fn devices(&self) -> Result, String> { tinyvoice::capture::list_input_devices().map_err(|error| error.to_string()) } + fn stream(&self) -> Result<(tinyvoice_bus::capture::CaptureFormat, Box), String> { + let (tx, rx) = tokio::sync::mpsc::channel(8); + let (format, handle) = tinyvoice::capture::start_capture_stream(tx, || Ok(())) + .map_err(|error| error.to_string())?; + Ok((format, Box::new(NativeStream { rx, handle }))) + } fn start(&self) -> Result, String> { // The manager refuses anything except the host's computer-module grant // before entering this backend. No permission decision is made locally. @@ -22,3 +28,20 @@ impl Backend for Native { .map_err(|error| error.to_string()) } } + +#[derive(Debug)] +struct NativeStream { + rx: tokio::sync::mpsc::Receiver, + handle: tinyvoice::capture::CaptureStreamHandle, +} +impl Stream for NativeStream { + fn poll( + &mut self, + max_chunks: usize, + ) -> tinyvoice_bus::capture::CaptureResult { + Ok(super::poll_chunks(&mut self.rx, max_chunks)) + } + fn stop(self: Box) -> StreamFuture { + Box::pin(async move { self.handle.stop().await.map_err(|error| error.to_string()) }) + } +} diff --git a/crates/tinyvoice-module/src/service/capture/mod.rs b/crates/tinyvoice-module/src/service/capture/mod.rs index 35f7699..a93e531 100644 --- a/crates/tinyvoice-module/src/service/capture/mod.rs +++ b/crates/tinyvoice-module/src/service/capture/mod.rs @@ -1,4 +1,4 @@ -//! Native recording ownership and bounded prepared audio outputs. +//! Native recording/stream ownership and bounded prepared audio outputs. use super::BASE64; use base64::Engine as _; use std::collections::HashMap; @@ -8,13 +8,18 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use tinyvoice::capture::RawRecording; use tinyvoice_bus::capture::{ - AudioOutput, CaptureError, CaptureHandle, CaptureResult, MicrophonePermission, - ReadAudioRequest, RecordingFinishRequest, + AudioOutput, CaptureBatch, CaptureError, CaptureHandle, CapturePollRequest, CaptureResult, + CaptureStream, MicrophonePermission, ReadAudioRequest, RecordingFinishRequest, }; const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024; const MAX_OUTPUTS: usize = 4; const MAX_READ_BYTES: usize = 256 * 1024; +type StreamFuture = Pin> + Send>>; +pub(super) trait Stream: std::fmt::Debug + Send { + fn poll(&mut self, max_chunks: usize) -> CaptureResult; + fn stop(self: Box) -> StreamFuture; +} type RecordingFuture = Pin> + Send>>; pub(super) trait Recording: std::fmt::Debug + Send { fn finish(self: Box) -> RecordingFuture; @@ -22,6 +27,7 @@ pub(super) trait Recording: std::fmt::Debug + Send { pub(super) trait Backend: std::fmt::Debug + Send + Sync { fn devices(&self) -> Result, String>; fn start(&self) -> Result, String>; + fn stream(&self) -> Result<(tinyvoice_bus::capture::CaptureFormat, Box), String>; } #[derive(Debug)] struct BusyGuard(Arc); @@ -36,11 +42,17 @@ struct Lease { busy: BusyGuard, } #[derive(Debug)] +struct StreamLease { + stream: Box, + busy: BusyGuard, +} +#[derive(Debug)] pub(super) struct Capture { backend: Arc, busy: Arc, recordings: Mutex>, outputs: Mutex>>, + streams: Mutex>, } impl Default for Capture { fn default() -> Self { @@ -54,6 +66,7 @@ impl Capture { busy: Arc::new(AtomicBool::new(false)), recordings: Mutex::new(HashMap::new()), outputs: Mutex::new(HashMap::new()), + streams: Mutex::new(HashMap::new()), } } pub(super) fn devices(&self) -> CaptureResult> { @@ -116,6 +129,48 @@ impl Capture { drop(busy); Ok(()) } + pub(super) fn stream_start( + &self, + permission: MicrophonePermission, + ) -> CaptureResult { + if permission != MicrophonePermission::Granted { + return Err(CaptureError::PermissionDenied); + } + self.busy + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .map_err(|_| CaptureError::Busy)?; + let busy = BusyGuard(self.busy.clone()); + let handle = handle()?; + let (format, stream) = self.backend.stream().map_err(CaptureError::Device)?; + self.streams + .lock() + .map_err(|_| CaptureError::Device("capture stream state unavailable".into()))? + .insert(handle.clone(), StreamLease { stream, busy }); + Ok(CaptureStream { handle, format }) + } + pub(super) fn stream_poll(&self, request: &CapturePollRequest) -> CaptureResult { + if !(1..=2).contains(&request.max_chunks) { + return Err(CaptureError::LimitExceeded); + } + self.streams + .lock() + .map_err(|_| CaptureError::Device("capture stream state unavailable".into()))? + .get_mut(&request.handle) + .ok_or(CaptureError::UnknownHandle)? + .stream + .poll(request.max_chunks) + } + pub(super) async fn stream_stop(&self, handle: &CaptureHandle) -> CaptureResult<()> { + let StreamLease { stream, busy } = self + .streams + .lock() + .map_err(|_| CaptureError::Device("capture stream state unavailable".into()))? + .remove(handle) + .ok_or(CaptureError::UnknownHandle)?; + let result = stream.stop().await.map_err(CaptureError::Device); + drop(busy); + result + } pub(super) fn read(&self, request: &ReadAudioRequest) -> CaptureResult { if request.length == 0 || request.length > MAX_READ_BYTES { return Err(CaptureError::LimitExceeded); @@ -200,3 +255,21 @@ pub(super) fn fixture_capture() -> Arc { } mod device_native; + +fn poll_chunks( + rx: &mut tokio::sync::mpsc::Receiver, + max_chunks: usize, +) -> CaptureBatch { + let mut batch = CaptureBatch::default(); + for _ in 0..max_chunks { + match rx.try_recv() { + Ok(chunk) => batch.chunks.push(chunk), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + batch.closed = true; + break; + } + } + } + batch +} diff --git a/crates/tinyvoice-module/src/service/capture/mod_tests.rs b/crates/tinyvoice-module/src/service/capture/mod_tests.rs index ef03725..ff9f4a7 100644 --- a/crates/tinyvoice-module/src/service/capture/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/capture/mod_tests.rs @@ -35,6 +35,27 @@ impl Backend for Fixture { Ok(vec!["fixture".into()]) } } + fn stream(&self) -> Result<(tinyvoice_bus::capture::CaptureFormat, Box), String> { + if self.fail { + return Err("fixture setup failure".into()); + } + let (tx, rx) = tokio::sync::mpsc::channel(8); + tx.try_send(tinyvoice::capture::RawChunk { + samples: vec![0.25; 16], + }) + .map_err(|error| error.to_string())?; + Ok(( + tinyvoice_bus::capture::CaptureFormat { + source_rate: 16_000, + channels: 1, + }, + Box::new(StreamFixture { + rx, + drops: self.drops.clone(), + fail_stop: false, + }), + )) + } fn start(&self) -> Result, String> { self.starts.fetch_add(1, Ordering::SeqCst); if self.fail { @@ -286,3 +307,173 @@ async fn preparing_with_a_poisoned_output_table_releases_the_recording() -> Capt capture.cancel(&restarted).await?; Ok(()) } + +#[derive(Debug)] +struct StreamFixture { + fail_stop: bool, + rx: tokio::sync::mpsc::Receiver, + drops: Arc, +} +impl Drop for StreamFixture { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } +} +impl Stream for StreamFixture { + fn poll(&mut self, max_chunks: usize) -> CaptureResult { + Ok(poll_chunks(&mut self.rx, max_chunks)) + } + fn stop(self: Box) -> StreamFuture { + Box::pin(async move { + let fail = self.fail_stop; + drop(self); + if fail { + Err("fixture terminal failure".into()) + } else { + Ok(()) + } + }) + } +} +#[tokio::test] +async fn continuous_capture_is_exclusive_drains_ordered_chunks_and_stops() -> CaptureResult<()> { + let manager = fixture_capture(); + assert_eq!( + manager.stream_start(MicrophonePermission::Denied).err(), + Some(CaptureError::PermissionDenied) + ); + let stream = manager.stream_start(MicrophonePermission::Granted)?; + assert_eq!( + manager.start(MicrophonePermission::Granted).err(), + Some(CaptureError::Busy) + ); + assert_eq!( + manager.stream_start(MicrophonePermission::Granted).err(), + Some(CaptureError::Busy) + ); + assert_eq!( + manager + .stream_poll(&CapturePollRequest { + handle: stream.handle.clone(), + max_chunks: 3 + }) + .err(), + Some(CaptureError::LimitExceeded) + ); + let batch = manager.stream_poll(&CapturePollRequest { + handle: stream.handle.clone(), + max_chunks: 2, + })?; + assert_eq!(batch.chunks[0].samples, vec![0.25; 16]); + assert!(batch.closed); + manager.stream_stop(&stream.handle).await?; + assert_eq!( + manager.stream_stop(&stream.handle).await.err(), + Some(CaptureError::UnknownHandle) + ); + assert_eq!( + manager + .stream_poll(&CapturePollRequest { + handle: stream.handle, + max_chunks: 1 + }) + .err(), + Some(CaptureError::UnknownHandle) + ); + let stream = manager.stream_start(MicrophonePermission::Granted)?; + manager.stream_stop(&stream.handle).await?; + Ok(()) +} +#[test] +fn chunk_poll_preserves_order_and_limits_batch_size() -> Result<(), Box> { + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + for i in [1.0, 2.0, 3.0] { + tx.try_send(tinyvoice::capture::RawChunk { samples: vec![i] })?; + } + let batch = poll_chunks(&mut rx, 2); + assert_eq!(batch.chunks.len(), 2); + assert_eq!(batch.chunks[1].samples, vec![2.0]); + assert!(!batch.closed); + assert_eq!(poll_chunks(&mut rx, 1).chunks[0].samples, vec![3.0]); + assert!(poll_chunks(&mut rx, 2).chunks.is_empty()); + drop(tx); + assert!(poll_chunks(&mut rx, 2).closed); + Ok(()) +} + +#[tokio::test] +async fn failed_stream_shutdown_releases_device_capacity() -> CaptureResult<()> { + let (capture, backend) = fixture(false); + let handle = CaptureHandle("failing stream".into()); + let (_tx, rx) = tokio::sync::mpsc::channel(8); + capture.busy.store(true, Ordering::SeqCst); + capture + .streams + .lock() + .map_err(|_| CaptureError::Device("fixture lock".into()))? + .insert( + handle.clone(), + StreamLease { + stream: Box::new(StreamFixture { + rx, + drops: backend.drops.clone(), + fail_stop: true, + }), + busy: BusyGuard(capture.busy.clone()), + }, + ); + assert_eq!( + capture.stream_stop(&handle).await, + Err(CaptureError::Device("fixture terminal failure".into())) + ); + assert_eq!(backend.drops.load(Ordering::SeqCst), 1); + let stream = capture.stream_start(MicrophonePermission::Granted)?; + capture.stream_stop(&stream.handle).await?; + Ok(()) +} + +#[test] +fn dropping_the_manager_releases_its_stream_and_failed_setup_releases_capacity() -> CaptureResult<()> +{ + let (capture, backend) = fixture(false); + let _stream = capture.stream_start(MicrophonePermission::Granted)?; + drop(capture); + assert_eq!(backend.drops.load(Ordering::SeqCst), 1); + let (capture, _) = fixture(true); + for _ in 0..2 { + assert_eq!( + capture.stream_start(MicrophonePermission::Granted).err(), + Some(CaptureError::Device("fixture setup failure".into())) + ); + assert!(!capture.busy.load(Ordering::SeqCst)); + } + Ok(()) +} + +#[tokio::test] +async fn poisoned_stream_state_fails_closed_and_drops_unregistered_capture() -> CaptureResult<()> { + let (capture, backend) = fixture(false); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = capture.streams.lock(); + std::panic::resume_unwind(Box::new("fixture poison")); + })); + let handle = CaptureHandle("unknown".into()); + assert!(matches!( + capture.stream_start(MicrophonePermission::Granted), + Err(CaptureError::Device(_)) + )); + assert_eq!(backend.drops.load(Ordering::SeqCst), 1); + assert!(!capture.busy.load(Ordering::SeqCst)); + assert!(matches!( + capture.stream_poll(&CapturePollRequest { + handle: handle.clone(), + max_chunks: 1 + }), + Err(CaptureError::Device(_)) + )); + assert!(matches!( + capture.stream_stop(&handle).await, + Err(CaptureError::Device(_)) + )); + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 3175ca3..6c40dee 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -161,13 +161,8 @@ fn to_json(value: &T) -> TinyBusResult { .map_err(|e| tinybus::Error::failed(format!("could not encode result: {e}"))) } -// Every operation underneath is synchronous — this crate exists to move pure -// functions across a bus, not to do I/O. `#[tinybus::interface]` still requires -// `async fn` signatures because dispatch awaits them, so the methods are async -// without awaiting anything. Making them genuinely async would mean inventing -// work for them to wait on. -// `#[tinybus::interface]` requires `async fn`; all work under this boundary is -// synchronous and changing these signatures would break the generated adapter. +// Pure audio operations use required async signatures; device startup runs +// on blocking workers and lifecycle completion awaits native cleanup. #[allow( clippy::unused_async, reason = "TinyBus interface methods require async signatures" @@ -183,6 +178,32 @@ impl VoiceService { .await .map_err(|_| tinybus::Error::failed("capture worker failed")) } + /// Start native continuous capture after a computer-module permission grant. + async fn capture_start( + &self, + request: tinyvoice_bus::capture::RecordingStartRequest, + ) -> TinyBusResult> + { + let capture = self.capture.clone(); + tokio::task::spawn_blocking(move || capture.stream_start(request.permission)) + .await + .map_err(|_| tinybus::Error::failed("capture start worker failed")) + } + /// Read at most two native chunks without blocking or per-sample requests. + async fn capture_poll( + &self, + request: tinyvoice_bus::capture::CapturePollRequest, + ) -> TinyBusResult> + { + Ok(self.capture.stream_poll(&request)) + } + /// Stop capture, wait for device cleanup and release its lease. + async fn capture_stop( + &self, + handle: tinyvoice_bus::capture::CaptureHandle, + ) -> TinyBusResult> { + Ok(self.capture.stream_stop(&handle).await) + } /// Open a native recording after an explicit permission decision. async fn recording_start( &self, @@ -565,6 +586,9 @@ tinybus_module::module_export_optional_static! { "VadClose", "PrepareFrames", "FrameEnergies", + "CaptureStart", + "CapturePoll", + "CaptureStop", "EncodeWav", "EncodeWavPcm16", "PrepareCapture", diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index 0ecdea2..a6dfb68 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -790,3 +790,56 @@ async fn prepare_capture_gates_inside_the_module_and_rejects_invalid_formats() - } Ok(()) } + +#[tokio::test] +async fn continuous_capture_lifecycle_executes_through_bus_fixtures() -> tinybus::Result<()> { + use tinyvoice_bus::capture::*; + let bus = MemoryBus::new(); + let task = Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await?).await?; + let fixture = VoiceService { + capture: super::capture::fixture_capture(), + ..VoiceService::default() + }; + service.serve_at(OBJECT_PATH.try_into()?, fixture).await?; + service.request_name(BUS_NAME).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME)?; + let denied: CaptureResult = proxy + .call( + names::methods::CAPTURE_START, + (RecordingStartRequest::default(),), + ) + .await?; + assert!(matches!(denied, Err(CaptureError::PermissionDenied))); + let stream: CaptureResult = proxy + .call( + names::methods::CAPTURE_START, + (RecordingStartRequest { + permission: MicrophonePermission::Granted, + },), + ) + .await?; + let stream = stream.unwrap(); + assert_eq!(stream.format.channels, 1); + let batch: CaptureResult = proxy + .call( + names::methods::CAPTURE_POLL, + (CapturePollRequest { + handle: stream.handle.clone(), + max_chunks: 2, + },), + ) + .await?; + assert_eq!(batch.unwrap().chunks[0].samples, vec![0.25; 16]); + let stopped: CaptureResult<()> = proxy + .call(names::methods::CAPTURE_STOP, (stream.handle.clone(),)) + .await?; + assert_eq!(stopped, Ok(())); + let missing: CaptureResult<()> = proxy + .call(names::methods::CAPTURE_STOP, (stream.handle,)) + .await?; + assert_eq!(missing, Err(CaptureError::UnknownHandle)); + task.abort(); + Ok(()) +} diff --git a/crates/tinyvoice/src/capture/chunks.rs b/crates/tinyvoice/src/capture/chunks.rs index 97f341d..494316b 100644 --- a/crates/tinyvoice/src/capture/chunks.rs +++ b/crates/tinyvoice/src/capture/chunks.rs @@ -1,30 +1,16 @@ //! A continuous capture stream: raw device chunks into a bounded channel. -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use super::PermissionCheck; const LOG_PREFIX: &str = "[voice::always_on]"; -/// One chunk of raw capture, exactly as the device delivered it. -/// -/// Interleaved and at the device's own rate: the callback converts the sample -/// format and nothing else, so [`CaptureFormat`] travels separately for the -/// consumer to hand to [`crate::audio`]. -#[derive(Debug)] -pub struct RawChunk { - /// Interleaved `f32` samples. - pub samples: Vec, -} +pub use tinyvoice_bus::capture::{CaptureFormat, RawChunk}; -/// The device format, learned once when the stream is built. -#[derive(Debug, Clone, Copy)] -pub struct CaptureFormat { - /// Device sample rate, before resampling to [`super::TARGET_SAMPLE_RATE`]. - pub source_rate: u32, - /// Interleaved channel count. - pub channels: u16, -} +/// Maximum native samples retained in one callback buffer. +pub const MAX_CHUNK_SAMPLES: usize = 32_768; /// Chunks the capture callback had to drop because the queue was full. /// @@ -38,6 +24,7 @@ static DROPPED_CHUNKS: AtomicU64 = AtomicU64::new(0); /// then keep the stream alive. pub(crate) type StreamBody = Box< dyn FnOnce( + Arc, tokio::sync::mpsc::Sender, &std::sync::mpsc::SyncSender>, ) -> Result<(), String> @@ -61,7 +48,9 @@ pub fn spawn_capture_thread( ) -> crate::Result { spawn_stream_thread( tx, - Box::new(move |tx, setup| super::device_stream::capture_on_thread(permission, tx, setup)), + Box::new(move |stop, tx, setup| { + super::device_stream::capture_on_thread(permission, stop, tx, setup) + }), ) .map_err(crate::Error::Capture) } @@ -71,20 +60,85 @@ pub(crate) fn spawn_stream_thread( tx: tokio::sync::mpsc::Sender, body: StreamBody, ) -> Result { + let (format, mut handle) = spawn_tracked_stream(tx, body)?; + // Compatibility API: receiver closure ends this stream, without returning + // a handle. The tracked API additionally offers explicit stop and await. + handle.stop_flag.take(); + Ok(format) +} + +/// Owns a continuous capture thread. Dropping it requests shutdown. +#[derive(Debug)] +pub struct CaptureStreamHandle { + stop_flag: Option>, + completed: tokio::sync::oneshot::Receiver>, +} +impl CaptureStreamHandle { + /// Request shutdown and wait until the native stream has been dropped. + /// + /// # Errors + /// Returns a terminal device error or a vanished capture-thread error. + pub async fn stop(mut self) -> crate::Result<()> { + if let Some(stop) = &self.stop_flag { + stop.store(true, Ordering::SeqCst); + } + (&mut self.completed) + .await + .map_err(|_| { + crate::Error::Capture("capture stream task dropped before completing".into()) + })? + .map_err(crate::Error::Capture) + } +} +impl Drop for CaptureStreamHandle { + fn drop(&mut self) { + if let Some(stop) = &self.stop_flag { + stop.store(true, Ordering::SeqCst); + } + } +} +/// Start continuous capture with explicit shutdown ownership. +/// +/// # Errors +/// Returns permission, device, format or thread startup errors. +pub fn start_capture_stream( + tx: tokio::sync::mpsc::Sender, + permission: PermissionCheck, +) -> crate::Result<(CaptureFormat, CaptureStreamHandle)> { + spawn_tracked_stream( + tx, + Box::new(move |stop, tx, setup| { + super::device_stream::capture_on_thread(permission, stop, tx, setup) + }), + ) + .map_err(crate::Error::Capture) +} +fn spawn_tracked_stream( + tx: tokio::sync::mpsc::Sender, + body: StreamBody, +) -> Result<(CaptureFormat, CaptureStreamHandle), String> { let (setup_tx, setup_rx) = std::sync::mpsc::sync_channel::>(1); + let stop_flag = Arc::new(AtomicBool::new(false)); + let worker_stop = stop_flag.clone(); + let (done_tx, completed) = tokio::sync::oneshot::channel(); std::thread::Builder::new() .name("voice-always-on".into()) .spawn(move || { - if let Err(e) = body(tx, &setup_tx) { - log::warn!("{LOG_PREFIX} capture thread error: {e}"); - let _ = setup_tx.send(Err(e)); + let result = body(worker_stop, tx, &setup_tx); + if let Err(error) = &result { + let _ = setup_tx.send(Err(error.clone())); } + let _ = done_tx.send(result); }) - .map_err(|e| format!("failed to spawn always-on capture thread: {e}"))?; + .map_err(|error| format!("failed to spawn always-on capture thread: {error}"))?; + let handle = CaptureStreamHandle { + stop_flag: Some(stop_flag), + completed, + }; match setup_rx.recv() { - Ok(Ok(format)) => Ok(format), - Ok(Err(e)) => Err(e), - Err(_) => Err("always-on capture thread exited before signalling readiness".to_string()), + Ok(Ok(format)) => Ok((format, handle)), + Ok(Err(error)) => Err(error), + Err(_) => Err("always-on capture thread exited before signalling readiness".into()), } } @@ -99,7 +153,7 @@ pub(crate) fn spawn_stream_thread( /// A send error also covers the consumer being gone (shutdown), which is why /// neither case is fatal here. pub(crate) fn forward(tx: &tokio::sync::mpsc::Sender, samples: Vec) { - if tx.try_send(RawChunk { samples }).is_err() { + if samples.len() > MAX_CHUNK_SAMPLES || tx.try_send(RawChunk { samples }).is_err() { let dropped = DROPPED_CHUNKS.fetch_add(1, Ordering::Relaxed) + 1; // Log on a power-of-two schedule: a persistently overloaded consumer // should be visible without logging inside every callback once it @@ -113,3 +167,21 @@ pub(crate) fn forward(tx: &tokio::sync::mpsc::Sender, samples: Vec, + stop: &std::sync::atomic::AtomicBool, + errors: &std::sync::mpsc::Receiver, +) -> Result<(), String> { + loop { + if stop.load(Ordering::SeqCst) || tx.is_closed() { + return Ok(()); + } + match errors.recv_timeout(std::time::Duration::from_millis(50)) { + Ok(error) => return Err(error), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Ok(()), + } + } +} diff --git a/crates/tinyvoice/src/capture/chunks_tests.rs b/crates/tinyvoice/src/capture/chunks_tests.rs index 541251c..c7d990c 100644 --- a/crates/tinyvoice/src/capture/chunks_tests.rs +++ b/crates/tinyvoice/src/capture/chunks_tests.rs @@ -36,7 +36,7 @@ fn a_stream_that_comes_up_reports_its_format() { let (tx, _rx) = tokio::sync::mpsc::channel(1); let format = spawn_stream_thread( tx, - Box::new(|_tx, setup| { + Box::new(|_stop, _tx, setup| { setup .send(Ok(CaptureFormat { source_rate: 44_100, @@ -56,7 +56,7 @@ fn a_body_error_reaches_the_caller_with_its_reason() { let (tx, _rx) = tokio::sync::mpsc::channel(1); let error = spawn_stream_thread( tx, - Box::new(|_, _| Err("no default audio input device".into())), + Box::new(|_, _, _| Err("no default audio input device".into())), ) .unwrap_err(); assert_eq!(error, "no default audio input device"); @@ -67,7 +67,7 @@ fn a_reported_setup_failure_is_returned_as_is() { let (tx, _rx) = tokio::sync::mpsc::channel(1); let error = spawn_stream_thread( tx, - Box::new(|_, setup| { + Box::new(|_stop, _, setup| { setup.send(Err("permission denied".to_string())).unwrap(); Ok(()) }), @@ -79,7 +79,7 @@ fn a_reported_setup_failure_is_returned_as_is() { #[test] fn a_thread_that_dies_before_reporting_is_named_as_such() { let (tx, _rx) = tokio::sync::mpsc::channel(1); - let error = spawn_stream_thread(tx, Box::new(|_, _| Ok(()))).unwrap_err(); + let error = spawn_stream_thread(tx, Box::new(|_, _, _| Ok(()))).unwrap_err(); assert_eq!( error, "always-on capture thread exited before signalling readiness" @@ -96,3 +96,121 @@ fn a_denied_permission_stops_the_stream_before_any_device_is_touched() { crate::Error::Capture("microphone permission denied".into()) ); } + +#[test] +fn continuous_stream_exits_when_its_consumer_closes() { + let (tx, rx) = tokio::sync::mpsc::channel(1); + let (errors, error_rx) = std::sync::mpsc::channel(); + errors + .send("late device error after consumer closure".to_string()) + .unwrap(); + drop(rx); + assert!( + super::wait_for_stream_end(&tx, &std::sync::atomic::AtomicBool::new(false), &error_rx) + .is_ok() + ); +} + +#[tokio::test] +async fn tracked_stop_waits_for_thread_cleanup_and_drop_requests_shutdown() { + for explicit in [true, false] { + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (cleaned_tx, cleaned_rx) = tokio::sync::oneshot::channel(); + let (format, handle) = super::spawn_tracked_stream( + tx, + Box::new(move |stop, _tx, setup| { + setup + .send(Ok(CaptureFormat { + source_rate: 48_000, + channels: 2, + })) + .unwrap(); + while !stop.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + let _ = cleaned_tx.send(()); + Ok(()) + }), + ) + .unwrap(); + assert_eq!(format.source_rate, 48_000); + if explicit { + handle.stop().await.unwrap(); + } else { + drop(handle); + } + cleaned_rx.await.unwrap(); + } +} + +#[tokio::test] +async fn tracked_stream_surfaces_terminal_and_vanished_thread_errors() { + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (_, handle) = super::spawn_tracked_stream( + tx, + Box::new(|_stop, _tx, setup| { + setup + .send(Ok(CaptureFormat { + source_rate: 16_000, + channels: 1, + })) + .unwrap(); + Err("device disconnected".into()) + }), + ) + .unwrap(); + assert_eq!( + handle.stop().await.unwrap_err(), + crate::Error::Capture("device disconnected".into()) + ); + let (done, completed) = tokio::sync::oneshot::channel(); + drop(done); + let handle = super::CaptureStreamHandle { + stop_flag: Some(std::sync::Arc::new(std::sync::atomic::AtomicBool::new( + false, + ))), + completed, + }; + assert!( + handle + .stop() + .await + .unwrap_err() + .to_string() + .contains("task dropped") + ); + let (tx, _rx) = tokio::sync::mpsc::channel(1); + assert_eq!( + super::start_capture_stream(tx, || Err("denied".into())).unwrap_err(), + crate::Error::Capture("denied".into()) + ); +} + +#[test] +fn stream_lifetime_surfaces_device_errors_and_accepts_explicit_stop() { + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (errors, rx) = std::sync::mpsc::channel(); + errors.send("device disconnected".into()).unwrap(); + let stop = std::sync::atomic::AtomicBool::new(false); + assert_eq!( + super::wait_for_stream_end(&tx, &stop, &rx).unwrap_err(), + "device disconnected" + ); + stop.store(true, Ordering::SeqCst); + assert!(super::wait_for_stream_end(&tx, &stop, &rx).is_ok()); + drop(errors); + stop.store(false, Ordering::SeqCst); + assert!(super::wait_for_stream_end(&tx, &stop, &rx).is_ok()); +} + +#[tokio::test] +async fn oversized_native_buffers_are_dropped_before_queueing() { + let (tx, mut rx) = tokio::sync::mpsc::channel(2); + forward(&tx, vec![0.0; super::MAX_CHUNK_SAMPLES + 1]); + assert!(rx.try_recv().is_err()); + forward(&tx, vec![0.0; super::MAX_CHUNK_SAMPLES]); + assert_eq!( + rx.try_recv().unwrap().samples.len(), + super::MAX_CHUNK_SAMPLES + ); +} diff --git a/crates/tinyvoice/src/capture/device_stream.rs b/crates/tinyvoice/src/capture/device_stream.rs index d44f06b..c3ba078 100644 --- a/crates/tinyvoice/src/capture/device_stream.rs +++ b/crates/tinyvoice/src/capture/device_stream.rs @@ -1,5 +1,5 @@ //! The `cpal` half of the continuous stream: open the default input, forward -//! each callback's buffer, and keep the stream alive for the process lifetime. +//! each callback's buffer, and keep the stream alive until stopped or its consumer closes. //! //! Touches a real audio device, so like [`super::device_recording`] it is //! excluded from the per-file coverage gate; the forwarding, drop accounting and @@ -31,6 +31,7 @@ const LOG_PREFIX: &str = "[voice::always_on]"; /// where the right amount of work is the least possible. pub(crate) fn capture_on_thread( permission: PermissionCheck, + stop: std::sync::Arc, tx: tokio::sync::mpsc::Sender, setup_tx: &std::sync::mpsc::SyncSender>, ) -> Result<(), String> { @@ -60,6 +61,7 @@ pub(crate) fn capture_on_thread( "{LOG_PREFIX} capture device ready name='{device_name}' rate={source_rate}->{TARGET_SAMPLE_RATE} channels={channels} format={sample_format:?}" ); + let lifecycle_tx = tx.clone(); let send_chunk = move |samples: Vec| forward(&tx, samples); let (stream_error_tx, stream_error_rx) = mpsc::channel(); @@ -104,13 +106,5 @@ pub(crate) fn capture_on_thread( })); log::info!("{LOG_PREFIX} microphone stream live"); - // Keep the stream alive until the device reports a terminal stream error. - // Returning drops the sender, allowing the consumer to observe channel closure. - loop { - match stream_error_rx.recv_timeout(std::time::Duration::from_secs(3600)) { - Ok(error) => return Err(error), - Err(mpsc::RecvTimeoutError::Timeout) => {} - Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()), - } - } + super::chunks::wait_for_stream_end(&lifecycle_tx, &stop, &stream_error_rx) } diff --git a/crates/tinyvoice/src/capture/mod.rs b/crates/tinyvoice/src/capture/mod.rs index 58c3e6e..255ed76 100644 --- a/crates/tinyvoice/src/capture/mod.rs +++ b/crates/tinyvoice/src/capture/mod.rs @@ -34,7 +34,10 @@ mod device_recording; mod device_stream; mod recording; -pub use chunks::{CaptureFormat, RawChunk, spawn_capture_thread}; +pub use chunks::{ + CaptureFormat, CaptureStreamHandle, MAX_CHUNK_SAMPLES, RawChunk, spawn_capture_thread, + start_capture_stream, +}; pub use device_recording::list_input_devices; pub use recording::{RawRecording, RecordingHandle, start_recording}; diff --git a/docs/specs/module-capture.md b/docs/specs/module-capture.md index 0bc3524..45e9f14 100644 --- a/docs/specs/module-capture.md +++ b/docs/specs/module-capture.md @@ -1,6 +1,6 @@ -# Module-owned recording contract +# Module-owned native capture contract -TinyVoice contract 1.1 adds six members while preserving every existing method's +TinyVoice contract 1.2 adds nine members while preserving every existing method's arity and wire format: | Member | One argument | Result | @@ -11,6 +11,9 @@ arity and wire format: | RecordingCancel | CaptureHandle | CaptureResult of unit | | ReadAudioOutput | ReadAudioRequest | CaptureResult of base64 WAV bytes | | ReleaseAudioOutput | CaptureHandle | CaptureResult of unit | +| CaptureStart | RecordingStartRequest | CaptureResult of CaptureStream | +| CapturePoll | CapturePollRequest | CaptureResult of CaptureBatch | +| CaptureStop | CaptureHandle | CaptureResult of unit | ListInputDevices has zero arguments. Each other new member takes exactly one. The contract contains serialized vocabulary only; CPAL, native device threads, @@ -20,7 +23,7 @@ The host obtains the microphone decision through TinyComputer and passes an explicit Granted value. Denied is the default and fails before device access. The module preserves the library's dedicated-thread native capture behavior. -Only one recording or finishing operation can own the device slot. Start returns +Only one recording, continuous stream or finishing operation can own the device slot. Start returns a random opaque handle. Finish consumes it and waits for capture to stop before downmixing, resampling to 16 kHz, optional silence gating and PCM16 WAV encoding. Cancel consumes the recording and waits for device shutdown, discarding its @@ -42,13 +45,21 @@ names, error detail, handles, audio, paths and request arguments. Local fixtures exercise lifecycle, bounds, faults and in-memory bus dispatch without hardware or external services. The actual compiled artifact verifier -checks all 21 declared members and permission denial without opening a device. +checks all 24 declared members and permission denial without opening a device. The native device bridge follows the repository's existing physical-device coverage exception; manager and processing code remain subject to the 90% per-file threshold. The release workflow installs ALSA development headers for Linux module builds. -OpenHuman must pin a released artifact compatible with contract 1.1 and verify -its digest before switching callers. Continuous capture and hotkey lifecycle -operations are follow-up work; this change covers enumeration and one-shot -recording only. +OpenHuman must pin a released artifact compatible with contract 1.2 and verify +its digest before switching callers. Hotkey lifecycle operations remain follow-up work. + +Continuous capture returns an opaque handle and native sample rate/channel +format. Native callbacks forward interleaved f32 samples through an eight-chunk +queue without blocking. Full queues and callbacks exceeding 32,768 samples drop +the newest callback; polling drains one or two chunks in order, reports whether +the sender has closed, and rejects larger batches. Stop consumes the handle, +waits for native thread cleanup, and reports any terminal device failure. A +failed setup or stop releases device capacity so callers can restart. Dropping +the manager requests shutdown; closing the consumer also ends native capture. +Neither stream polling nor device callbacks run STT, VAD or product policy. From 570735a35ee6f2f83ed1cfd2eec87888ed09374b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 18:27:35 +0300 Subject: [PATCH 03/25] fix: retain native capture ownership through cancellation and shutdown Co-authored-by: Medulla --- MODULE.md | 8 +- README.md | 2 +- crates/tinyvoice-bus/src/capture/mod.rs | 11 +- crates/tinyvoice-bus/src/names/mod.rs | 6 + crates/tinyvoice-bus/src/version/mod.rs | 2 +- .../examples/verify_module.rs | 19 + .../src/service/capture/README.md | 15 +- .../src/service/capture/mod.rs | 263 ++++++- .../src/service/capture/mod_tests.rs | 667 +++++++++++++++++- .../src/service/capture/startup.rs | 234 ++++++ .../src/service/capture/startup_tests.rs | 218 ++++++ crates/tinyvoice-module/src/service/mod.rs | 78 +- .../tinyvoice-module/src/service/mod_tests.rs | 74 +- docs/specs/module-capture.md | 42 +- 14 files changed, 1546 insertions(+), 93 deletions(-) create mode 100644 crates/tinyvoice-module/src/service/capture/startup.rs create mode 100644 crates/tinyvoice-module/src/service/capture/startup_tests.rs diff --git a/MODULE.md b/MODULE.md index 8618462..0841808 100644 --- a/MODULE.md +++ b/MODULE.md @@ -4,7 +4,7 @@ This package contains the native `tinyvoice` module for TinyBus module ABI v1. Install only the archive matching the host operating system and architecture. The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at -`/ai/tinyhumans/tinyvoice/Voice`, and provides 24 methods under contract 1.2. +`/ai/tinyhumans/tinyvoice/Voice`, and provides 26 methods under contract 1.3. ## Methods @@ -26,12 +26,14 @@ The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at | `EncodeWavPcm16` | `samples` (`i16`), `sample_rate`, `channels` | base64 WAV, samples unchanged | | `PrepareCapture` | `samples`, `source_rate`, `channels`, `gate_threshold` | base64 WAV | | `ListInputDevices` | none | structured result of device names | -| `RecordingStart` | permission request | opaque recording handle | +| `CaptureShutdown` | none | await native shutdown, terminal for this instance | +| `ReserveCapture` | permission request | known reservation handle | +| `RecordingStart` | permission and reserved handle | opaque recording handle | | `RecordingFinish` | handle, gate threshold in one request | bounded WAV output handle and length | | `RecordingCancel` | handle | structured result of unit | | `ReadAudioOutput` | handle, offset, length in one request | base64 WAV slice | | `ReleaseAudioOutput` | handle | structured result of unit | -| `CaptureStart` | permission request | opaque stream handle and native format | +| `CaptureStart` | permission and reserved handle | opaque stream handle and native format | | `CapturePoll` | handle, max_chunks in one request | ordered raw chunks and closed flag | | `CaptureStop` | handle | structured result after native thread shutdown | diff --git a/README.md b/README.md index 03f105b..661384f 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ GPL-3.0-only. See [`LICENSE`](LICENSE). ## Module-owned recording -Contract 1.2 adds device enumeration, one-shot recording and continuous capture through opaque +Contract 1.3 adds device enumeration, reserved startup/cancellation, one-shot recording and continuous capture through opaque handles. The host supplies a microphone permission grant obtained through TinyComputer; the compiled module owns the device, audio preparation, bounded WAV reads, bounded raw chunk polling, cancellation and release. See [capture contract](docs/specs/module-capture.md). diff --git a/crates/tinyvoice-bus/src/capture/mod.rs b/crates/tinyvoice-bus/src/capture/mod.rs index 689f0cd..5875749 100644 --- a/crates/tinyvoice-bus/src/capture/mod.rs +++ b/crates/tinyvoice-bus/src/capture/mod.rs @@ -14,11 +14,14 @@ pub enum MicrophonePermission { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct CaptureHandle(pub String); -/// Start a recording using an explicit permission decision. +/// Reserve or start capture using an explicit permission decision. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RecordingStartRequest { /// Permission granted through the computer module. pub permission: MicrophonePermission, + /// Module-generated reservation obtained before starting native setup. + #[serde(default)] + pub handle: Option, } /// Finish capture and run the module's existing preparation pipeline. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -31,7 +34,7 @@ pub struct RecordingFinishRequest { /// Prepared WAV held inside the module for bounded reads. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AudioOutput { - /// Lease released with `ReleaseAudioOutput`. + /// Caller-known recording lease, released with `ReleaseAudioOutput`. pub handle: CaptureHandle, /// WAV bytes available. pub length: usize, @@ -52,6 +55,10 @@ pub struct ReadAudioRequest { pub enum CaptureError { /// Permission was not explicitly granted. PermissionDenied, + /// Startup was canceled before its resource was delivered. + Cancelled, + /// Capture shutdown has closed this module instance. + Closed, /// A recording or finishing operation already owns the device slot. Busy, /// The recording or output handle is unknown or released. diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index 58a412c..6506406 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -8,6 +8,10 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { + /// Close capture and await pending native cleanup. + pub const CAPTURE_SHUTDOWN: &str = "CaptureShutdown"; + /// Reserve an opaque cancellation handle before native startup. + pub const RESERVE_CAPTURE: &str = "ReserveCapture"; /// Start native continuous capture. pub const CAPTURE_START: &str = "CaptureStart"; /// Read a bounded native chunk batch. @@ -66,6 +70,7 @@ pub mod methods { /// Every member of [`BUS_NAME`], in the interface's sorted dispatch order. pub const METHODS: &[&str] = &[ methods::CAPTURE_POLL, + methods::CAPTURE_SHUTDOWN, methods::CAPTURE_START, methods::CAPTURE_STOP, methods::ENCODE_WAV, @@ -81,6 +86,7 @@ pub const METHODS: &[&str] = &[ methods::RECORDING_FINISH, methods::RECORDING_START, methods::RELEASE_AUDIO_OUTPUT, + methods::RESERVE_CAPTURE, methods::ROUTE, methods::SEGMENT, methods::VAD_CLOSE, diff --git a/crates/tinyvoice-bus/src/version/mod.rs b/crates/tinyvoice-bus/src/version/mod.rs index fb39774..0bb1cfe 100644 --- a/crates/tinyvoice-bus/src/version/mod.rs +++ b/crates/tinyvoice-bus/src/version/mod.rs @@ -1,7 +1,7 @@ //! The `TinyVoice` `TinyBus` contract version and its binding rule. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 2); +pub const CONTRACT_VERSION: (u32, u32) = (1, 3); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// diff --git a/crates/tinyvoice-module/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index f0b05c0..8a0658d 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -95,6 +95,25 @@ async fn main() -> Result<(), Box> { ); } + let denied: tinyvoice_bus::capture::CaptureResult = + proxy + .call( + tinyvoice_bus::names::methods::RESERVE_CAPTURE, + (tinyvoice_bus::capture::RecordingStartRequest::default(),), + ) + .await?; + if !matches!( + denied, + Err(tinyvoice_bus::capture::CaptureError::PermissionDenied) + ) { + return Err(io::Error::other("reservation did not refuse missing permission").into()); + } + let closed: tinyvoice_bus::capture::CaptureResult<()> = proxy + .call(tinyvoice_bus::names::methods::CAPTURE_SHUTDOWN, ()) + .await?; + if closed.is_err() { + return Err(io::Error::other("capture shutdown failed").into()); + } println!( "verified {} as TinyBus module `{}`", module.display(), diff --git a/crates/tinyvoice-module/src/service/capture/README.md b/crates/tinyvoice-module/src/service/capture/README.md index 605c7c9..180eb08 100644 --- a/crates/tinyvoice-module/src/service/capture/README.md +++ b/crates/tinyvoice-module/src/service/capture/README.md @@ -1,12 +1,18 @@ # Native capture ownership `Capture` owns one microphone slot shared by recording and continuous streams. -Each live resource holds a `BusyGuard`; failures and drop release the slot. +Each live resource holds a `BusyGuard`; owned blocking workers retain it through +native cleanup/preparation even if the requesting future disappears. `Backend`, `Recording` and `Stream` allow lifecycle fixtures without hardware. The production bridge in `device_native.rs` opens CPAL through the library's existing dedicated thread behavior only after the host supplies permission. -Recording leases become bounded prepared WAV outputs when finished. Cancel +The host first reserves an opaque handle, then uses it for startup and cancellation. +Sixteen idle reservations expire after 60 seconds and hold no microphone capacity. +Pending cancellation awaits setup and cleanup; startup receiver loss also cleans up. + +Recording leases become bounded prepared WAV outputs under the same known handle +when finished. Release works while preparation is pending as well as after delivery. Cancel waits for device cleanup and discards samples. Invalid processing parameters retain the recording lease so callers can retry or cancel. @@ -20,3 +26,8 @@ Mutex poisoning fails closed. Unknown and consumed handles fail explicitly. Opaque random handles prevent accidental rebinding across module reloads. Tests exercise permission denial, exclusivity, bounds, restart, failed setup, failed stop, manager drop, serialization and poisoned tables. + +CaptureShutdown is terminal: it freezes startup publication, cancels pending +preparation, discards outputs and awaits native workers. Hosts call it before +TinyBus ABI shutdown; runtime.shutdown_timeout alone cannot guarantee cleanup +of blocked native setup. Sign-out uses cancel/stop/release instead. diff --git a/crates/tinyvoice-module/src/service/capture/mod.rs b/crates/tinyvoice-module/src/service/capture/mod.rs index a93e531..a87b825 100644 --- a/crates/tinyvoice-module/src/service/capture/mod.rs +++ b/crates/tinyvoice-module/src/service/capture/mod.rs @@ -10,8 +10,11 @@ use tinyvoice::capture::RawRecording; use tinyvoice_bus::capture::{ AudioOutput, CaptureBatch, CaptureError, CaptureHandle, CapturePollRequest, CaptureResult, CaptureStream, MicrophonePermission, ReadAudioRequest, RecordingFinishRequest, + RecordingStartRequest, }; +const MAX_RESERVATIONS: usize = 16; +const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_secs(60); const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024; const MAX_OUTPUTS: usize = 4; const MAX_READ_BYTES: usize = 256 * 1024; @@ -26,14 +29,18 @@ pub(super) trait Recording: std::fmt::Debug + Send { } pub(super) trait Backend: std::fmt::Debug + Send + Sync { fn devices(&self) -> Result, String>; + fn prepare(&self, raw: &RawRecording, threshold: f32) -> CaptureResult> { + prepare(raw, threshold) + } fn start(&self) -> Result, String>; fn stream(&self) -> Result<(tinyvoice_bus::capture::CaptureFormat, Box), String>; } #[derive(Debug)] -struct BusyGuard(Arc); +struct BusyGuard(Arc, tokio::sync::watch::Sender); impl Drop for BusyGuard { fn drop(&mut self) { self.0.store(false, Ordering::SeqCst); + self.1.send_replace(true); } } #[derive(Debug)] @@ -47,11 +54,30 @@ struct StreamLease { busy: BusyGuard, } #[derive(Debug)] +struct PendingStart { + canceled: AtomicBool, + completed: tokio::sync::watch::Sender, +} +#[derive(Debug)] +struct Reservation { + created: std::time::Instant, + pending: Option>, +} +#[derive(Debug)] +pub(super) enum Started { + Recording(CaptureHandle), + Stream(CaptureStream), +} +#[derive(Debug)] pub(super) struct Capture { backend: Arc, busy: Arc, + idle: tokio::sync::watch::Sender, + closed: AtomicBool, recordings: Mutex>, - outputs: Mutex>>, + outputs: Arc>>>, + finishing: Arc>>>, + reservations: Mutex>, streams: Mutex>, } impl Default for Capture { @@ -64,22 +90,101 @@ impl Capture { Self { backend, busy: Arc::new(AtomicBool::new(false)), + idle: tokio::sync::watch::channel(true).0, + closed: AtomicBool::new(false), recordings: Mutex::new(HashMap::new()), - outputs: Mutex::new(HashMap::new()), + outputs: Arc::new(Mutex::new(HashMap::new())), + finishing: Arc::new(Mutex::new(HashMap::new())), + reservations: Mutex::new(HashMap::new()), streams: Mutex::new(HashMap::new()), } } + pub(super) async fn shutdown(self: &Arc) -> CaptureResult<()> { + // Freeze publication under the same lock used by startup workers. + let pending = { + let mut reservations = self + .reservations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.closed.store(true, Ordering::SeqCst); + let pending: Vec<_> = reservations + .values() + .filter_map(|reservation| reservation.pending.clone()) + .collect(); + for start in &pending { + start.canceled.store(true, Ordering::SeqCst); + } + reservations.retain(|_, reservation| reservation.pending.is_some()); + pending + }; + { + let finishing = self + .finishing + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for canceled in finishing.values() { + canceled.store(true, Ordering::SeqCst); + } + self.outputs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } + let capture = self.clone(); + let runtime = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + let recordings: Vec<_> = capture + .recordings + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain() + .map(|(_, lease)| lease) + .collect(); + let streams: Vec<_> = capture + .streams + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain() + .map(|(_, lease)| lease) + .collect(); + for Lease { recording, busy } in recordings { + let _ = runtime.block_on(recording.finish()); + drop(busy); + } + for StreamLease { stream, busy } in streams { + let _ = runtime.block_on(stream.stop()); + drop(busy); + } + runtime.block_on(wait_for_idle(&capture.busy, capture.idle.subscribe()))?; + for start in pending { + let mut completed = start.completed.subscribe(); + while !*completed.borrow_and_update() { + if runtime.block_on(completed.changed()).is_err() { + return Err(state_error()); + } + } + } + Ok(()) + }) + .await + .map_err(|_| state_error())? + } pub(super) fn devices(&self) -> CaptureResult> { self.backend.devices().map_err(CaptureError::Device) } + #[cfg(test)] pub(super) fn start(&self, permission: MicrophonePermission) -> CaptureResult { if permission != MicrophonePermission::Granted { return Err(CaptureError::PermissionDenied); } + if self.closed.load(Ordering::SeqCst) { + return Err(CaptureError::Closed); + } self.busy .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .map_err(|_| CaptureError::Busy)?; - let busy = BusyGuard(self.busy.clone()); + self.idle.send_replace(false); + let busy = BusyGuard(self.busy.clone(), self.idle.clone()); let handle = handle()?; let recording = self.backend.start().map_err(CaptureError::Device)?; self.recordings @@ -102,33 +207,71 @@ impl Capture { if !request.gate_threshold.is_finite() || request.gate_threshold < 0.0 { return Err(CaptureError::InvalidParameters); } - let Lease { recording, busy } = self.take(&request.handle)?; - let raw = recording.finish().await.map_err(CaptureError::Device)?; - let wav = tokio::task::spawn_blocking(move || prepare(&raw, request.gate_threshold)) - .await - .map_err(|_| CaptureError::Device("audio preparation worker failed".into()))??; - let mut outputs = self - .outputs - .lock() - .map_err(|_| CaptureError::Device("audio output state unavailable".into()))?; - let total: usize = outputs.values().map(Vec::len).sum(); - if outputs.len() >= MAX_OUTPUTS || wav.len() > MAX_OUTPUT_BYTES.saturating_sub(total) { - return Err(CaptureError::LimitExceeded); - } - let handle = handle()?; - let length = wav.len(); - outputs.insert(handle.clone(), wav); - drop(busy); - Ok(AudioOutput { handle, length }) + let (recording, busy, canceled) = { + let mut finishing = self.finishing.lock().map_err(|_| state_error())?; + if self.closed.load(Ordering::SeqCst) { + return Err(CaptureError::Closed); + } + let Lease { recording, busy } = self.take(&request.handle)?; + let canceled = Arc::new(AtomicBool::new(false)); + finishing.insert(request.handle.clone(), canceled.clone()); + (recording, busy, canceled) + }; + let backend = self.backend.clone(); + let outputs = self.outputs.clone(); + let finishing = self.finishing.clone(); + let runtime = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + let result = (|| { + let raw = runtime + .block_on(recording.finish()) + .map_err(CaptureError::Device)?; + let wav = backend.prepare(&raw, request.gate_threshold)?; + let _finish_lock = finishing.lock().map_err(|_| state_error())?; + if canceled.load(Ordering::SeqCst) { + return Err(CaptureError::Cancelled); + } + let mut outputs = outputs.lock().map_err(|_| state_error())?; + let total: usize = outputs.values().map(Vec::len).sum(); + if outputs.len() >= MAX_OUTPUTS + || wav.len() > MAX_OUTPUT_BYTES.saturating_sub(total) + { + return Err(CaptureError::LimitExceeded); + } + let length = wav.len(); + outputs.insert(request.handle.clone(), wav); + Ok(AudioOutput { + handle: request.handle.clone(), + length, + }) + })(); + if let Ok(mut finishing) = finishing.lock() { + finishing.remove(&request.handle); + } + drop(busy); + result + }) + .await + .map_err(|_| state_error())? } pub(super) async fn cancel(&self, handle: &CaptureHandle) -> CaptureResult<()> { - let Lease { recording, busy } = self.take(handle)?; - // A canceled empty/failed recording is discarded, but the finish await - // still waits for its device thread to drop the native stream. - let _ = recording.finish().await; - drop(busy); + let was_pending = self.cancel_pending(handle).await?; + let lease = match self.take(handle) { + Ok(lease) => lease, + Err(CaptureError::UnknownHandle) if was_pending => return Ok(()), + Err(error) => return Err(error), + }; + let runtime = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + let Lease { recording, busy } = lease; + let _ = runtime.block_on(recording.finish()); + drop(busy); + }) + .await + .map_err(|_| state_error())?; Ok(()) } + #[cfg(test)] pub(super) fn stream_start( &self, permission: MicrophonePermission, @@ -136,10 +279,14 @@ impl Capture { if permission != MicrophonePermission::Granted { return Err(CaptureError::PermissionDenied); } + if self.closed.load(Ordering::SeqCst) { + return Err(CaptureError::Closed); + } self.busy .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .map_err(|_| CaptureError::Busy)?; - let busy = BusyGuard(self.busy.clone()); + self.idle.send_replace(false); + let busy = BusyGuard(self.busy.clone(), self.idle.clone()); let handle = handle()?; let (format, stream) = self.backend.stream().map_err(CaptureError::Device)?; self.streams @@ -161,15 +308,30 @@ impl Capture { .poll(request.max_chunks) } pub(super) async fn stream_stop(&self, handle: &CaptureHandle) -> CaptureResult<()> { - let StreamLease { stream, busy } = self + let was_pending = self.cancel_pending(handle).await?; + let lease = self .streams .lock() - .map_err(|_| CaptureError::Device("capture stream state unavailable".into()))? - .remove(handle) - .ok_or(CaptureError::UnknownHandle)?; - let result = stream.stop().await.map_err(CaptureError::Device); - drop(busy); - result + .map_err(|_| state_error())? + .remove(handle); + let Some(lease) = lease else { + return if was_pending { + Ok(()) + } else { + Err(CaptureError::UnknownHandle) + }; + }; + let runtime = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + let StreamLease { stream, busy } = lease; + let result = runtime + .block_on(stream.stop()) + .map_err(CaptureError::Device); + drop(busy); + result + }) + .await + .map_err(|_| state_error())? } pub(super) fn read(&self, request: &ReadAudioRequest) -> CaptureResult { if request.length == 0 || request.length > MAX_READ_BYTES { @@ -189,14 +351,42 @@ impl Capture { Ok(BASE64.encode(&wav[request.offset..end])) } pub(super) fn release(&self, handle: &CaptureHandle) -> CaptureResult<()> { + let finishing = self.finishing.lock().map_err(|_| state_error())?; + if let Some(canceled) = finishing.get(handle) { + canceled.store(true, Ordering::SeqCst); + self.outputs + .lock() + .map_err(|_| state_error())? + .remove(handle); + return Ok(()); + } self.outputs .lock() - .map_err(|_| CaptureError::Device("audio output state unavailable".into()))? + .map_err(|_| state_error())? .remove(handle) .map(|_| ()) .ok_or(CaptureError::UnknownHandle) } } +/// Notifications can arrive out of order across owners; only the slot is authoritative. +async fn wait_for_idle( + busy: &AtomicBool, + mut idle: tokio::sync::watch::Receiver, +) -> CaptureResult<()> { + loop { + // Register this notification generation before reading the predicate, + // so completion between the read and changed().await cannot be missed. + let _ = idle.borrow_and_update(); + if !busy.load(Ordering::SeqCst) { + return Ok(()); + } + idle.changed().await.map_err(|_| state_error())?; + } +} + +fn state_error() -> CaptureError { + CaptureError::Device("capture state unavailable".into()) +} fn handle() -> CaptureResult { let mut bytes = [0_u8; 16]; getrandom::fill(&mut bytes) @@ -255,6 +445,7 @@ pub(super) fn fixture_capture() -> Arc { } mod device_native; +mod startup; fn poll_chunks( rx: &mut tokio::sync::mpsc::Receiver, diff --git a/crates/tinyvoice-module/src/service/capture/mod_tests.rs b/crates/tinyvoice-module/src/service/capture/mod_tests.rs index ff9f4a7..d2630f6 100644 --- a/crates/tinyvoice-module/src/service/capture/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/capture/mod_tests.rs @@ -419,7 +419,7 @@ async fn failed_stream_shutdown_releases_device_capacity() -> CaptureResult<()> drops: backend.drops.clone(), fail_stop: true, }), - busy: BusyGuard(capture.busy.clone()), + busy: BusyGuard(capture.busy.clone(), capture.idle.clone()), }, ); assert_eq!( @@ -477,3 +477,668 @@ async fn poisoned_stream_state_fails_closed_and_drops_unregistered_capture() -> )); Ok(()) } + +#[derive(Debug)] +struct PendingRecording(tokio::sync::oneshot::Receiver<()>); +impl Recording for PendingRecording { + fn finish(self: Box) -> RecordingFuture { + Box::pin(async move { + let _ = self.0.await; + Ok(RawRecording { + samples: vec![0.25; 320], + source_rate: 16_000, + channels: 1, + }) + }) + } +} +#[tokio::test] +async fn dropping_cancel_future_keeps_device_busy_until_cleanup_completes() -> CaptureResult<()> { + let (capture, _) = fixture(false); + let handle = capture.start(MicrophonePermission::Granted)?; + let (done, pending) = tokio::sync::oneshot::channel(); + capture + .recordings + .lock() + .map_err(|_| state_error())? + .get_mut(&handle) + .ok_or(CaptureError::UnknownHandle)? + .recording = Box::new(PendingRecording(pending)); + let mut cancel = Box::pin(capture.cancel(&handle)); + std::future::poll_fn(|cx| { + assert!(cancel.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + drop(cancel); + assert_eq!( + capture.start(MicrophonePermission::Granted), + Err(CaptureError::Busy) + ); + let _ = done.send(()); + while capture.busy.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + let next = capture.start(MicrophonePermission::Granted)?; + capture.cancel(&next).await?; + Ok(()) +} + +#[derive(Debug)] +struct DelayedBackend { + entered: Mutex>>, + proceed: Mutex>, + drops: Arc, +} +impl Backend for DelayedBackend { + fn devices(&self) -> Result, String> { + Ok(vec![]) + } + fn start(&self) -> Result, String> { + if let Some(entered) = self + .entered + .lock() + .map_err(|error| error.to_string())? + .take() + { + let _ = entered.send(()); + } + self.proceed + .lock() + .map_err(|error| error.to_string())? + .recv() + .map_err(|error| error.to_string())?; + Ok(Box::new(RecordingFixture(self.drops.clone()))) + } + fn stream(&self) -> Result<(tinyvoice_bus::capture::CaptureFormat, Box), String> { + Err("fixture stream unavailable".into()) + } +} +#[tokio::test] +async fn abandoned_start_has_a_known_cancel_handle_and_waits_for_native_cleanup() +-> CaptureResult<()> { + let (entered, startup) = tokio::sync::oneshot::channel(); + let (proceed, blocked) = std::sync::mpsc::channel(); + let drops = Arc::new(AtomicUsize::new(0)); + let capture = Arc::new(Capture::new(Arc::new(DelayedBackend { + entered: Mutex::new(Some(entered)), + proceed: Mutex::new(blocked), + drops: drops.clone(), + }))); + let handle = capture.reserve(MicrophonePermission::Granted)?; + let service = crate::service::VoiceService { + capture: capture.clone(), + ..Default::default() + }; + let mut start = Box::pin(service.recording_start(RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(handle.clone()), + })); + std::future::poll_fn(|cx| { + assert!(start.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + startup.await.map_err(|_| state_error())?; + drop(start); + let mut cancel = Box::pin(capture.cancel(&handle)); + std::future::poll_fn(|cx| { + assert!(cancel.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + assert!(capture.busy.load(Ordering::SeqCst)); + proceed.send(()).map_err(|_| state_error())?; + cancel.await?; + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(!capture.busy.load(Ordering::SeqCst)); + assert!( + capture + .recordings + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + Ok(()) +} + +#[tokio::test] +async fn abandoned_finish_keeps_capacity_and_known_output_can_be_released_before_delivery() +-> CaptureResult<()> { + let (capture, _) = fixture(false); + let handle = capture.start(MicrophonePermission::Granted)?; + let (done, pending) = tokio::sync::oneshot::channel(); + capture + .recordings + .lock() + .map_err(|_| state_error())? + .get_mut(&handle) + .ok_or(CaptureError::UnknownHandle)? + .recording = Box::new(PendingRecording(pending)); + let mut finish = Box::pin(capture.finish(RecordingFinishRequest { + handle: handle.clone(), + gate_threshold: 0.0, + })); + std::future::poll_fn(|cx| { + assert!(finish.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + drop(finish); + assert_eq!( + capture.start(MicrophonePermission::Granted), + Err(CaptureError::Busy) + ); + capture.release(&handle)?; + let _ = done.send(()); + while capture.busy.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + assert!( + capture + .outputs + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!( + capture + .finishing + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + let handle = capture.start(MicrophonePermission::Granted)?; + let output = capture + .finish(RecordingFinishRequest { + handle: handle.clone(), + gate_threshold: 0.0, + }) + .await?; + assert_eq!(output.handle, handle); + capture.release(&handle)?; + Ok(()) +} +#[derive(Debug)] +struct PendingStream(tokio::sync::oneshot::Receiver<()>); +impl Stream for PendingStream { + fn poll(&mut self, _: usize) -> CaptureResult { + Ok(CaptureBatch::default()) + } + fn stop(self: Box) -> StreamFuture { + Box::pin(async move { + let _ = self.0.await; + Ok(()) + }) + } +} +#[tokio::test] +async fn abandoned_stream_stop_keeps_device_busy_until_native_cleanup() -> CaptureResult<()> { + let (capture, _) = fixture(false); + let stream = capture.stream_start(MicrophonePermission::Granted)?; + let (done, pending) = tokio::sync::oneshot::channel(); + capture + .streams + .lock() + .map_err(|_| state_error())? + .get_mut(&stream.handle) + .ok_or(CaptureError::UnknownHandle)? + .stream = Box::new(PendingStream(pending)); + let mut stop = Box::pin(capture.stream_stop(&stream.handle)); + std::future::poll_fn(|cx| { + assert!(stop.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + drop(stop); + assert_eq!( + capture.stream_start(MicrophonePermission::Granted).err(), + Some(CaptureError::Busy) + ); + let _ = done.send(()); + while capture.busy.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + let stream = capture.stream_start(MicrophonePermission::Granted)?; + capture.stream_stop(&stream.handle).await?; + Ok(()) +} +#[tokio::test] +async fn abandoned_reservations_are_bounded_expire_and_never_hold_device_capacity() +-> CaptureResult<()> { + let capture = Arc::new(fixture_capture()); + assert_eq!( + capture.reserve(MicrophonePermission::Denied), + Err(CaptureError::PermissionDenied) + ); + let handle = capture.reserve(MicrophonePermission::Granted)?; + let live = capture.start(MicrophonePermission::Granted)?; + capture.cancel(&live).await?; + for _ in 1..MAX_RESERVATIONS { + capture.reserve(MicrophonePermission::Granted)?; + } + assert_eq!( + capture.reserve(MicrophonePermission::Granted), + Err(CaptureError::LimitExceeded) + ); + capture + .reservations + .lock() + .map_err(|_| state_error())? + .values_mut() + .for_each(|reservation| reservation.created -= RESERVATION_TTL); + assert_eq!( + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(handle) + }, + false + ) + .await + .err(), + Some(CaptureError::UnknownHandle) + ); + let handle = capture.reserve(MicrophonePermission::Granted)?; + assert_eq!( + capture + .reservations + .lock() + .map_err(|_| state_error())? + .len(), + 1 + ); + capture.cancel(&handle).await?; + assert!( + capture + .reservations + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!(!capture.busy.load(Ordering::SeqCst)); + Ok(()) +} +#[tokio::test] +async fn reserved_start_rejects_missing_unknown_and_repeated_handles_and_recovers_setup_errors() +-> CaptureResult<()> { + let capture = Arc::new(fixture_capture()); + assert_eq!( + capture + .start_reserved(RecordingStartRequest::default(), false) + .await + .err(), + Some(CaptureError::PermissionDenied) + ); + assert_eq!( + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: None + }, + false + ) + .await + .err(), + Some(CaptureError::InvalidParameters) + ); + assert_eq!( + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(CaptureHandle("unknown".into())) + }, + false + ) + .await + .err(), + Some(CaptureError::UnknownHandle) + ); + let handle = capture.reserve(MicrophonePermission::Granted)?; + let request = RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(handle.clone()), + }; + let started = capture.start_reserved(request.clone(), false).await?; + assert!(matches!(started, Started::Recording(_))); + assert_eq!( + capture.start_reserved(request, false).await.err(), + Some(CaptureError::UnknownHandle) + ); + capture.cancel(&handle).await?; + let capture = Arc::new(fixture(true).0); + for stream in [false, true] { + let handle = capture.reserve(MicrophonePermission::Granted)?; + assert!(matches!( + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(handle) + }, + stream + ) + .await, + Err(CaptureError::Device(_)) + )); + assert!(!capture.busy.load(Ordering::SeqCst)); + } + Ok(()) +} + +#[tokio::test] +async fn shutdown_waits_for_delayed_start_and_prevents_publication() -> CaptureResult<()> { + let (entered, startup) = tokio::sync::oneshot::channel(); + let (proceed, blocked) = std::sync::mpsc::channel(); + let drops = Arc::new(AtomicUsize::new(0)); + let capture = Arc::new(Capture::new(Arc::new(DelayedBackend { + entered: Mutex::new(Some(entered)), + proceed: Mutex::new(blocked), + drops: drops.clone(), + }))); + let handle = capture.reserve(MicrophonePermission::Granted)?; + let request = RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(handle.clone()), + }; + let mut start = Box::pin(capture.start_reserved(request.clone(), false)); + std::future::poll_fn(|cx| { + assert!(start.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + startup.await.map_err(|_| state_error())?; + assert_eq!( + capture.start_reserved(request.clone(), false).await.err(), + Some(CaptureError::Busy) + ); + let other = capture.reserve(MicrophonePermission::Granted)?; + assert_eq!( + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(other) + }, + false + ) + .await + .err(), + Some(CaptureError::Busy) + ); + let mut shutdown = Box::pin(capture.shutdown()); + std::future::poll_fn(|cx| { + assert!(shutdown.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + assert!(capture.busy.load(Ordering::SeqCst)); + assert_eq!( + capture.reserve(MicrophonePermission::Granted), + Err(CaptureError::Closed) + ); + assert_eq!( + capture.start_reserved(request, false).await.err(), + Some(CaptureError::Closed) + ); + proceed.send(()).map_err(|_| state_error())?; + assert_eq!(start.await.err(), Some(CaptureError::Cancelled)); + shutdown.await?; + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!( + capture + .recordings + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!( + capture + .reservations + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + Ok(()) +} +#[tokio::test] +async fn shutdown_waits_for_detached_finish_cancel_and_stop_workers() -> CaptureResult<()> { + for operation in ["finish", "cancel", "stop"] { + let capture = Arc::new(fixture_capture()); + let (done, pending) = tokio::sync::oneshot::channel(); + let handle = if operation == "stop" { + let stream = capture.stream_start(MicrophonePermission::Granted)?; + capture + .streams + .lock() + .map_err(|_| state_error())? + .get_mut(&stream.handle) + .ok_or(CaptureError::UnknownHandle)? + .stream = Box::new(PendingStream(pending)); + stream.handle + } else { + let handle = capture.start(MicrophonePermission::Granted)?; + capture + .recordings + .lock() + .map_err(|_| state_error())? + .get_mut(&handle) + .ok_or(CaptureError::UnknownHandle)? + .recording = Box::new(PendingRecording(pending)); + handle + }; + let mut work: Pin>>> = match operation { + "finish" => Box::pin(async { + capture + .finish(RecordingFinishRequest { + handle: handle.clone(), + gate_threshold: 0.0, + }) + .await + .map(|_| ()) + }), + "cancel" => Box::pin(capture.cancel(&handle)), + _ => Box::pin(capture.stream_stop(&handle)), + }; + std::future::poll_fn(|cx| { + assert!(work.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + drop(work); + // Reproduce the stale notification from a predecessor guard. + capture.idle.send_replace(true); + let mut shutdown = Box::pin(capture.shutdown()); + std::future::poll_fn(|cx| { + assert!(shutdown.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + done.send(()).map_err(|()| state_error())?; + shutdown.await?; + assert!( + capture + .outputs + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!( + capture + .finishing + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!(!capture.busy.load(Ordering::SeqCst)); + assert_eq!( + capture + .finish(RecordingFinishRequest { + handle, + gate_threshold: 0.0 + }) + .await, + Err(CaptureError::Closed) + ); + } + Ok(()) +} +#[tokio::test] +async fn shutdown_drains_live_resources_and_idle_reservations() -> CaptureResult<()> { + for stream in [false, true] { + let capture = Arc::new(fixture_capture()); + let handle = capture.reserve(MicrophonePermission::Granted)?; + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(handle), + }, + stream, + ) + .await?; + capture.reserve(MicrophonePermission::Granted)?; + capture.shutdown().await?; + capture.shutdown().await?; + assert!( + capture + .recordings + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!( + capture + .streams + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!( + capture + .reservations + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert_eq!( + capture.start(MicrophonePermission::Granted), + Err(CaptureError::Closed) + ); + assert_eq!( + capture.stream_start(MicrophonePermission::Granted).err(), + Some(CaptureError::Closed) + ); + } + Ok(()) +} + +#[derive(Debug)] +struct DelayedPreparation { + entered: Mutex>>, + proceed: Mutex>, + fixture: Arc, +} +impl Backend for DelayedPreparation { + fn devices(&self) -> Result, String> { + self.fixture.devices() + } + fn start(&self) -> Result, String> { + self.fixture.start() + } + fn stream(&self) -> Result<(tinyvoice_bus::capture::CaptureFormat, Box), String> { + self.fixture.stream() + } + fn prepare(&self, raw: &RawRecording, threshold: f32) -> CaptureResult> { + if let Some(entered) = self.entered.lock().map_err(|_| state_error())?.take() { + let _ = entered.send(()); + } + self.proceed + .lock() + .map_err(|_| state_error())? + .recv() + .map_err(|_| state_error())?; + prepare(raw, threshold) + } +} +#[tokio::test] +async fn shutdown_waits_for_blocked_preparation_and_discards_its_late_output() -> CaptureResult<()> +{ + let (entered, preparing) = tokio::sync::oneshot::channel(); + let (proceed, blocked) = std::sync::mpsc::channel(); + let capture = Arc::new(Capture::new(Arc::new(DelayedPreparation { + entered: Mutex::new(Some(entered)), + proceed: Mutex::new(blocked), + fixture: fixture(false).1, + }))); + let handle = capture.start(MicrophonePermission::Granted)?; + let mut finish = Box::pin(capture.finish(RecordingFinishRequest { + handle: handle.clone(), + gate_threshold: 0.0, + })); + std::future::poll_fn(|cx| { + assert!(finish.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + preparing.await.map_err(|_| state_error())?; + drop(finish); + capture.release(&handle)?; + let mut shutdown = Box::pin(capture.shutdown()); + std::future::poll_fn(|cx| { + assert!(shutdown.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + assert!(capture.busy.load(Ordering::SeqCst)); + proceed.send(()).map_err(|_| state_error())?; + shutdown.await?; + assert!( + capture + .outputs + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!( + capture + .finishing + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + Ok(()) +} + +#[tokio::test] +async fn stale_predecessor_idle_notification_does_not_release_successor_cleanup() +-> CaptureResult<()> { + // Old guard publishes busy=false; successor claims busy=true/idle=false; + // old guard then publishes the stale idle=true notification. + let busy = AtomicBool::new(true); + let (changed, idle) = tokio::sync::watch::channel(true); + let mut cleanup = Box::pin(wait_for_idle(&busy, idle)); + std::future::poll_fn(|cx| { + assert!(cleanup.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + busy.store(false, Ordering::SeqCst); + changed.send_replace(true); + cleanup.await?; + Ok(()) +} + +#[tokio::test] +async fn lost_cleanup_notifications_fail_closed() { + let busy = AtomicBool::new(true); + let (changed, idle) = tokio::sync::watch::channel(true); + drop(changed); + assert!(matches!( + wait_for_idle(&busy, idle).await, + Err(CaptureError::Device(_)) + )); +} diff --git a/crates/tinyvoice-module/src/service/capture/startup.rs b/crates/tinyvoice-module/src/service/capture/startup.rs new file mode 100644 index 0000000..4114d30 --- /dev/null +++ b/crates/tinyvoice-module/src/service/capture/startup.rs @@ -0,0 +1,234 @@ +//! Known reservations and cancellation-safe native startup workers. +use super::{ + Arc, AtomicBool, BusyGuard, Capture, CaptureError, CaptureHandle, CaptureResult, CaptureStream, + Lease, MAX_RESERVATIONS, MicrophonePermission, Ordering, PendingStart, RESERVATION_TTL, + RecordingStartRequest, Reservation, Started, StreamLease, handle, state_error, +}; + +#[derive(Debug)] +enum NativeResource { + Recording(Box), + Stream( + tinyvoice_bus::capture::CaptureFormat, + Box, + ), +} +impl NativeResource { + fn discard(self, runtime: &tokio::runtime::Handle) { + match self { + Self::Recording(recording) => { + let _ = runtime.block_on(recording.finish()); + } + Self::Stream(_, stream) => { + let _ = runtime.block_on(stream.stop()); + } + } + } + fn publish( + self, + capture: &Capture, + handle: &CaptureHandle, + busy: BusyGuard, + ) -> Result { + match self { + Self::Recording(recording) => { + let Ok(mut recordings) = capture.recordings.lock() else { + return Err((Self::Recording(recording), busy)); + }; + recordings.insert(handle.clone(), Lease { recording, busy }); + Ok(Started::Recording(handle.clone())) + } + Self::Stream(format, stream) => { + let Ok(mut streams) = capture.streams.lock() else { + return Err((Self::Stream(format, stream), busy)); + }; + streams.insert(handle.clone(), StreamLease { stream, busy }); + Ok(Started::Stream(CaptureStream { + handle: handle.clone(), + format, + })) + } + } + } +} + +impl Capture { + pub(in crate::service) fn reserve( + &self, + permission: MicrophonePermission, + ) -> CaptureResult { + if permission != MicrophonePermission::Granted { + return Err(CaptureError::PermissionDenied); + } + let mut reservations = self.reservations.lock().map_err(|_| state_error())?; + if self.closed.load(Ordering::SeqCst) { + return Err(CaptureError::Closed); + } + reservations.retain(|_, reservation| { + reservation.pending.is_some() || reservation.created.elapsed() < RESERVATION_TTL + }); + if reservations.len() >= MAX_RESERVATIONS { + return Err(CaptureError::LimitExceeded); + } + let handle = handle()?; + reservations.insert( + handle.clone(), + Reservation { + created: std::time::Instant::now(), + pending: None, + }, + ); + Ok(handle) + } + pub(in crate::service) async fn start_reserved( + self: &Arc, + request: RecordingStartRequest, + stream: bool, + ) -> CaptureResult { + if request.permission != MicrophonePermission::Granted { + return Err(CaptureError::PermissionDenied); + } + let handle = request.handle.ok_or(CaptureError::InvalidParameters)?; + let (pending, busy) = { + let mut reservations = self.reservations.lock().map_err(|_| state_error())?; + if self.closed.load(Ordering::SeqCst) { + return Err(CaptureError::Closed); + } + let reservation = reservations + .get_mut(&handle) + .ok_or(CaptureError::UnknownHandle)?; + if reservation.pending.is_some() { + return Err(CaptureError::Busy); + } + if reservation.created.elapsed() >= RESERVATION_TTL { + reservations.remove(&handle); + return Err(CaptureError::UnknownHandle); + } + self.busy + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .map_err(|_| CaptureError::Busy)?; + self.idle.send_replace(false); + let busy = BusyGuard(self.busy.clone(), self.idle.clone()); + let (completed, _) = tokio::sync::watch::channel(false); + let pending = Arc::new(PendingStart { + canceled: AtomicBool::new(false), + completed, + }); + reservation.pending = Some(pending.clone()); + (pending, busy) + }; + let capture = self.clone(); + let (reply, received) = tokio::sync::oneshot::channel(); + let runtime = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + capture.run_start(&handle, &pending, busy, stream, &runtime, reply); + })); + // Notification follows all cleanup, even failed or abandoned setup. + if let Ok(mut reservations) = capture.reservations.lock() { + reservations.remove(&handle); + } + pending.completed.send_replace(true); + }); + received.await.map_err(|_| state_error())? + } + fn run_start( + &self, + handle: &CaptureHandle, + pending: &PendingStart, + busy: BusyGuard, + stream: bool, + runtime: &tokio::runtime::Handle, + reply: tokio::sync::oneshot::Sender>, + ) { + let result = if stream { + self.backend + .stream() + .map(|(format, resource)| NativeResource::Stream(format, resource)) + } else { + self.backend.start().map(NativeResource::Recording) + }; + let result = result.map_err(CaptureError::Device).and_then(|resource| { + let Ok(mut reservations) = self.reservations.lock() else { + resource.discard(runtime); + return Err(state_error()); + }; + if pending.canceled.load(Ordering::SeqCst) || reply.is_closed() { + drop(reservations); + resource.discard(runtime); + return Err(CaptureError::Cancelled); + } + match resource.publish(self, handle, busy) { + Ok(started) => { + reservations.remove(handle); + Ok(started) + } + Err((resource, busy)) => { + drop(reservations); + resource.discard(runtime); + drop(busy); + Err(state_error()) + } + } + }); + if let Err(Ok(started)) = reply.send(result) { + // The result receiver vanished after publication. Reclaim the live resource. + self.discard_live(started, runtime); + } + } + fn discard_live(&self, started: Started, runtime: &tokio::runtime::Handle) { + match started { + Started::Recording(handle) => { + let lease = self + .recordings + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&handle); + if let Some(Lease { recording, busy }) = lease { + let _ = runtime.block_on(recording.finish()); + drop(busy); + } + } + Started::Stream(stream) => { + let lease = self + .streams + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&stream.handle); + if let Some(StreamLease { stream, busy }) = lease { + let _ = runtime.block_on(stream.stop()); + drop(busy); + } + } + } + } + pub(super) async fn cancel_pending(&self, handle: &CaptureHandle) -> CaptureResult { + let pending = { + let mut reservations = self.reservations.lock().map_err(|_| state_error())?; + let Some(reservation) = reservations.get(handle) else { + return Ok(false); + }; + if let Some(pending) = &reservation.pending { + pending.canceled.store(true, Ordering::SeqCst); + Some(pending.clone()) + } else { + reservations.remove(handle); + None + } + }; + if let Some(pending) = pending { + let mut completed = pending.completed.subscribe(); + while !*completed.borrow_and_update() { + completed.changed().await.map_err(|_| state_error())?; + } + // A cancel may arrive between the worker's check and publication. + // Fall through to reclaim a published recording/stream if present. + return Ok(true); + } + Ok(true) + } +} + +#[cfg(test)] +#[path = "startup_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/capture/startup_tests.rs b/crates/tinyvoice-module/src/service/capture/startup_tests.rs new file mode 100644 index 0000000..7a9f4e0 --- /dev/null +++ b/crates/tinyvoice-module/src/service/capture/startup_tests.rs @@ -0,0 +1,218 @@ +//! Startup faults and reclamation use in-memory resources, never devices. +use super::*; + +fn fixture() -> Arc { + Arc::new(super::super::tests::fixture_capture()) +} +fn poison(mutex: &std::sync::Mutex) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = mutex.lock(); + std::panic::resume_unwind(Box::new("fixture poison")); + })); +} +#[tokio::test] +async fn lost_reply_reclamation_stops_both_native_modes() -> CaptureResult<()> { + for stream in [false, true] { + let capture = fixture(); + let handle = capture.reserve(MicrophonePermission::Granted)?; + let started = capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(handle), + }, + stream, + ) + .await?; + let worker_capture = capture.clone(); + let runtime = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || worker_capture.discard_live(started, &runtime)) + .await + .map_err(|_| state_error())?; + assert!(!capture.busy.load(Ordering::SeqCst)); + assert!( + capture + .recordings + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!( + capture + .streams + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + } + Ok(()) +} +#[tokio::test] +async fn poisoned_live_tables_stop_unpublished_resources_and_free_capacity() -> CaptureResult<()> { + for stream in [false, true] { + let capture = fixture(); + let handle = capture.reserve(MicrophonePermission::Granted)?; + if stream { + poison(&capture.streams); + } else { + poison(&capture.recordings); + } + assert!(matches!( + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(handle) + }, + stream + ) + .await, + Err(CaptureError::Device(_)) + )); + assert!(!capture.busy.load(Ordering::SeqCst)); + capture.shutdown().await?; + } + Ok(()) +} +#[tokio::test] +async fn poisoned_reservations_fail_closed_and_cleanup_unpublished_native_resources() +-> CaptureResult<()> { + for stream in [false, true] { + let capture = fixture(); + poison(&capture.reservations); + assert!(matches!( + capture.reserve(MicrophonePermission::Granted), + Err(CaptureError::Device(_)) + )); + assert!(matches!( + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(CaptureHandle("fixture".into())) + }, + stream + ) + .await, + Err(CaptureError::Device(_)) + )); + assert!(matches!( + capture + .cancel_pending(&CaptureHandle("fixture".into())) + .await, + Err(CaptureError::Device(_)) + )); + let (reply, received) = tokio::sync::oneshot::channel(); + let pending = PendingStart { + canceled: AtomicBool::new(false), + completed: tokio::sync::watch::channel(false).0, + }; + capture.busy.store(true, Ordering::SeqCst); + let worker_capture = capture.clone(); + let runtime = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + worker_capture.run_start( + &CaptureHandle("fixture".into()), + &pending, + BusyGuard(worker_capture.busy.clone(), worker_capture.idle.clone()), + stream, + &runtime, + reply, + ); + }) + .await + .map_err(|_| state_error())?; + assert!(matches!( + received.await.map_err(|_| state_error())?, + Err(CaptureError::Device(_)) + )); + assert!(!capture.busy.load(Ordering::SeqCst)); + } + Ok(()) +} +#[tokio::test] +async fn canceled_and_undeliverable_startup_waits_for_native_discard() -> CaptureResult<()> { + for stream in [false, true] { + for canceled in [false, true] { + let capture = fixture(); + let (reply, received) = tokio::sync::oneshot::channel(); + drop(received); + let pending = PendingStart { + canceled: AtomicBool::new(canceled), + completed: tokio::sync::watch::channel(false).0, + }; + capture.busy.store(true, Ordering::SeqCst); + let worker_capture = capture.clone(); + let runtime = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + worker_capture.run_start( + &CaptureHandle("fixture".into()), + &pending, + BusyGuard(worker_capture.busy.clone(), worker_capture.idle.clone()), + stream, + &runtime, + reply, + ); + }) + .await + .map_err(|_| state_error())?; + assert!(!capture.busy.load(Ordering::SeqCst)); + assert!( + capture + .recordings + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + assert!( + capture + .streams + .lock() + .map_err(|_| state_error())? + .is_empty() + ); + } + } + Ok(()) +} +#[derive(Debug)] +struct PanicBackend; +impl super::super::Backend for PanicBackend { + fn devices(&self) -> Result, String> { + Ok(vec![]) + } + fn start(&self) -> Result, String> { + std::panic::resume_unwind(Box::new("fixture setup panic")) + } + fn stream( + &self, + ) -> Result< + ( + tinyvoice_bus::capture::CaptureFormat, + Box, + ), + String, + > { + Err("fixture".into()) + } +} +#[tokio::test] +async fn panicking_setup_notifies_completion_and_shutdown_recovers() -> CaptureResult<()> { + let capture = Arc::new(Capture::new(Arc::new(PanicBackend))); + let handle = capture.reserve(MicrophonePermission::Granted)?; + assert!(matches!( + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Granted, + handle: Some(handle) + }, + false + ) + .await, + Err(CaptureError::Device(_)) + )); + capture.shutdown().await?; + assert!(!capture.busy.load(Ordering::SeqCst)); + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 6c40dee..7c160fd 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -35,22 +35,13 @@ pub const MAX_AUDIO_BYTES: usize = 8 * 1024 * 1024; /// Generous relative to real use: a host runs one always-on loop. pub const MAX_SESSIONS: usize = 64; -/// The served object. +/// Voice operations served by the compiled module. /// -/// Almost every method is a pure function of its arguments. The exception is -/// the VAD, which is a state machine over successive frames and therefore needs -/// somewhere to live between calls — hence its one field. -/// -/// # Why the VAD gets a session and nothing else does -/// -/// A stateless `Segment` exists too, and it is the right call for a recording -/// that is already complete. It cannot serve a live capture loop: a batch that -/// cuts an utterance in half loses the open segment, and a loop that re-sent -/// everything each time would do quadratic work to avoid holding one enum. -/// -/// The state here is deliberately tiny — a `VadSegmenter` is two `u32`s and a -/// config — so a session costs a map entry, not a buffer. The audio itself -/// stays with the host, which is already accumulating it. +/// Pure audio, intent and transcript methods have no persistent state. VAD +/// segmenters retain utterance state across frame batches, while capture owns +/// reservations, native recording/stream leases and bounded prepared outputs. +/// Native setup and cleanup run in owned workers; hosts use `CaptureShutdown` +/// before stopping the module and per-handle cleanup when signing out. #[derive(Debug, Default)] pub struct VoiceService { /// Live segmenters, keyed by the id `VadOpen` handed out. @@ -94,6 +85,23 @@ fn decode_audio(encoded: &str) -> TinyBusResult> { .map_err(|e| tinybus::Error::failed(format!("audio payload is not valid base64: {e}"))) } +/// Bound resampling before allocating its output, including serialized float output. +fn check_expansion(samples: usize, source_rate: u32) -> TinyBusResult<()> { + if source_rate == 0 { + return Err(failed(&tinyvoice::Error::ZeroSampleRate)); + } + let expanded = samples + .checked_mul(audio::STT_SAMPLE_RATE as usize) + .map(|count| count.div_ceil(source_rate as usize)) + .ok_or_else(|| tinybus::Error::failed("audio expansion exceeds the output limit"))?; + if expanded > MAX_AUDIO_BYTES / 4 { + return Err(tinybus::Error::failed( + "audio expansion exceeds the output limit", + )); + } + Ok(()) +} + /// Reinterpret a little-endian byte buffer as `f32` samples. fn f32_samples(bytes: &[u8]) -> TinyBusResult> { if !bytes.len().is_multiple_of(4) { @@ -178,16 +186,31 @@ impl VoiceService { .await .map_err(|_| tinybus::Error::failed("capture worker failed")) } + /// Close capture, cancel pending work and await all native cleanup. + async fn capture_shutdown(&self) -> TinyBusResult> { + Ok(self.capture.shutdown().await) + } + /// Obtain a known cancellation handle before beginning native device setup. + async fn reserve_capture( + &self, + request: tinyvoice_bus::capture::RecordingStartRequest, + ) -> TinyBusResult> + { + Ok(self.capture.reserve(request.permission)) + } /// Start native continuous capture after a computer-module permission grant. async fn capture_start( &self, request: tinyvoice_bus::capture::RecordingStartRequest, ) -> TinyBusResult> { - let capture = self.capture.clone(); - tokio::task::spawn_blocking(move || capture.stream_start(request.permission)) - .await - .map_err(|_| tinybus::Error::failed("capture start worker failed")) + match self.capture.start_reserved(request, true).await { + Ok(capture::Started::Stream(stream)) => Ok(Ok(stream)), + Err(error) => Ok(Err(error)), + Ok(capture::Started::Recording(_)) => { + Ok(Err(tinyvoice_bus::capture::CaptureError::InvalidParameters)) + } + } } /// Read at most two native chunks without blocking or per-sample requests. async fn capture_poll( @@ -210,10 +233,13 @@ impl VoiceService { request: tinyvoice_bus::capture::RecordingStartRequest, ) -> TinyBusResult> { - let capture = self.capture.clone(); - tokio::task::spawn_blocking(move || capture.start(request.permission)) - .await - .map_err(|_| tinybus::Error::failed("capture worker failed")) + match self.capture.start_reserved(request, false).await { + Ok(capture::Started::Recording(handle)) => Ok(Ok(handle)), + Err(error) => Ok(Err(error)), + Ok(capture::Started::Stream(_)) => { + Ok(Err(tinyvoice_bus::capture::CaptureError::InvalidParameters)) + } + } } /// Finish and prepare the recording inside the module. async fn recording_finish( @@ -304,7 +330,7 @@ impl VoiceService { /// caller streaming a long recording must submit whole utterances rather /// than arbitrary slices — a batch that cuts an utterance in half loses the /// open segment. That is the cost of a stateless interface, and it is the - /// reason a realtime host should link the library instead. + /// reason a realtime host uses the module's stateful VAD methods instead. async fn segment( &self, config: String, @@ -442,6 +468,7 @@ impl VoiceService { ) -> TinyBusResult { let raw = f32_samples(&decode_audio(&samples)?)?; let mono = audio::to_mono(&raw, channels).map_err(|e| failed(&e))?; + check_expansion(mono.len(), source_rate)?; let resampled = audio::resample(&mono, source_rate, audio::STT_SAMPLE_RATE).map_err(|e| failed(&e))?; Ok(encode_samples(&resampled)) @@ -533,6 +560,7 @@ impl VoiceService { ) -> TinyBusResult { let raw = f32_samples(&decode_audio(&samples)?)?; let mono = audio::to_mono(&raw, channels).map_err(|e| failed(&e))?; + check_expansion(mono.len(), source_rate)?; let resampled = audio::resample(&mono, source_rate, audio::STT_SAMPLE_RATE).map_err(|e| failed(&e))?; @@ -589,11 +617,13 @@ tinybus_module::module_export_optional_static! { "CaptureStart", "CapturePoll", "CaptureStop", + "CaptureShutdown", "EncodeWav", "EncodeWavPcm16", "PrepareCapture", "ListInputDevices", "RecordingStart", + "ReserveCapture", "RecordingFinish", "RecordingCancel", "ReadAudioOutput", diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index a6dfb68..65d131d 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -670,8 +670,8 @@ async fn a_truncated_pcm16_buffer_is_refused() -> tinybus::Result<()> { async fn native_capture_members_own_resources_and_return_bounded_wav_batches() -> tinybus::Result<()> { use tinyvoice_bus::capture::{ - AudioOutput, CaptureError, CaptureHandle, CaptureResult, MicrophonePermission, - ReadAudioRequest, RecordingFinishRequest, RecordingStartRequest, + AudioOutput, CaptureError, CaptureHandle, CaptureResult, ReadAudioRequest, + RecordingFinishRequest, RecordingStartRequest, }; let bus = MemoryBus::new(); let task = Broker::new().spawn(bus.clone()); @@ -701,9 +701,7 @@ async fn native_capture_members_own_resources_and_return_bounded_wav_batches() - let handle: CaptureResult = proxy .call( names::methods::RECORDING_START, - (RecordingStartRequest { - permission: MicrophonePermission::Granted, - },), + (reserved_request(&proxy).await?,), ) .await?; let handle = handle.unwrap(); @@ -749,9 +747,7 @@ async fn native_capture_members_own_resources_and_return_bounded_wav_batches() - let handle: CaptureResult = proxy .call( names::methods::RECORDING_START, - (RecordingStartRequest { - permission: MicrophonePermission::Granted, - },), + (reserved_request(&proxy).await?,), ) .await?; let canceled: CaptureResult<()> = proxy @@ -815,9 +811,7 @@ async fn continuous_capture_lifecycle_executes_through_bus_fixtures() -> tinybus let stream: CaptureResult = proxy .call( names::methods::CAPTURE_START, - (RecordingStartRequest { - permission: MicrophonePermission::Granted, - },), + (reserved_request(&proxy).await?,), ) .await?; let stream = stream.unwrap(); @@ -843,3 +837,61 @@ async fn continuous_capture_lifecycle_executes_through_bus_fixtures() -> tinybus task.abort(); Ok(()) } +#[tokio::test] +async fn prepare_frames_rejects_expanded_output_before_resampling() { + let service = VoiceService::default(); + let encoded = encode_samples(&vec![0.25; 200]); + assert!(service.prepare_frames(encoded, 1, 1).await.is_err()); +} + +async fn reserved_request( + proxy: &tinybus::Proxy, +) -> tinybus::Result { + use tinyvoice_bus::capture::{ + CaptureHandle, CaptureResult, MicrophonePermission, RecordingStartRequest, + }; + let permission = MicrophonePermission::Granted; + let handle: CaptureResult = proxy + .call( + names::methods::RESERVE_CAPTURE, + (RecordingStartRequest { + permission, + ..Default::default() + },), + ) + .await?; + Ok(RecordingStartRequest { + permission, + handle: Some(handle.unwrap()), + }) +} +#[tokio::test] +async fn legacy_prepare_capture_rejects_expansion_before_encoding() { + let service = VoiceService::default(); + assert!( + service + .prepare_capture(super::encode_samples(&vec![0.25; 300]), 1, 1, 0.0) + .await + .is_err() + ); + assert!(super::check_expansion(usize::MAX, 1).is_err()); + assert!(super::check_expansion(MAX_AUDIO_BYTES / 4, tinyvoice::audio::STT_SAMPLE_RATE).is_ok()); +} +#[tokio::test] +async fn capture_shutdown_is_terminal_over_the_bus() -> tinybus::Result<()> { + use tinyvoice_bus::capture::{CaptureError, CaptureResult}; + let proxy = connect().await?; + let result: CaptureResult<()> = proxy.call(names::methods::CAPTURE_SHUTDOWN, ()).await?; + assert_eq!(result, Ok(())); + let reservation: CaptureResult = proxy + .call( + names::methods::RESERVE_CAPTURE, + (tinyvoice_bus::capture::RecordingStartRequest { + permission: tinyvoice_bus::capture::MicrophonePermission::Granted, + ..Default::default() + },), + ) + .await?; + assert_eq!(reservation, Err(CaptureError::Closed)); + Ok(()) +} diff --git a/docs/specs/module-capture.md b/docs/specs/module-capture.md index 45e9f14..21be590 100644 --- a/docs/specs/module-capture.md +++ b/docs/specs/module-capture.md @@ -1,11 +1,13 @@ # Module-owned native capture contract -TinyVoice contract 1.2 adds nine members while preserving every existing method's +TinyVoice contract 1.3 adds eleven members while preserving every existing method's arity and wire format: | Member | One argument | Result | | --- | --- | --- | | ListInputDevices | None | CaptureResult of device names | +| CaptureShutdown | None | CaptureResult of unit | +| ReserveCapture | RecordingStartRequest (permission only) | CaptureResult of CaptureHandle | | RecordingStart | RecordingStartRequest | CaptureResult of CaptureHandle | | RecordingFinish | RecordingFinishRequest | CaptureResult of AudioOutput | | RecordingCancel | CaptureHandle | CaptureResult of unit | @@ -15,27 +17,43 @@ arity and wire format: | CapturePoll | CapturePollRequest | CaptureResult of CaptureBatch | | CaptureStop | CaptureHandle | CaptureResult of unit | -ListInputDevices has zero arguments. Each other new member takes exactly one. -The contract contains serialized vocabulary only; CPAL, native device threads, +ListInputDevices and CaptureShutdown have zero arguments. Each other new member takes exactly one. +ReserveCapture returns a module-generated handle before device startup. The host +passes this known handle in RecordingStartRequest when starting either native mode. +Missing handles are refused. The contract contains serialized vocabulary only; CPAL, native device threads, processing, storage and entropy remain in the implementation module. The host obtains the microphone decision through TinyComputer and passes an explicit Granted value. Denied is the default and fails before device access. The module preserves the library's dedicated-thread native capture behavior. -Only one recording, continuous stream or finishing operation can own the device slot. Start returns -a random opaque handle. Finish consumes it and waits for capture to stop before +Only one recording, continuous stream or finishing operation can own the device slot. ReserveCapture returns +a random opaque handle. Idle reservations do not claim the device slot; at most 16 +are retained, and they expire after 60 seconds. Expiry is checked on reserve/start. +Cancel or stop can discard an idle reservation. Once setup begins, cancellation +marks the known reservation and waits for native setup and cleanup. Startup +workers reclaim resources if their result receiver disappears. Finish consumes it and waits for capture to stop before downmixing, resampling to 16 kHz, optional silence gating and PCM16 WAV encoding. Cancel consumes the recording and waits for device shutdown, discarding its audio and capture errors. Invalid gate thresholds retain the lease so it can be canceled. Setup, processing and storage failures release device capacity. -Dropping the module drops active library handles, signaling their device threads -to stop. +Native startup, finish, cancel and stop run in owned blocking workers. Dropping a +request future does not release its BusyGuard while cleanup/preparation is pending. +Workers retain ownership through completion even if a request future disappears. +CaptureShutdown closes this capture instance permanently, marks pending startup +and preparation canceled, discards outputs, drains live resources and awaits all +native cleanup and worker completion. Reserve/start/finish then return Closed. +The host must invoke CaptureShutdown before the generic TinyBus ABI shutdown: its +runtime.shutdown_timeout alone does not await blocked native workers. Sign-out +uses per-handle cancel/stop/release so the process module remains usable. Dropping +the module drops idle/active library handles, signaling their device threads to stop. -Prepared outputs have distinct opaque handles and require explicit release. -There are at most four outputs totaling 32 MiB. Reads return at most 256 KiB +Prepared outputs retain the caller-known recording handle and require explicit release. +A host can release that handle while preparation is pending or after losing the +finish reply; pending release discards the eventual result. +There are at most four outputs totaling 32 MiB. Reads return base64 for at most 256 KiB of WAV bytes per call; EOF returns an empty string. Unknown, consumed and released handles -fail explicitly. Preparation checks expanded sample count before resampling +fail explicitly. Preparation and legacy PrepareFrames/PrepareCapture check expanded sample count before resampling and checks encoded and aggregate size afterward. No transport call occurs per audio sample. @@ -45,13 +63,13 @@ names, error detail, handles, audio, paths and request arguments. Local fixtures exercise lifecycle, bounds, faults and in-memory bus dispatch without hardware or external services. The actual compiled artifact verifier -checks all 24 declared members and permission denial without opening a device. +checks all 26 declared members and permission denial without opening a device. The native device bridge follows the repository's existing physical-device coverage exception; manager and processing code remain subject to the 90% per-file threshold. The release workflow installs ALSA development headers for Linux module builds. -OpenHuman must pin a released artifact compatible with contract 1.2 and verify +OpenHuman must pin a released artifact compatible with contract 1.3 and verify its digest before switching callers. Hotkey lifecycle operations remain follow-up work. Continuous capture returns an opaque handle and native sample rate/channel From ee09645df1ff44a772633baec60642475e93cd16 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:21:02 +0300 Subject: [PATCH 04/25] Add hotkey lifecycle bus contract Co-authored-by: Medulla --- crates/tinyvoice-bus/src/hotkey.rs | 197 +++++++++++++++++++++++ crates/tinyvoice-bus/src/hotkey_tests.rs | 25 +++ crates/tinyvoice-bus/src/lib.rs | 2 + crates/tinyvoice-bus/src/names/mod.rs | 18 +++ crates/tinyvoice-bus/src/version/mod.rs | 2 +- 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 crates/tinyvoice-bus/src/hotkey.rs create mode 100644 crates/tinyvoice-bus/src/hotkey_tests.rs diff --git a/crates/tinyvoice-bus/src/hotkey.rs b/crates/tinyvoice-bus/src/hotkey.rs new file mode 100644 index 0000000..d9e7288 --- /dev/null +++ b/crates/tinyvoice-bus/src/hotkey.rs @@ -0,0 +1,197 @@ +//! Bounded, transport-neutral hotkey lifecycle vocabulary. +use serde::{Deserialize, Serialize}; + +/// Opaque hotkey reservation, unique within one module process. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct HotkeyHandle(pub String); + +/// Existing tap-toggle or push-to-talk behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActivationMode { + /// A fresh down toggles activation. + Tap, + /// A down activates until its matching up. + Push, +} + +/// Source of activation facts for a listener. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HotkeySource { + /// Use a module-owned platform listener. + Native, + /// Accept sequenced generic facts from the host. + Host, +} + +/// The input chord and mode requested by the host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HotkeyRequest { + /// Existing `TinyVoice` key spelling, including supported aliases. + pub key: String, + /// Tap or push activation behavior. + pub mode: ActivationMode, + /// Native listener or host-fed facts (macOS). + pub source: HotkeySource, +} + +/// Reserve a hotkey lease before potentially fallible native startup. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HotkeyReserveRequest { + /// Requested chord and source. + pub request: HotkeyRequest, +} + +/// Handle used by start, read, feed, and stop. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HotkeyHandleRequest { + /// Reserved listener. + pub handle: HotkeyHandle, +} + +/// Start a reserved listener. +pub type HotkeyStartRequest = HotkeyHandleRequest; + +/// Start or feed acknowledgment with the current listener status. +pub type HotkeyReply = HotkeyStatus; + +/// Read retained events, acknowledging only the exact prior batch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HotkeyReadRequest { + /// Reserved listener. + pub handle: HotkeyHandle, + /// Batch previously returned, if the caller consumed it. + pub acknowledged_batch: Option, +} + +/// Whether the activation reducer is currently active. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HotkeyEvent { + /// Begin activation. + Pressed, + /// End activation. + Released, +} + +/// Host-owned physical fact. No native key or Computer type crosses the bus. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HostKeyFact { + /// Configured source became pressed. + Down, + /// Configured source became released. + Up, +} + +/// One sequenced host fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SequencedHostFact { + /// Monotonic source sequence. + pub sequence: u64, + /// Generic activation input. + pub fact: HostKeyFact, +} + +/// Feed a bounded batch of Computer-derived generic activation facts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HotkeyFeedRequest { + /// Reserved host-fed listener. + pub handle: HotkeyHandle, + /// Generation returned by start, fencing replaced source leases. + pub generation: u64, + /// At most 64 source facts. + pub facts: Vec, + /// Upstream reported that facts were lost. + pub overflow: bool, +} + +/// Lifecycle phase of one listener. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HotkeyState { + /// Reserved and not started. + Reserved, + /// Native startup is in progress. + Starting, + /// Accepting source facts. + Running, + /// Cleanup is in progress. + Stopping, + /// Stopped and no longer accepting facts. + Stopped, +} + +/// Public status contains no native or permission details. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HotkeyStatus { + /// Current lifecycle phase. + pub state: HotkeyState, + /// Listener generation, when startup began. + pub generation: Option, + /// Current reducer state. + pub active: bool, + /// Continuity was lost and the reducer was reset. + pub continuity_lost: bool, +} + +/// One ordered activation fact emitted by the reducer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SequencedHotkeyEvent { + /// Listener generation. + pub generation: u64, + /// Monotonic listener event sequence. + pub sequence: u64, + /// Pressed or Released activation. + pub event: HotkeyEvent, +} + +/// Replayable read batch. The same batch remains until acknowledged. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HotkeyBatch { + /// Listener generation. + pub generation: u64, + /// Batch identity acknowledged by the next read. + pub batch: u64, + /// Ordered retained activation events. + pub events: Vec, + /// Reader must treat activation as inactive and await a fresh release. + pub reset: bool, + /// Current active state after the batch. + pub active: bool, +} + +/// Fixed, content-free hotkey failure codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HotkeyError { + /// Permission was not granted by the host. + PermissionDenied, + /// Module or listener is closed. + Closed, + /// Listener capacity or operation is busy. + Busy, + /// Handle is unknown, expired, or already released. + UnknownHandle, + /// Request is malformed or incompatible with the source. + InvalidRequest, + /// A bounded resource or payload limit was reached. + LimitExceeded, + /// Request belongs to an old listener generation. + StaleGeneration, + /// Source sequence is duplicate or discontinuous. + SequenceGap, + /// Native cleanup failed and may be retried. + CleanupFailed, + /// This platform/source is not supported. + Unsupported, +} + +/// Typed result returned by lifecycle operations. +pub type HotkeyResult = Result; + +#[cfg(test)] +#[path = "hotkey_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-bus/src/hotkey_tests.rs b/crates/tinyvoice-bus/src/hotkey_tests.rs new file mode 100644 index 0000000..31dd24b --- /dev/null +++ b/crates/tinyvoice-bus/src/hotkey_tests.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn activation_mode_wire_names_remain_compatible() { + let tap = serde_json::to_string(&ActivationMode::Tap).unwrap_or_default(); + let push = serde_json::to_string(&ActivationMode::Push).unwrap_or_default(); + assert_eq!(tap, "\"tap\""); + assert_eq!(push, "\"push\""); +} + +#[test] +fn host_facts_have_a_generic_stable_wire_shape() { + let fact = SequencedHostFact { + sequence: 9, + fact: HostKeyFact::Down, + }; + let encoded = serde_json::to_string(&fact).unwrap_or_default(); + assert_eq!(encoded, r#"{"sequence":9,"fact":"down"}"#); +} + +#[test] +fn hotkey_errors_never_carry_native_detail() { + let encoded = serde_json::to_string(&HotkeyError::CleanupFailed).unwrap_or_default(); + assert_eq!(encoded, "\"cleanup_failed\""); +} diff --git a/crates/tinyvoice-bus/src/lib.rs b/crates/tinyvoice-bus/src/lib.rs index c4c44df..418fa51 100644 --- a/crates/tinyvoice-bus/src/lib.rs +++ b/crates/tinyvoice-bus/src/lib.rs @@ -11,12 +11,14 @@ //! the adapter; and the root `tinyvoice` crate remains host-agnostic. pub mod capture; +pub mod hotkey; pub mod intent; pub mod names; pub mod transcript; pub mod vad; pub mod version; +pub use hotkey::*; pub use intent::VoiceIntent; pub use names::{BUS_NAME, METHODS, OBJECT_PATH}; pub use transcript::Mode; diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index 6506406..63795c4 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -8,6 +8,18 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { + /// Reserves a hotkey lease before native startup. + pub const HOTKEY_RESERVE: &str = "HotkeyReserve"; + /// Starts a reserved hotkey listener idempotently. + pub const HOTKEY_START: &str = "HotkeyStart"; + /// Reads a replayable activation batch. + pub const HOTKEY_READ: &str = "HotkeyRead"; + /// Feeds generic host-owned key facts. + pub const HOTKEY_FEED: &str = "HotkeyFeed"; + /// Stops a listener after native cleanup and join. + pub const HOTKEY_STOP: &str = "HotkeyStop"; + /// Closes admission and joins every listener. + pub const HOTKEY_SHUTDOWN: &str = "HotkeyShutdown"; /// Close capture and await pending native cleanup. pub const CAPTURE_SHUTDOWN: &str = "CaptureShutdown"; /// Reserve an opaque cancellation handle before native startup. @@ -77,6 +89,12 @@ pub const METHODS: &[&str] = &[ methods::ENCODE_WAV_PCM16, methods::EXTRACT_COMMAND, methods::FRAME_ENERGIES, + methods::HOTKEY_FEED, + methods::HOTKEY_READ, + methods::HOTKEY_RESERVE, + methods::HOTKEY_SHUTDOWN, + methods::HOTKEY_START, + methods::HOTKEY_STOP, methods::IS_HALLUCINATED, methods::LIST_INPUT_DEVICES, methods::PREPARE_CAPTURE, diff --git a/crates/tinyvoice-bus/src/version/mod.rs b/crates/tinyvoice-bus/src/version/mod.rs index 0bb1cfe..1cef137 100644 --- a/crates/tinyvoice-bus/src/version/mod.rs +++ b/crates/tinyvoice-bus/src/version/mod.rs @@ -1,7 +1,7 @@ //! The `TinyVoice` `TinyBus` contract version and its binding rule. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 3); +pub const CONTRACT_VERSION: (u32, u32) = (1, 4); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// From 7ac8f6c965ebcb7c681d502658ed0cc42a3b771c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:21:11 +0300 Subject: [PATCH 05/25] Own native hotkey lifecycle in module Co-authored-by: Medulla --- crates/tinyvoice-hotkey-win/Cargo.lock | 25 ++ crates/tinyvoice-hotkey-win/Cargo.toml | 24 ++ crates/tinyvoice-hotkey-win/src/lib.rs | 190 +++++++++++ crates/tinyvoice-module/Cargo.lock | 237 +++++++++++++- crates/tinyvoice-module/Cargo.toml | 11 +- .../examples/verify_module.rs | 102 ++++++ .../src/service/hotkey/mod.rs | 270 ++++++++++++++++ .../src/service/hotkey/mod_tests.rs | 297 ++++++++++++++++++ .../src/service/hotkey/native/linux.rs | 175 +++++++++++ .../src/service/hotkey/native/linux_tests.rs | 82 +++++ .../src/service/hotkey/native/mod.rs | 61 ++++ .../src/service/hotkey/native/windows.rs | 78 +++++ crates/tinyvoice-module/src/service/mod.rs | 39 +++ .../tinyvoice-module/src/service/mod_tests.rs | 40 +++ 14 files changed, 1616 insertions(+), 15 deletions(-) create mode 100644 crates/tinyvoice-hotkey-win/Cargo.lock create mode 100644 crates/tinyvoice-hotkey-win/Cargo.toml create mode 100644 crates/tinyvoice-hotkey-win/src/lib.rs create mode 100644 crates/tinyvoice-module/src/service/hotkey/mod.rs create mode 100644 crates/tinyvoice-module/src/service/hotkey/mod_tests.rs create mode 100644 crates/tinyvoice-module/src/service/hotkey/native/linux.rs create mode 100644 crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs create mode 100644 crates/tinyvoice-module/src/service/hotkey/native/mod.rs create mode 100644 crates/tinyvoice-module/src/service/hotkey/native/windows.rs diff --git a/crates/tinyvoice-hotkey-win/Cargo.lock b/crates/tinyvoice-hotkey-win/Cargo.lock new file mode 100644 index 0000000..89cb9c6 --- /dev/null +++ b/crates/tinyvoice-hotkey-win/Cargo.lock @@ -0,0 +1,25 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "tinyvoice-hotkey-win" +version = "0.1.0" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/crates/tinyvoice-hotkey-win/Cargo.toml b/crates/tinyvoice-hotkey-win/Cargo.toml new file mode 100644 index 0000000..f818b98 --- /dev/null +++ b/crates/tinyvoice-hotkey-win/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tinyvoice-hotkey-win" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "GPL-3.0-only" +publish = false +description = "Owned, joined Windows keyboard-hook boundary for TinyVoice." + +[workspace] + +[dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_LibraryLoader", + "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", +] } + +[lints.rust] +# This standalone crate is the single reviewed Win32 pointer/FFI boundary. +# tinyvoice and tinyvoice-module continue to forbid unsafe code entirely. +unsafe_code = "allow" +missing_docs = "warn" diff --git a/crates/tinyvoice-hotkey-win/src/lib.rs b/crates/tinyvoice-hotkey-win/src/lib.rs new file mode 100644 index 0000000..bc8e773 --- /dev/null +++ b/crates/tinyvoice-hotkey-win/src/lib.rs @@ -0,0 +1,190 @@ +//! Small owning boundary for the Windows low-level keyboard hook. +//! +//! This crate is intentionally separate so the pure voice library and TinyBus +//! module can keep `unsafe_code = "forbid"`. The hook callback runs on the +//! installing thread's message loop, reads the Windows-owned event pointer only +//! during the callback, and forwards bounded facts with `try_send`. Stop is a +//! message to that owner thread; success follows `UnhookWindowsHookEx` and join. + +#![allow(unsafe_code)] + +use std::cell::RefCell; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}, mpsc}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; +use windows_sys::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; +use windows_sys::Win32::System::Threading::GetCurrentThreadId; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, GetMessageW, PeekMessageW, PostThreadMessageW, SetWindowsHookExW, + UnhookWindowsHookEx, HC_ACTION, KBDLLHOOKSTRUCT, MSG, PM_NOREMOVE, WH_KEYBOARD_LL, + WM_APP, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN, WM_SYSKEYUP, +}; + +const STOP_MESSAGE: u32 = WM_APP + 0x41; +const STOP_REPLY_TIMEOUT: Duration = Duration::from_secs(1); + +/// Physical Windows virtual-key chord in the existing TinyVoice key vocabulary. +#[derive(Debug, Clone)] +pub struct Chord { + /// Trigger virtual key. + pub trigger: u32, + /// Required modifier virtual keys, preserving left/right identity. + pub modifiers: Vec, + /// Push releases when a required modifier lifts before the trigger. + pub push: bool, +} + +/// A bounded hook startup or cleanup operation failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HookError; + +/// An owned hook thread and its bounded activation fact receiver. +#[derive(Debug)] +pub struct Listener { + events: Option>, + overflow: Arc, + thread_id: u32, + worker: Option>>, + replies: mpsc::Receiver>, +} + +impl Listener { + /// Receives the bounded activation facts observed by the hook callback. + pub fn take_events(&mut self) -> Option> { self.events.take() } + /// Shared overflow bit, set when bounded callback handoff fills. + pub fn overflow(&self) -> Arc { self.overflow.clone() } + + /// Unhooks on the owner thread and joins it before returning success. + pub fn stop(&mut self) -> Result<(), HookError> { + if self.worker.is_none() { return Ok(()); } + let posted = unsafe { PostThreadMessageW(self.thread_id, STOP_MESSAGE, 0, 0) }; + if posted == 0 { return Err(HookError); } + match self.replies.recv_timeout(STOP_REPLY_TIMEOUT) { + Ok(Ok(())) => { + if let Some(worker) = self.worker.take() { worker.join().map_err(|_| HookError)??; } + Ok(()) + } + Ok(Err(error)) => Err(error), + Err(_) => Err(HookError), + } + } +} + +impl Drop for Listener { + fn drop(&mut self) { + // If the owner is being destroyed, keep its code mapped until Windows + // confirms unregistration; an unbounded wait is safer than detaching a + // callback into an unloadable module. + while self.worker.is_some() && self.stop().is_err() { thread::sleep(Duration::from_millis(50)); } + } +} + +#[derive(Debug)] +struct CallbackState { + chord: Chord, + pressed: [bool; 256], + sender: mpsc::SyncSender, + overflow: Arc, +} + +thread_local! { static CALLBACK: RefCell> = const { RefCell::new(None) }; } + +/// Start a dedicated owner thread, wait for hook registration, and return its lease. +pub fn start(chord: Chord) -> Result { + let (events_tx, events) = mpsc::sync_channel(256); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let (reply_tx, replies) = mpsc::sync_channel(4); + let overflow = Arc::new(AtomicBool::new(false)); + let worker_overflow = overflow.clone(); + let (thread_id_tx, thread_id_rx) = mpsc::sync_channel(1); + let worker = thread::Builder::new().name("tinyvoice-hotkey-win".into()).spawn(move || { + owner_loop(chord, events_tx, worker_overflow, ready_tx, reply_tx, thread_id_tx) + }).map_err(|_| HookError)?; + let thread_id = match thread_id_rx.recv() { + Ok(thread_id) => thread_id, + Err(_) => { let _ = worker.join(); return Err(HookError); } + }; + match ready_rx.recv() { + Ok(Ok(())) => {} + _ => { let _ = worker.join(); return Err(HookError); } + } + Ok(Listener { events: Some(events), overflow, thread_id, worker: Some(worker), replies }) +} + +fn owner_loop( + chord: Chord, + sender: mpsc::SyncSender, + overflow: Arc, + ready: mpsc::SyncSender>, + replies: mpsc::SyncSender>, + thread_id_sender: mpsc::SyncSender, +) -> Result<(), HookError> { + let thread_id = unsafe { GetCurrentThreadId() }; + let mut message = MSG::default(); + // Force creation of this thread's message queue before the handle is published. + unsafe { PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_NOREMOVE); } + let _ = thread_id_sender.send(thread_id); + let module = unsafe { GetModuleHandleW(std::ptr::null()) }; + let hook = unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_callback), module, 0) }; + if hook.is_null() { let _ = ready.send(Err(HookError)); return Err(HookError); } + CALLBACK.with(|target| *target.borrow_mut() = Some(CallbackState { chord, pressed: [false; 256], sender, overflow })); + if ready.send(Ok(())).is_err() { + let result = unsafe { UnhookWindowsHookEx(hook) }; + CALLBACK.with(|target| *target.borrow_mut() = None); + return if result != 0 { Ok(()) } else { Err(HookError) }; + } + loop { + let result = unsafe { GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) }; + if result <= 0 { break; } + if message.message == STOP_MESSAGE { + let result = unsafe { UnhookWindowsHookEx(hook) }; + if result != 0 { + CALLBACK.with(|target| *target.borrow_mut() = None); + let _ = replies.send(Ok(())); + return Ok(()); + } + let _ = replies.send(Err(HookError)); + } else if message.message == WM_QUIT { break; } + } + let result = unsafe { UnhookWindowsHookEx(hook) }; + CALLBACK.with(|target| *target.borrow_mut() = None); + if result != 0 { Ok(()) } else { Err(HookError) } +} + +unsafe extern "system" fn keyboard_callback(code: i32, message: WPARAM, data: LPARAM) -> LRESULT { + if code == HC_ACTION as i32 { + // Windows owns KBDLLHOOKSTRUCT for this callback invocation. We only + // read the keycode synchronously and never retain the pointer. + let event = unsafe { *(data as *const KBDLLHOOKSTRUCT) }; + let down = message == WM_KEYDOWN as usize || message == WM_SYSKEYDOWN as usize; + let up = message == WM_KEYUP as usize || message == WM_SYSKEYUP as usize; + if down || up { + CALLBACK.with(|target| { + if let Some(state) = target.borrow_mut().as_mut() { + let key = event.vkCode; + let index = key as usize; + if index >= state.pressed.len() { return; } + if down { + let fresh = !state.pressed[index]; + state.pressed[index] = true; + if fresh + && key == state.chord.trigger + && state.chord.modifiers.iter().all(|modifier| state.pressed.get(*modifier as usize).copied().unwrap_or(false)) + && state.sender.try_send(true).is_err() + { + state.overflow.store(true, Ordering::SeqCst); + } + } else { + state.pressed[index] = false; + if key == state.chord.trigger || (state.chord.push && state.chord.modifiers.contains(&key) && state.pressed.get(state.chord.trigger as usize).copied().unwrap_or(false)) { + if state.chord.push && state.chord.modifiers.contains(&key) { state.pressed[state.chord.trigger as usize] = false; } + if state.sender.try_send(false).is_err() { state.overflow.store(true, Ordering::SeqCst); } + } + } + } + }); + } + } + unsafe { CallNextHookEx(std::ptr::null_mut(), code, message, data) } +} diff --git a/crates/tinyvoice-module/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index c934a09..ae5cc75 100644 --- a/crates/tinyvoice-module/Cargo.lock +++ b/crates/tinyvoice-module/Cargo.lock @@ -15,7 +15,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -68,12 +68,24 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -120,6 +132,21 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cocoa" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667fdc068627a2816b9ff831201dd9864249d6ee8d190b9532357f1fc0f61ea7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics 0.21.0", + "foreign-types", + "libc", + "objc", +] + [[package]] name = "combine" version = "4.6.8" @@ -130,13 +157,69 @@ dependencies = [ "memchr", ] +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a67c4378cf203eace8fb6567847eb641fd6ff933c1145a115c6ee820ebb978" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "foreign-types", + "libc", +] + [[package]] name = "coreaudio-rs" version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -241,7 +324,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] @@ -304,6 +387,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "futures-core" version = "0.3.34" @@ -338,6 +436,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -467,6 +575,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.189" @@ -500,6 +614,15 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.3" @@ -522,7 +645,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -587,11 +710,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" -version = "0.6.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -602,7 +734,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", "objc2", "objc2-core-audio", @@ -617,7 +749,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -641,7 +773,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] @@ -651,7 +783,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "dispatch2", "libc", @@ -670,7 +802,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -757,13 +889,29 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rdev" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3" +dependencies = [ + "cocoa", + "core-foundation 0.7.0", + "core-foundation-sys 0.7.0", + "core-graphics 0.19.2", + "lazy_static", + "libc", + "winapi", + "x11", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -795,7 +943,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1107,6 +1255,8 @@ dependencies = [ "cpal", "log", "parking_lot", + "rdev", + "serde", "thiserror 2.0.20", "tinyvoice-bus", "tokio", @@ -1119,19 +1269,29 @@ dependencies = [ "serde", ] +[[package]] +name = "tinyvoice-hotkey-win" +version = "0.1.0" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "tinyvoice-module" version = "0.1.12" dependencies = [ "base64 0.22.1", "getrandom 0.4.3", + "rustix", "serde", "serde_json", "tinybus", "tinybus-module", "tinyvoice", "tinyvoice-bus", + "tinyvoice-hotkey-win", "tokio", + "x11rb", ] [[package]] @@ -1377,15 +1537,37 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" @@ -1587,6 +1769,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index 28821c4..815d56b 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -14,7 +14,9 @@ publish = false crate-type = ["rlib", "cdylib"] [features] +default = ["native-hotkeys"] static-link = [] +native-hotkeys = [] [dependencies] # Opaque native recording/output leases must not rebind after a module reload. @@ -24,7 +26,7 @@ getrandom = "0.4" tinyvoice-bus = { version = "0.1.2", path = "../tinyvoice-bus" } # The pure voice library stays independently usable and bus-agnostic; this # crate exists so the vendored TinyBus dependency never reaches it. -tinyvoice = { version = "0.1.3", path = "../tinyvoice", features = ["capture"] } +tinyvoice = { version = "0.1.3", path = "../tinyvoice", features = ["capture", "hotkey"] } # TinyBus provides the typed service interface and the dynamic module host ABI. tinybus = { version = "=0.1.2", default-features = false, features = ["macros", "modules"] } # The module-side SDK owns its runtime and exports the stable C entrypoints. @@ -39,6 +41,13 @@ base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" +[target.'cfg(target_os = "linux")'.dependencies] +rustix = { version = "1", features = ["net"] } +x11rb = { version = "0.13.2", features = ["record"] } + +[target.'cfg(target_os = "windows")'.dependencies] +tinyvoice-hotkey-win = { path = "../tinyvoice-hotkey-win" } + [lints.rust] unsafe_code = "forbid" missing_docs = "warn" diff --git a/crates/tinyvoice-module/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index 8a0658d..509a764 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -108,6 +108,8 @@ async fn main() -> Result<(), Box> { ) { return Err(io::Error::other("reservation did not refuse missing permission").into()); } + + verify_hotkeys(&proxy).await?; let closed: tinyvoice_bus::capture::CaptureResult<()> = proxy .call(tinyvoice_bus::names::methods::CAPTURE_SHUTDOWN, ()) .await?; @@ -123,6 +125,106 @@ async fn main() -> Result<(), Box> { Ok(()) } +async fn verify_hotkeys(proxy: &tinybus::Proxy) -> Result<(), Box> { + let hotkey: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_RESERVE, (host_hotkey_request(),)) + .await?; + let hotkey = hotkey.map_err(|error| io::Error::other(format!("hotkey reserve failed: {error:?}")))?; + let start_request = tinyvoice_bus::HotkeyHandleRequest { handle: hotkey.clone() }; + let started: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_START, (start_request.clone(),)) + .await?; + let started = started.map_err(|error| io::Error::other(format!("hotkey start failed: {error:?}")))?; + let retried: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_START, (start_request,)) + .await?; + if retried.map_err(|error| io::Error::other(format!("hotkey start retry failed: {error:?}")))? != started { + return Err(io::Error::other("hotkey start retry changed its generation or state").into()); + } + let generation = started.generation.ok_or_else(|| io::Error::other("hotkey start omitted generation"))?; + let stale: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_FEED, (feed_request(hotkey.clone(), generation.saturating_add(1), vec![]),)) + .await?; + if stale != Err(tinyvoice_bus::HotkeyError::StaleGeneration) { + return Err(io::Error::other("hotkey feed accepted a stale generation").into()); + } + verify_hotkey_replay(proxy, hotkey.clone(), generation).await?; + let stopped: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_STOP, (tinyvoice_bus::HotkeyHandleRequest { handle: hotkey },)) + .await?; + if stopped.map_err(|error| io::Error::other(format!("hotkey stop failed: {error:?}")))?.state != tinyvoice_bus::HotkeyState::Stopped { + return Err(io::Error::other("hotkey stop did not finish the lease").into()); + } + let closed: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_SHUTDOWN, ()) + .await?; + if closed.map_err(|error| io::Error::other(format!("hotkey shutdown failed: {error:?}")))?.state != tinyvoice_bus::HotkeyState::Stopped { + return Err(io::Error::other("hotkey shutdown did not finish").into()); + } + let after_shutdown: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_RESERVE, (host_hotkey_request(),)) + .await?; + if after_shutdown != Err(tinyvoice_bus::HotkeyError::Closed) { + return Err(io::Error::other("hotkey shutdown left admission open").into()); + } + Ok(()) +} + +async fn verify_hotkey_replay( + proxy: &tinybus::Proxy, + handle: tinyvoice_bus::HotkeyHandle, + generation: u64, +) -> Result<(), Box> { + let facts = vec![ + tinyvoice_bus::SequencedHostFact { sequence: 1, fact: tinyvoice_bus::HostKeyFact::Down }, + tinyvoice_bus::SequencedHostFact { sequence: 2, fact: tinyvoice_bus::HostKeyFact::Up }, + ]; + let fed: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_FEED, (feed_request(handle.clone(), generation, facts),)) + .await?; + fed.map_err(|error| io::Error::other(format!("hotkey feed failed: {error:?}")))?; + let read_request = tinyvoice_bus::HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }; + let first: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_READ, (read_request.clone(),)) + .await?; + let first = first.map_err(|error| io::Error::other(format!("hotkey read failed: {error:?}")))?; + let replay: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_READ, (read_request,)) + .await?; + if replay.map_err(|error| io::Error::other(format!("hotkey replay failed: {error:?}")))? != first + || first.events.iter().map(|event| event.event).collect::>() + != [tinyvoice_bus::HotkeyEvent::Pressed, tinyvoice_bus::HotkeyEvent::Released] + { + return Err(io::Error::other("hotkey read did not replay the ordered activation batch").into()); + } + let ack: tinyvoice_bus::HotkeyResult = proxy + .call(tinyvoice_bus::names::methods::HOTKEY_READ, (tinyvoice_bus::HotkeyReadRequest { handle, acknowledged_batch: Some(first.batch) },)) + .await?; + let ack = ack.map_err(|error| io::Error::other(format!("hotkey acknowledgment failed: {error:?}")))?; + if !ack.events.is_empty() || ack.active { + return Err(io::Error::other("hotkey acknowledgment did not advance to the inactive snapshot").into()); + } + Ok(()) +} + +fn host_hotkey_request() -> tinyvoice_bus::HotkeyReserveRequest { + tinyvoice_bus::HotkeyReserveRequest { + request: tinyvoice_bus::HotkeyRequest { + key: "Fn".to_owned(), + mode: tinyvoice_bus::ActivationMode::Push, + source: tinyvoice_bus::HotkeySource::Host, + }, + } +} + +fn feed_request( + handle: tinyvoice_bus::HotkeyHandle, + generation: u64, + facts: Vec, +) -> tinyvoice_bus::HotkeyFeedRequest { + tinyvoice_bus::HotkeyFeedRequest { handle, generation, facts, overflow: false } +} + fn module_argument() -> Result { std::env::args_os() .nth(1) diff --git a/crates/tinyvoice-module/src/service/hotkey/mod.rs b/crates/tinyvoice-module/src/service/hotkey/mod.rs new file mode 100644 index 0000000..3a8c148 --- /dev/null +++ b/crates/tinyvoice-module/src/service/hotkey/mod.rs @@ -0,0 +1,270 @@ +//! Owned hotkey leases, activation sequencing, and replayable event batches. +use std::collections::{HashMap, VecDeque}; +use std::sync::{Mutex, atomic::{AtomicBool, Ordering}}; +use tinyvoice_bus::{ + ActivationMode, HotkeyBatch, HotkeyError, HotkeyEvent, HotkeyFeedRequest, HotkeyHandle, + HotkeyHandleRequest, HotkeyReadRequest, HotkeyReply, HotkeyRequest, HotkeyReserveRequest, + HotkeyResult, HotkeySource, HotkeyState, HotkeyStatus, SequencedHotkeyEvent, +}; + +mod native; + +const MAX_RESERVATIONS: usize = 16; +const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_secs(60); +const MAX_KEY_BYTES: usize = 128; +const MAX_HANDLE_BYTES: usize = 32; +const MAX_FEED_FACTS: usize = 64; +const MAX_EVENTS: usize = 256; + +#[derive(Debug)] +#[allow(clippy::struct_excessive_bools)] // These are independent key, activation, and continuity facts. +struct Lease { + request: HotkeyRequest, + created: std::time::Instant, + state: HotkeyState, + generation: Option, + source_sequence: u64, + last_feed: Option<(Vec, bool)>, + active: bool, + armed: bool, + source_down: bool, + continuity_lost: bool, + event_sequence: u64, + next_batch: u64, + events: VecDeque, + pending_batch: Option, + reset_pending: bool, + native: Option, +} + +#[derive(Debug)] +pub(super) struct Hotkeys { + closed: AtomicBool, + next_generation: std::sync::atomic::AtomicU64, + leases: Mutex>, + backend: std::sync::Arc, +} + +impl Default for Hotkeys { + fn default() -> Self { Self::with_backend(native::backend()) } +} + +impl Hotkeys { + fn with_backend(backend: std::sync::Arc) -> Self { + Self { closed: AtomicBool::new(false), next_generation: std::sync::atomic::AtomicU64::new(0), leases: Mutex::new(HashMap::new()), backend } + } + pub(super) fn reserve(&self, request: HotkeyReserveRequest) -> HotkeyResult { + let request = request.request; + if request.key.is_empty() || request.key.len() > MAX_KEY_BYTES || self.closed.load(Ordering::SeqCst) { + return Err(if self.closed.load(Ordering::SeqCst) { HotkeyError::Closed } else { HotkeyError::InvalidRequest }); + } + // Use the existing parser so aliases and key spelling stay one contract. + if request.source == HotkeySource::Native { + #[cfg(feature = "native-hotkeys")] + tinyvoice::hotkey::parse_hotkey(&request.key).map_err(|_| HotkeyError::InvalidRequest)?; + #[cfg(not(feature = "native-hotkeys"))] + return Err(HotkeyError::Unsupported); + } else if request.key != "Fn" && request.key != "fn" { + return Err(HotkeyError::InvalidRequest); + } + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + leases.retain(|_, lease| match lease.state { + HotkeyState::Reserved => lease.created.elapsed() < RESERVATION_TTL, + HotkeyState::Stopped => false, + _ => true, + }); + if leases.len() >= MAX_RESERVATIONS { return Err(HotkeyError::LimitExceeded); } + let handle = new_handle()?; + leases.insert(handle.clone(), Lease { + request, created: std::time::Instant::now(), state: HotkeyState::Reserved, + generation: None, source_sequence: 0, last_feed: None, active: false, + armed: true, source_down: false, continuity_lost: false, event_sequence: 0, next_batch: 1, + events: VecDeque::new(), pending_batch: None, reset_pending: false, native: None, + }); + Ok(handle) + } + + pub(super) fn start(&self, request: &HotkeyHandleRequest) -> HotkeyResult { + validate_handle(&request.handle)?; + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + if leases.get(&request.handle).is_some_and(|lease| + lease.state == HotkeyState::Reserved && lease.created.elapsed() >= RESERVATION_TTL + ) { + leases.remove(&request.handle); + return Err(HotkeyError::UnknownHandle); + } + let lease = leases.get_mut(&request.handle).ok_or(HotkeyError::UnknownHandle)?; + if lease.state == HotkeyState::Running { return Ok(status(lease)); } + if lease.state != HotkeyState::Reserved { return Err(HotkeyError::Closed); } + if self.closed.load(Ordering::SeqCst) { return Err(HotkeyError::Closed); } + let generation = self.next_generation.fetch_add(1, Ordering::SeqCst).checked_add(1).ok_or(HotkeyError::LimitExceeded)?; + if lease.request.source == HotkeySource::Native { + lease.state = HotkeyState::Starting; + match self.backend.start(&lease.request) { + Ok(listener) => lease.native = Some(listener), + Err(error) => { lease.state = HotkeyState::Reserved; return Err(error); } + } + } + lease.generation = Some(generation); + lease.state = HotkeyState::Running; + Ok(status(lease)) + } + + pub(super) fn feed(&self, request: &HotkeyFeedRequest) -> HotkeyResult { + validate_handle(&request.handle)?; + if request.facts.len() > MAX_FEED_FACTS { return Err(HotkeyError::LimitExceeded); } + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + let lease = leases.get_mut(&request.handle).ok_or(HotkeyError::UnknownHandle)?; + if lease.request.source != HotkeySource::Host { return Err(HotkeyError::InvalidRequest); } + if lease.state != HotkeyState::Running { return Err(HotkeyError::Closed); } + if lease.generation != Some(request.generation) { return Err(HotkeyError::StaleGeneration); } + let signature = (request.facts.clone(), request.overflow); + if let Some((prior, prior_overflow)) = &lease.last_feed + && prior == &request.facts + && *prior_overflow == request.overflow + { + return Ok(status(lease)); + } + if request.overflow { + reset(lease); + lease.source_sequence = request.facts.last().map_or(lease.source_sequence, |fact| fact.sequence); + lease.last_feed = Some(signature); + return Ok(status(lease)); + } + for fact in &request.facts { + if fact.sequence <= lease.source_sequence { return Err(HotkeyError::SequenceGap); } + if fact.sequence != lease.source_sequence.saturating_add(1) { + reset(lease); + lease.source_sequence = fact.sequence; + lease.last_feed = Some(signature); + return Err(HotkeyError::SequenceGap); + } + lease.source_sequence = fact.sequence; + apply_fact(lease, matches!(fact.fact, tinyvoice_bus::HostKeyFact::Down)); + } + lease.last_feed = Some(signature); + Ok(status(lease)) + } + + pub(super) fn read(&self, request: &HotkeyReadRequest) -> HotkeyResult { + validate_handle(&request.handle)?; + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + let lease = leases.get_mut(&request.handle).ok_or(HotkeyError::UnknownHandle)?; + if lease.state != HotkeyState::Running { return Err(HotkeyError::Closed); } + let overflow = lease.native.as_ref().is_some_and(|listener| listener.overflow.swap(false, Ordering::SeqCst)); + if overflow { + if let Some(listener) = lease.native.as_ref() { + while listener.events.try_recv().is_ok() {} + } + reset(lease); + } + else if let Some(listener) = lease.native.as_mut() { + let facts: Vec<_> = std::iter::from_fn(|| listener.events.try_recv().ok()).collect(); + for down in facts { apply_fact(lease, down); } + } + if let Some(batch) = &lease.pending_batch { + match request.acknowledged_batch { + Some(ack) if ack == batch.batch => lease.pending_batch = None, + Some(ack) if ack > batch.batch => return Err(HotkeyError::InvalidRequest), + None | Some(_) => return Ok(batch.clone()), + } + } else if request.acknowledged_batch.is_some() { + return Err(HotkeyError::InvalidRequest); + } + let generation = lease.generation.ok_or(HotkeyError::Closed)?; + let events: Vec<_> = lease.events.drain(..).collect(); + let batch = HotkeyBatch { + generation, batch: lease.next_batch, events, reset: std::mem::take(&mut lease.reset_pending), active: lease.active, + }; + lease.next_batch = lease.next_batch.saturating_add(1); + lease.pending_batch = Some(batch.clone()); + Ok(batch) + } + + pub(super) fn stop(&self, request: &HotkeyHandleRequest) -> HotkeyResult { + validate_handle(&request.handle)?; + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + let lease = leases.get_mut(&request.handle).ok_or(HotkeyError::UnknownHandle)?; + if lease.state == HotkeyState::Running { reset(lease); lease.state = HotkeyState::Stopping; } + if lease.state == HotkeyState::Stopped { return Ok(status(lease)); } + if let Some(listener) = lease.native.as_mut() { + listener.stop()?; + lease.native = None; + } + lease.state = HotkeyState::Stopped; + let status = status(lease); + leases.remove(&request.handle); + Ok(status) + } + + pub(super) fn shutdown(&self) -> HotkeyResult { + self.closed.store(true, Ordering::SeqCst); + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + for lease in leases.values_mut() { + if lease.state == HotkeyState::Running { reset(lease); lease.state = HotkeyState::Stopping; } + if let Some(listener) = lease.native.as_mut() { + listener.stop()?; + lease.native = None; + } + lease.state = HotkeyState::Stopped; + } + leases.clear(); + Ok(HotkeyStatus { state: HotkeyState::Stopped, generation: None, active: false, continuity_lost: false }) + } +} + +fn apply_fact(lease: &mut Lease, down: bool) { + if !down { + lease.source_down = false; + if !lease.armed { lease.armed = true; return; } + if lease.request.mode == ActivationMode::Push && lease.active { emit(lease, HotkeyEvent::Released); } + return; + } + if lease.source_down { return; } + lease.source_down = true; + if !lease.armed { return; } + match lease.request.mode { + ActivationMode::Tap => emit(lease, if lease.active { HotkeyEvent::Released } else { HotkeyEvent::Pressed }), + ActivationMode::Push if !lease.active => emit(lease, HotkeyEvent::Pressed), + ActivationMode::Push => {} + } +} + +fn validate_handle(handle: &HotkeyHandle) -> HotkeyResult<()> { + if handle.0.len() > MAX_HANDLE_BYTES { return Err(HotkeyError::LimitExceeded); } + Ok(()) +} + +fn emit(lease: &mut Lease, event: HotkeyEvent) { + if lease.events.len() >= MAX_EVENTS { + reset(lease); + return; + } + lease.active = event == HotkeyEvent::Pressed; + lease.event_sequence = lease.event_sequence.saturating_add(1); + lease.events.push_back(SequencedHotkeyEvent { generation: lease.generation.unwrap_or_default(), sequence: lease.event_sequence, event }); +} + +fn reset(lease: &mut Lease) { + lease.active = false; + lease.armed = false; + lease.source_down = true; + lease.continuity_lost = true; + lease.reset_pending = true; + lease.events.clear(); + lease.pending_batch = None; +} + +fn status(lease: &Lease) -> HotkeyStatus { + HotkeyStatus { state: lease.state, generation: lease.generation, active: lease.active, continuity_lost: lease.continuity_lost } +} + +fn new_handle() -> HotkeyResult { + let mut bytes = [0_u8; 16]; + getrandom::fill(&mut bytes).map_err(|_| HotkeyError::LimitExceeded)?; + Ok(HotkeyHandle(format!("{:032x}", u128::from_le_bytes(bytes)))) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs new file mode 100644 index 0000000..b0fe1cd --- /dev/null +++ b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs @@ -0,0 +1,297 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use tinyvoice_bus::{HostKeyFact, SequencedHostFact}; +use std::sync::{Arc, atomic::{AtomicBool, AtomicUsize, Ordering}}; + +fn started(mode: ActivationMode) -> (Hotkeys, HotkeyHandle, u64) { + let hotkeys = Hotkeys::default(); + let handle = hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { + key: "Fn".into(), mode, source: HotkeySource::Host, + }}).unwrap(); + let status = hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }).unwrap(); + (hotkeys, handle, status.generation.unwrap()) +} + +fn feed(hotkeys: &Hotkeys, handle: &HotkeyHandle, generation: u64, facts: &[(u64, HostKeyFact)]) -> HotkeyReply { + hotkeys.feed(&HotkeyFeedRequest { + handle: handle.clone(), generation, + facts: facts.iter().map(|(sequence, fact)| SequencedHostFact { sequence: *sequence, fact: *fact }).collect(), + overflow: false, + }).unwrap() +} + +#[test] +fn start_retry_and_duplicate_host_feed_are_idempotent() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + let retry = hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }).unwrap(); + assert_eq!(retry.generation, Some(generation)); + assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); + assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); +} + +#[test] +fn expired_reservation_cannot_be_started_and_releases_capacity() { + let hotkeys = Hotkeys::default(); + let request = HotkeyRequest { key: "Fn".into(), mode: ActivationMode::Push, source: HotkeySource::Host }; + let handle = hotkeys.reserve(HotkeyReserveRequest { request: request.clone() }).unwrap(); + hotkeys.leases.lock().unwrap().get_mut(&handle).unwrap().created = std::time::Instant::now() + .checked_sub(RESERVATION_TTL) + .expect("test TTL fits in the monotonic clock range"); + assert_eq!(hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }), Err(HotkeyError::UnknownHandle)); + assert!(hotkeys.reserve(HotkeyReserveRequest { request }).is_ok()); +} + +#[test] +fn host_source_accepts_only_the_function_key_aliases() { + let hotkeys = Hotkeys::default(); + let invalid = Hotkeys::default().reserve(HotkeyReserveRequest { request: HotkeyRequest { + key: "ctrl+space".into(), mode: ActivationMode::Push, source: HotkeySource::Host, + }}); + assert_eq!(invalid, Err(HotkeyError::InvalidRequest)); + assert!(hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { + key: "fn".into(), mode: ActivationMode::Push, source: HotkeySource::Host, + }}).is_ok()); +} + +#[test] +fn oversized_handles_are_rejected_before_lookup() { + let hotkeys = Hotkeys::default(); + let request = HotkeyHandleRequest { handle: HotkeyHandle("x".repeat(MAX_HANDLE_BYTES + 1)) }; + assert_eq!(hotkeys.start(&request), Err(HotkeyError::LimitExceeded)); + assert_eq!(hotkeys.stop(&request), Err(HotkeyError::LimitExceeded)); + assert_eq!(hotkeys.read(&HotkeyReadRequest { handle: request.handle.clone(), acknowledged_batch: None }), Err(HotkeyError::LimitExceeded)); + assert_eq!(hotkeys.feed(&HotkeyFeedRequest { handle: request.handle, generation: 0, facts: vec![], overflow: false }), Err(HotkeyError::LimitExceeded)); +} + +#[test] +fn read_replays_identical_batch_until_exact_acknowledgment() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]); + let first = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + let replay = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + assert_eq!(first, replay); + let next = hotkeys.read(&HotkeyReadRequest { handle, acknowledged_batch: Some(first.batch) }).unwrap(); + assert!(next.events.is_empty()); + assert!(next.active); +} + +#[test] +fn future_acknowledgment_does_not_mutate_the_retained_batch() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]); + let request = HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }; + let first = hotkeys.read(&request).unwrap(); + assert_eq!( + hotkeys.read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: Some(first.batch + 1), + }), + Err(HotkeyError::InvalidRequest) + ); + assert_eq!(hotkeys.read(&request), Ok(first)); +} + +#[test] +fn output_overflow_resets_inactive_and_rearms_only_after_release() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + let mut facts = Vec::new(); + for sequence in 1..=257 { + facts.push((sequence, if sequence % 2 == 1 { HostKeyFact::Down } else { HostKeyFact::Up })); + } + for batch in facts.chunks(MAX_FEED_FACTS) { + feed(&hotkeys, &handle, generation, batch); + } + let reset = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + assert!(reset.reset); + assert!(!reset.active); + assert!(reset.events.is_empty()); + assert!(!feed(&hotkeys, &handle, generation, &[(258, HostKeyFact::Up)]).active); + assert!(feed(&hotkeys, &handle, generation, &[(259, HostKeyFact::Down)]).active); +} + +#[test] +fn sequence_gap_resets_push_state_until_a_fresh_release() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); + let gap = hotkeys.feed(&HotkeyFeedRequest { handle: handle.clone(), generation, + facts: vec![SequencedHostFact { sequence: 3, fact: HostKeyFact::Down }], overflow: false }).unwrap_err(); + assert_eq!(gap, HotkeyError::SequenceGap); + let reset = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + assert!(reset.reset); + assert!(!reset.active); + assert!(!feed(&hotkeys, &handle, generation, &[(4, HostKeyFact::Down)]).active); + assert!(!feed(&hotkeys, &handle, generation, &[(5, HostKeyFact::Up)]).active); + assert!(feed(&hotkeys, &handle, generation, &[(6, HostKeyFact::Down)]).active); +} + +#[test] +fn upstream_overflow_resets_active_listener_and_requires_release() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); + let status = hotkeys.feed(&HotkeyFeedRequest { handle: handle.clone(), generation, + facts: vec![], overflow: true }).unwrap(); + assert!(!status.active); + assert!(status.continuity_lost); + assert!(!feed(&hotkeys, &handle, generation, &[(2, HostKeyFact::Down)]).active); + assert!(!feed(&hotkeys, &handle, generation, &[(3, HostKeyFact::Up)]).active); + assert!(feed(&hotkeys, &handle, generation, &[(4, HostKeyFact::Down)]).active); +} + +#[derive(Debug)] +struct EndedReaderBackend; + +impl native::Backend for EndedReaderBackend { + fn start(&self, _request: &HotkeyRequest) -> Result { + let (events_tx, events) = std::sync::mpsc::sync_channel(8); + events_tx.send(true).expect("fixture queue accepts Down before EOF"); + drop(events_tx); + let overflow = Arc::new(AtomicBool::new(true)); + Ok(native::NativeListener::new( + events, + overflow, + Arc::new(AtomicBool::new(false)), + Box::new(EndedReaderOwner), + )) + } +} + +#[derive(Debug)] +struct EndedReaderOwner; + +impl native::NativeOwner for EndedReaderOwner { + fn stop(&mut self) -> Result<(), HotkeyError> { Ok(()) } +} + +#[test] +fn native_reader_eof_after_down_returns_inactive_reset_snapshot() { + let hotkeys = Hotkeys::with_backend(Arc::new(EndedReaderBackend)); + let handle = hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { + key: "ctrl+space".into(), mode: ActivationMode::Push, source: HotkeySource::Native, + }}).unwrap(); + hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }).unwrap(); + let snapshot = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + assert!(snapshot.reset, "EOF reports a continuity reset"); + assert!(!snapshot.active, "queued Down cannot survive an ended reader"); + assert!(snapshot.events.is_empty(), "the incomplete event stream is discarded"); + let after_ack = hotkeys.read(&HotkeyReadRequest { handle, acknowledged_batch: Some(snapshot.batch) }).unwrap(); + assert!(!after_ack.active, "stale queued Down is discarded with the reset snapshot"); + assert!(after_ack.events.is_empty()); +} + +#[test] +fn tap_mode_repeats_toggle_without_changing_activation_alias_semantics() { + let (hotkeys, handle, generation) = started(ActivationMode::Tap); + assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); + assert!(feed(&hotkeys, &handle, generation, &[(2, HostKeyFact::Down)]).active); + assert!(feed(&hotkeys, &handle, generation, &[(3, HostKeyFact::Up)]).active); + assert!(!feed(&hotkeys, &handle, generation, &[(4, HostKeyFact::Down)]).active); +} + +#[derive(Debug, Default)] +struct NativeFixtureState { + stop_calls: AtomicUsize, + joined: AtomicBool, +} + +#[derive(Debug)] +struct FakeBackend(Arc); + +impl native::Backend for FakeBackend { + fn start(&self, _request: &HotkeyRequest) -> Result { + let (cancel, wait) = std::sync::mpsc::channel::<()>(); + let joined = self.0.clone(); + let worker = std::thread::spawn(move || { let _ = wait.recv(); joined.joined.store(true, Ordering::SeqCst); }); + let (events_tx, events) = std::sync::mpsc::sync_channel(8); + drop(events_tx); + let owner = FakeOwner { state: self.0.clone(), cancel, worker: Some(worker) }; + Ok(native::NativeListener::new(events, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), Box::new(owner))) + } +} + +#[derive(Debug)] +struct FakeOwner { + state: Arc, + cancel: std::sync::mpsc::Sender<()>, + worker: Option>, +} + +impl native::NativeOwner for FakeOwner { + fn stop(&mut self) -> Result<(), HotkeyError> { + let _ = self.cancel.send(()); + if let Some(worker) = self.worker.take() { worker.join().map_err(|_| HotkeyError::CleanupFailed)?; } + if self.state.stop_calls.fetch_add(1, Ordering::SeqCst) == 0 { return Err(HotkeyError::CleanupFailed); } + Ok(()) + } +} + +#[derive(Debug)] +struct BlockingBackend { + entered: std::sync::mpsc::SyncSender<()>, + release: std::sync::Mutex>, + starts: Arc, +} + +impl native::Backend for BlockingBackend { + fn start(&self, _request: &HotkeyRequest) -> Result { + self.starts.fetch_add(1, Ordering::SeqCst); + self.entered.send(()).map_err(|_| HotkeyError::Closed)?; + self.release.lock().map_err(|_| HotkeyError::Closed)?.recv().map_err(|_| HotkeyError::Closed)?; + let (sender, events) = std::sync::mpsc::sync_channel(8); + drop(sender); + Ok(native::NativeListener::new( + events, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + Box::new(EndedReaderOwner), + )) + } +} + +#[tokio::test] +async fn canceled_start_reply_leaves_running_lease_reachable_for_retry() { + let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let starts = Arc::new(AtomicUsize::new(0)); + let hotkeys = Arc::new(Hotkeys::with_backend(Arc::new(BlockingBackend { + entered: entered_tx, + release: std::sync::Mutex::new(release_rx), + starts: starts.clone(), + }))); + let handle = hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { + key: "ctrl+space".into(), mode: ActivationMode::Push, source: HotkeySource::Native, + }}).unwrap(); + let request = HotkeyHandleRequest { handle: handle.clone() }; + let first_hotkeys = hotkeys.clone(); + let canceled_waiter = tokio::spawn(async move { + let _ = tokio::task::spawn_blocking(move || first_hotkeys.start(&request)).await; + }); + tokio::task::spawn_blocking(move || entered_rx.recv().unwrap()).await.unwrap(); + canceled_waiter.abort(); + release_tx.send(()).unwrap(); + + let retry_hotkeys = hotkeys.clone(); + let retry_request = HotkeyHandleRequest { handle }; + let retried = tokio::time::timeout( + std::time::Duration::from_secs(2), + tokio::task::spawn_blocking(move || retry_hotkeys.start(&retry_request)), + ).await.unwrap().unwrap().unwrap(); + assert_eq!(retried.state, HotkeyState::Running); + assert_eq!(retried.generation, Some(1)); + assert_eq!(starts.load(Ordering::SeqCst), 1, "retry must return the retained listener"); +} + +#[test] +fn failed_native_cleanup_keeps_retryable_ownership_and_never_acknowledges_stop() { + let state = Arc::new(NativeFixtureState::default()); + let hotkeys = Hotkeys::with_backend(Arc::new(FakeBackend(state.clone()))); + let handle = hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { + key: "ctrl+space".into(), mode: ActivationMode::Push, source: HotkeySource::Native, + }}).unwrap(); + hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }).unwrap(); + assert_eq!(hotkeys.stop(&HotkeyHandleRequest { handle: handle.clone() }), Err(HotkeyError::CleanupFailed)); + assert!(state.joined.load(Ordering::SeqCst), "stop waits for the native reader"); + assert_eq!(hotkeys.stop(&HotkeyHandleRequest { handle }), Ok(HotkeyStatus { + state: HotkeyState::Stopped, generation: Some(1), active: false, continuity_lost: true, + })); +} diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs new file mode 100644 index 0000000..23b4026 --- /dev/null +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs @@ -0,0 +1,175 @@ +//! Linux/X11 listener backed by an owned X RECORD context and data socket. +use super::{NativeListener, NativeOwner}; +use std::collections::HashSet; +use std::os::fd::AsFd; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}, mpsc}; +use std::thread; +use tinyvoice_bus::{HotkeyError, HotkeyRequest}; +use tinyvoice::hotkey::{ActivationMode, HotkeyCombination, Key}; +use x11rb::{connection::Connection, protocol::record::{self, ConnectionExt as _}}; +const CONTROL_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300); + +pub(super) fn start(request: &HotkeyRequest) -> Result { + let combo = tinyvoice::hotkey::parse_hotkey(&request.key).map_err(|_| HotkeyError::InvalidRequest)?; + if combo.trigger == Key::Function || combo.modifiers.contains(&Key::Function) { return Err(HotkeyError::Unsupported); } + let (control, _) = x11rb::connect(None).map_err(|_| HotkeyError::Unsupported)?; + rustix::net::sockopt::set_socket_timeout(control.stream(), rustix::net::sockopt::Timeout::Recv, Some(CONTROL_TIMEOUT)).map_err(|_| HotkeyError::Unsupported)?; + rustix::net::sockopt::set_socket_timeout(control.stream(), rustix::net::sockopt::Timeout::Send, Some(CONTROL_TIMEOUT)).map_err(|_| HotkeyError::Unsupported)?; + let (data, _) = x11rb::connect(None).map_err(|_| HotkeyError::Unsupported)?; + let context = control.generate_id().map_err(|_| HotkeyError::Unsupported)?; + let empty = record::Range8 { first: 0, last: 0 }; + let range = record::Range { + core_requests: empty, + core_replies: empty, + ext_requests: record::ExtRange { major: empty, minor: record::Range16 { first: 0, last: 0 } }, + ext_replies: record::ExtRange { major: empty, minor: record::Range16 { first: 0, last: 0 } }, + delivered_events: empty, + device_events: record::Range8 { first: 2, last: 3 }, + errors: empty, + client_started: false, + client_died: false, + }; + control.record_create_context(context, 0, &[record::CS::ALL_CLIENTS.into()], &[range]) + .map_err(|_| HotkeyError::Unsupported)?.check().map_err(|_| HotkeyError::Unsupported)?; + let wake = rustix::io::dup(data.stream().as_fd()).map_err(|_| HotkeyError::Unsupported)?; + let (events_tx, events) = mpsc::sync_channel(256); + let overflow = Arc::new(AtomicBool::new(false)); + let worker_overflow = overflow.clone(); + let closing = Arc::new(AtomicBool::new(false)); + let worker_closing = closing.clone(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let mode = match request.mode { + tinyvoice_bus::ActivationMode::Tap => ActivationMode::Tap, + tinyvoice_bus::ActivationMode::Push => ActivationMode::Push, + }; + let combo_for_thread = combo.clone(); + let worker = thread::Builder::new().name("tinyvoice-hotkey-x11".into()).spawn(move || { + let Ok(mut replies) = data.record_enable_context(context) else { + let _ = ready_tx.send(Err(())); + return; + }; + let _ = ready_tx.send(Ok(())); + let mut down = HashSet::new(); + while let Some(Ok(reply)) = replies.next() { + if reply.category == 5 { break; } + process_record_bytes(&reply.data, &combo_for_thread, mode, &mut down, &events_tx, &worker_overflow); + } + if !worker_closing.load(Ordering::SeqCst) { worker_overflow.store(true, Ordering::SeqCst); } + }).map_err(|_| HotkeyError::Unsupported)?; + let Ok(Ok(())) = ready_rx.recv() else { + let mut owner = XRecordOwner { control, context: Some(context), disable_sent: false, wake, worker: Some(worker) }; + let _ = owner.stop(); + return Err(HotkeyError::Unsupported); + }; + let owner = XRecordOwner { control, context: Some(context), disable_sent: false, wake, worker: Some(worker) }; + Ok(NativeListener::new(events, overflow, closing, Box::new(owner))) +} + +#[derive(Debug)] +pub(super) struct XRecordOwner { + control: x11rb::rust_connection::RustConnection, + context: Option, + disable_sent: bool, + wake: rustix::fd::OwnedFd, + worker: Option>, +} + +impl NativeOwner for XRecordOwner { + fn stop(&mut self) -> Result<(), HotkeyError> { + let disable_result = if let Some(context) = self.context.filter(|_| !self.disable_sent) { + match self.control.record_disable_context(context) { + Ok(cookie) => { + self.disable_sent = true; + cookie.check().map_err(|_| HotkeyError::CleanupFailed) + } + Err(_) => Err(HotkeyError::CleanupFailed), + } + } else { Ok(()) }; + let _ = rustix::net::shutdown(&self.wake, rustix::net::Shutdown::Both); + if let Some(worker) = self.worker.take() { worker.join().map_err(|_| HotkeyError::CleanupFailed)?; } + disable_result?; + if let Some(context) = self.context { + self.control.record_free_context(context).map_err(|_| HotkeyError::CleanupFailed)? + .check().map_err(|_| HotkeyError::CleanupFailed)?; + self.context = None; + } + Ok(()) + } +} + +fn process_record_bytes( + bytes: &[u8], + combo: &HotkeyCombination, + mode: ActivationMode, + down: &mut HashSet, + events: &mpsc::SyncSender, + overflow: &AtomicBool, +) { + let frames = bytes.chunks_exact(32).collect::>(); + let mut index = 0; + while index < frames.len() { + let frame = frames[index]; + let kind = frame[0] & 0x7f; + let code = frame[1]; + if kind == 3 && frames.get(index + 1).is_some_and(|next| { + next[0] & 0x7f == 2 && next[1] == code && next[4..8] == frame[4..8] + }) { + // X11 auto-repeat is a same-time release/press pair. Preserve the + // held state so repeats do not toggle tap or release push mode. + index += 2; + continue; + } + if matches!(kind, 2 | 3) + && let Some(key) = key_from_x_keycode(u32::from(code)) + && let Some(pressed) = process_event(key, kind == 2, combo, mode, down) + && events.try_send(pressed).is_err() + { + overflow.store(true, Ordering::SeqCst); + } + index += 1; + } +} + +fn process_event(key: Key, pressed: bool, combo: &HotkeyCombination, mode: ActivationMode, down: &mut HashSet) -> Option { + if pressed { + let is_new = down.insert(key); + if !is_new || key != combo.trigger || !combo.modifiers.iter().all(|modifier| down.contains(modifier)) { return None; } + return Some(true); + } + down.remove(&key); + if key == combo.trigger { return Some(false); } + if mode == ActivationMode::Push && combo.modifiers.contains(&key) && down.contains(&combo.trigger) { + down.remove(&combo.trigger); + return Some(false); + } + None +} + +fn key_from_x_keycode(code: u32) -> Option { + Some(match code { + 64 => Key::Alt, 108 => Key::AltGr, 22 => Key::Backspace, 66 => Key::CapsLock, + 37 => Key::ControlLeft, 105 => Key::ControlRight, 119 => Key::Delete, 116 => Key::DownArrow, + 115 => Key::End, 9 => Key::Escape, 67 => Key::F1, 76 => Key::F10, 95 => Key::F11, 96 => Key::F12, + 68 => Key::F2, 69 => Key::F3, 70 => Key::F4, 71 => Key::F5, 72 => Key::F6, 73 => Key::F7, + 74 => Key::F8, 75 => Key::F9, 110 => Key::Home, 113 => Key::LeftArrow, 133 => Key::MetaLeft, 134 => Key::MetaRight, + 117 => Key::PageDown, 112 => Key::PageUp, 36 => Key::Return, 114 => Key::RightArrow, + 50 => Key::ShiftLeft, 62 => Key::ShiftRight, 65 => Key::Space, 23 => Key::Tab, 111 => Key::UpArrow, + 49 => Key::BackQuote, 10 => Key::Num1, 11 => Key::Num2, 12 => Key::Num3, 13 => Key::Num4, + 14 => Key::Num5, 15 => Key::Num6, 16 => Key::Num7, 17 => Key::Num8, 18 => Key::Num9, 19 => Key::Num0, + 20 => Key::Minus, 21 => Key::Equal, 24 => Key::KeyQ, 25 => Key::KeyW, 26 => Key::KeyE, 27 => Key::KeyR, + 28 => Key::KeyT, 29 => Key::KeyY, 30 => Key::KeyU, 31 => Key::KeyI, 32 => Key::KeyO, 33 => Key::KeyP, + 34 => Key::LeftBracket, 35 => Key::RightBracket, 38 => Key::KeyA, 39 => Key::KeyS, 40 => Key::KeyD, + 41 => Key::KeyF, 42 => Key::KeyG, 43 => Key::KeyH, 44 => Key::KeyJ, 45 => Key::KeyK, 46 => Key::KeyL, + 47 => Key::SemiColon, 48 => Key::Quote, 51 => Key::BackSlash, 94 => Key::IntlBackslash, + 52 => Key::KeyZ, 53 => Key::KeyX, 54 => Key::KeyC, 55 => Key::KeyV, 56 => Key::KeyB, 57 => Key::KeyN, + 58 => Key::KeyM, 59 => Key::Comma, 60 => Key::Dot, 61 => Key::Slash, 118 => Key::Insert, + 104 => Key::KpReturn, 82 => Key::KpMinus, 86 => Key::KpPlus, 63 => Key::KpMultiply, 106 => Key::KpDivide, + 90 => Key::Kp0, 87 => Key::Kp1, 88 => Key::Kp2, 89 => Key::Kp3, 83 => Key::Kp4, 84 => Key::Kp5, + 85 => Key::Kp6, 79 => Key::Kp7, 80 => Key::Kp8, 81 => Key::Kp9, 91 => Key::KpDelete, + _ => return None, + }) +} + +#[cfg(test)] +#[path = "linux_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs new file mode 100644 index 0000000..bcec8d1 --- /dev/null +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs @@ -0,0 +1,82 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use std::collections::HashSet; + +#[test] +fn local_xrecord_listener_cancels_blocked_reader_and_joins() { + if std::env::var_os("DISPLAY").is_none() { return; } + let request = HotkeyRequest { + key: "ctrl+space".to_owned(), + mode: tinyvoice_bus::ActivationMode::Push, + source: tinyvoice_bus::HotkeySource::Native, + }; + let mut listener = super::super::backend() + .start(&request) + .expect("Xvfb X RECORD startup"); + assert!(listener.events.try_recv().is_err()); + listener.stop().expect("X RECORD stop disables context and joins reader"); +} + +#[test] +fn autorepeat_release_press_pair_does_not_end_or_retoggle_activation() { + let combo = tinyvoice::hotkey::parse_hotkey("space").expect("known key"); + let mut bytes = Vec::new(); + bytes.extend(key_event(2, 65, 10)); + bytes.extend(key_event(3, 65, 11)); + bytes.extend(key_event(2, 65, 11)); + bytes.extend(key_event(3, 65, 12)); + let (sender, receiver) = mpsc::sync_channel(8); + let mut down = HashSet::new(); + let overflow = AtomicBool::new(false); + process_record_bytes(&bytes, &combo, ActivationMode::Push, &mut down, &sender, &overflow); + assert_eq!(receiver.try_iter().collect::>(), vec![true, false]); + assert!(!overflow.load(Ordering::SeqCst)); +} + +#[test] +fn physical_x_keycodes_keep_the_existing_key_vocabulary() { + let mappings = [ + (64, Key::Alt), (37, Key::ControlLeft), (9, Key::Escape), (68, Key::F2), + (110, Key::Home), (117, Key::PageDown), (50, Key::ShiftLeft), (49, Key::BackQuote), + (10, Key::Num1), (20, Key::Minus), (24, Key::KeyQ), (34, Key::LeftBracket), + (41, Key::KeyF), (47, Key::SemiColon), (52, Key::KeyZ), (118, Key::Insert), + (104, Key::KpReturn), (85, Key::Kp6), (91, Key::KpDelete), + ]; + for (keycode, expected) in mappings { + assert_eq!(key_from_x_keycode(keycode), Some(expected)); + } + assert_eq!(key_from_x_keycode(0), None); +} + +#[test] +fn push_modifier_release_ends_activation_before_trigger_release() { + let combo = tinyvoice::hotkey::parse_hotkey("ctrl+space").unwrap(); + let mut down = HashSet::new(); + assert_eq!(process_event(Key::ControlLeft, true, &combo, ActivationMode::Push, &mut down), None); + assert_eq!(process_event(Key::Space, true, &combo, ActivationMode::Push, &mut down), Some(true)); + assert_eq!(process_event(Key::ControlLeft, false, &combo, ActivationMode::Push, &mut down), Some(false)); + assert_eq!(process_event(Key::Space, false, &combo, ActivationMode::Push, &mut down), Some(false)); +} + +#[test] +fn a_full_native_event_queue_reports_continuity_loss() { + let combo = tinyvoice::hotkey::parse_hotkey("space").unwrap(); + let mut bytes = Vec::new(); + bytes.extend(key_event(2, 65, 1)); + bytes.extend(key_event(3, 65, 2)); + let (sender, receiver) = mpsc::sync_channel(1); + let mut down = HashSet::new(); + let overflow = AtomicBool::new(false); + process_record_bytes(&bytes, &combo, ActivationMode::Push, &mut down, &sender, &overflow); + assert!(overflow.load(Ordering::SeqCst)); + assert_eq!(receiver.try_iter().collect::>(), vec![true]); +} + +fn key_event(kind: u8, keycode: u8, timestamp: u32) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + bytes[0] = kind; + bytes[1] = keycode; + bytes[4..8].copy_from_slice(×tamp.to_ne_bytes()); + bytes +} diff --git a/crates/tinyvoice-module/src/service/hotkey/native/mod.rs b/crates/tinyvoice-module/src/service/hotkey/native/mod.rs new file mode 100644 index 0000000..a886d0d --- /dev/null +++ b/crates/tinyvoice-module/src/service/hotkey/native/mod.rs @@ -0,0 +1,61 @@ +//! Platform owner boundary for cancellable native listeners. +use std::sync::{Arc, atomic::AtomicBool, mpsc}; +use tinyvoice_bus::{HotkeyError, HotkeyRequest}; + +#[derive(Debug)] +pub(super) struct NativeListener { + pub events: mpsc::Receiver, + pub overflow: Arc, + closing: Arc, + owner: Box, +} + +pub(super) trait NativeOwner: std::fmt::Debug + Send { + fn stop(&mut self) -> Result<(), HotkeyError>; +} + +pub(super) trait Backend: std::fmt::Debug + Send + Sync { + fn start(&self, request: &HotkeyRequest) -> Result; +} + +impl NativeListener { + pub(super) fn new(events: mpsc::Receiver, overflow: Arc, closing: Arc, owner: Box) -> Self { + Self { events, overflow, closing, owner } + } +} + +impl NativeListener { + pub(super) fn stop(&mut self) -> Result<(), HotkeyError> { + self.closing.store(true, std::sync::atomic::Ordering::SeqCst); + self.owner.stop() + } +} + +impl Drop for NativeListener { + fn drop(&mut self) { let _ = self.stop(); } +} + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "windows")] +mod windows; + +#[derive(Debug, Default)] +struct PlatformBackend; + +impl Backend for PlatformBackend { + fn start(&self, request: &HotkeyRequest) -> Result { start_platform(request) } +} + +pub(super) fn backend() -> Arc { Arc::new(PlatformBackend) } + +fn start_platform(request: &HotkeyRequest) -> Result { + #[cfg(target_os = "linux")] + { linux::start(request) } + #[cfg(target_os = "windows")] + { windows::start(request) } + #[cfg(target_os = "macos")] + { let _ = request; Err(HotkeyError::Unsupported) } + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] + { let _ = request; Err(HotkeyError::Unsupported) } +} diff --git a/crates/tinyvoice-module/src/service/hotkey/native/windows.rs b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs new file mode 100644 index 0000000..8a64730 --- /dev/null +++ b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs @@ -0,0 +1,78 @@ +//! Windows adapter for the narrowly unsafe, owner-thread hook crate. +use super::{NativeListener, NativeOwner}; +use std::sync::{Arc, atomic::AtomicBool}; +use tinyvoice::hotkey::Key; +use tinyvoice_bus::{ActivationMode, HotkeyError, HotkeyRequest}; +use tinyvoice_hotkey_win::{Chord, Listener}; + +pub(super) fn start(request: &HotkeyRequest) -> Result { + let combination = tinyvoice::hotkey::parse_hotkey(&request.key).map_err(|_| HotkeyError::InvalidRequest)?; + let trigger = virtual_key(combination.trigger).ok_or(HotkeyError::Unsupported)?; + let modifiers = combination.modifiers.iter().map(|key| virtual_key(*key).ok_or(HotkeyError::Unsupported)).collect::, _>>()?; + let chord = Chord { trigger, modifiers, push: request.mode == ActivationMode::Push }; + let mut listener = tinyvoice_hotkey_win::start(chord).map_err(|_| HotkeyError::Unsupported)?; + let events = listener.take_events().ok_or(HotkeyError::Unsupported)?; + let (sender, receiver) = std::sync::mpsc::sync_channel(256); + let overflow = listener.overflow(); + let bridge_overflow = overflow.clone(); + let bridge = std::thread::Builder::new().name("tinyvoice-hotkey-win-bridge".into()).spawn(move || { + while let Ok(event) = events.recv() { + if sender.try_send(event).is_err() { bridge_overflow.store(true, std::sync::atomic::Ordering::SeqCst); } + } + }).map_err(|_| HotkeyError::Unsupported)?; + Ok(NativeListener::new(receiver, overflow, Arc::new(AtomicBool::new(false)), Box::new(WindowsOwner { listener, bridge: Some(bridge) }))) +} + +#[derive(Debug)] +struct WindowsOwner { listener: Listener, bridge: Option> } + +impl NativeOwner for WindowsOwner { + fn stop(&mut self) -> Result<(), HotkeyError> { + self.listener.stop().map_err(|_| HotkeyError::CleanupFailed)?; + if let Some(bridge) = self.bridge.take() { bridge.join().map_err(|_| HotkeyError::CleanupFailed)?; } + Ok(()) + } +} + +impl Drop for WindowsOwner { + fn drop(&mut self) { + while self.stop().is_err() { std::thread::sleep(std::time::Duration::from_millis(50)); } + } +} + +fn virtual_key(key: Key) -> Option { + Some(match key { + Key::Alt => 0xA4, Key::AltGr => 0xA5, Key::Backspace => 0x08, Key::CapsLock => 0x14, + Key::ControlLeft => 0xA2, Key::ControlRight => 0xA3, Key::Delete => 0x2E, Key::DownArrow => 0x28, + Key::End => 0x23, Key::Escape => 0x1B, Key::F1 => 0x70, Key::F2 => 0x71, Key::F3 => 0x72, + Key::F4 => 0x73, Key::F5 => 0x74, Key::F6 => 0x75, Key::F7 => 0x76, Key::F8 => 0x77, + Key::F9 => 0x78, Key::F10 => 0x79, Key::F11 => 0x7A, Key::F12 => 0x7B, Key::Home => 0x24, + Key::LeftArrow => 0x25, Key::MetaLeft => 0x5B, Key::MetaRight => 0x5C, Key::PageDown => 0x22, + Key::PageUp => 0x21, Key::Return | Key::KpReturn => 0x0D, Key::RightArrow => 0x27, + Key::ShiftLeft => 0xA0, Key::ShiftRight => 0xA1, Key::Space => 0x20, Key::Tab => 0x09, + Key::UpArrow => 0x26, Key::PrintScreen => 0x2C, Key::ScrollLock => 0x91, Key::Pause => 0x13, + Key::NumLock => 0x90, Key::BackQuote => 0xC0, Key::Num0 => 0x30, Key::Num1 => 0x31, + Key::Num2 => 0x32, Key::Num3 => 0x33, Key::Num4 => 0x34, Key::Num5 => 0x35, Key::Num6 => 0x36, + Key::Num7 => 0x37, Key::Num8 => 0x38, Key::Num9 => 0x39, Key::Minus => 0xBD, Key::Equal => 0xBB, + Key::KeyA => b'A' as u32, Key::KeyB => b'B' as u32, Key::KeyC => b'C' as u32, + Key::KeyD => b'D' as u32, Key::KeyE => b'E' as u32, Key::KeyF => b'F' as u32, + Key::KeyG => b'G' as u32, Key::KeyH => b'H' as u32, Key::KeyI => b'I' as u32, + Key::KeyJ => b'J' as u32, Key::KeyK => b'K' as u32, Key::KeyL => b'L' as u32, + Key::KeyM => b'M' as u32, Key::KeyN => b'N' as u32, Key::KeyO => b'O' as u32, + Key::KeyP => b'P' as u32, Key::KeyQ => b'Q' as u32, Key::KeyR => b'R' as u32, + Key::KeyS => b'S' as u32, Key::KeyT => b'T' as u32, Key::KeyU => b'U' as u32, + Key::KeyV => b'V' as u32, Key::KeyW => b'W' as u32, Key::KeyX => b'X' as u32, + Key::KeyY => b'Y' as u32, Key::KeyZ => b'Z' as u32, Key::LeftBracket => 0xDB, + Key::RightBracket => 0xDD, Key::SemiColon => 0xBA, Key::Quote => 0xDE, Key::BackSlash => 0xDC, + Key::IntlBackslash => 0xE2, Key::Comma => 0xBC, Key::Dot => 0xBE, Key::Slash => 0xBF, + Key::Insert => 0x2D, Key::KpMinus => 0x6D, Key::KpPlus => 0x6B, Key::KpMultiply => 0x6A, + Key::KpDivide => 0x6F, Key::Kp0 => 0x60, Key::Kp1 => 0x61, Key::Kp2 => 0x62, Key::Kp3 => 0x63, + Key::Kp4 => 0x64, Key::Kp5 => 0x65, Key::Kp6 => 0x66, Key::Kp7 => 0x67, Key::Kp8 => 0x68, + Key::Kp9 => 0x69, Key::KpDelete => 0x6E, + Key::Function | Key::Unknown(_) => return None, + }) +} + +#[cfg(test)] +#[path = "windows_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 7c160fd..b6412ed 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -8,6 +8,7 @@ use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use tinybus::{Connection, Result as TinyBusResult}; use tinyvoice_bus::{IndexedVadEvent, names}; +use tinyvoice_bus::{HotkeyFeedRequest, HotkeyHandleRequest, HotkeyReadRequest, HotkeyReserveRequest, HotkeyResult}; use tinyvoice::audio::{self, SilenceGateConfig}; use tinyvoice::intent; @@ -52,6 +53,7 @@ pub struct VoiceService { /// for long. It is deliberately never held across an `.await`. sessions: std::sync::Mutex, capture: std::sync::Arc, + hotkeys: std::sync::Arc, } /// The session table and the counter that names its entries. @@ -586,6 +588,36 @@ impl VoiceService { } Ok(BASE64.encode(wav)) } + /// Reserve bounded hotkey state before native listener startup. + async fn hotkey_reserve(&self, request: HotkeyReserveRequest) -> TinyBusResult> { + Ok(self.hotkeys.reserve(request)) + } + /// Start a reserved listener; retries return the same running lease. + async fn hotkey_start(&self, request: HotkeyHandleRequest) -> TinyBusResult> { + let hotkeys = self.hotkeys.clone(); + tokio::task::spawn_blocking(move || hotkeys.start(&request)) + .await.map_err(|_| tinybus::Error::failed("hotkey startup worker failed")) + } + /// Read the retained oldest activation batch until it is acknowledged. + async fn hotkey_read(&self, request: HotkeyReadRequest) -> TinyBusResult> { + Ok(self.hotkeys.read(&request)) + } + /// Feed generic, bounded host-owned key facts. + async fn hotkey_feed(&self, request: HotkeyFeedRequest) -> TinyBusResult> { + Ok(self.hotkeys.feed(&request)) + } + /// Stop a listener after its owned cleanup completes. + async fn hotkey_stop(&self, request: HotkeyHandleRequest) -> TinyBusResult> { + let hotkeys = self.hotkeys.clone(); + tokio::task::spawn_blocking(move || hotkeys.stop(&request)) + .await.map_err(|_| tinybus::Error::failed("hotkey cleanup worker failed")) + } + /// Close admission and stop every listener before unloading the module. + async fn hotkey_shutdown(&self) -> TinyBusResult> { + let hotkeys = self.hotkeys.clone(); + tokio::task::spawn_blocking(move || hotkeys.shutdown()) + .await.map_err(|_| tinybus::Error::failed("hotkey shutdown worker failed")) + } } /// Claim the bus name and serve the interface. @@ -628,6 +660,12 @@ tinybus_module::module_export_optional_static! { "RecordingCancel", "ReadAudioOutput", "ReleaseAudioOutput", + "HotkeyReserve", + "HotkeyStart", + "HotkeyRead", + "HotkeyFeed", + "HotkeyStop", + "HotkeyShutdown", ], signals = [], @@ -637,3 +675,4 @@ tinybus_module::module_export_optional_static! { } mod capture; +mod hotkey; diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index 65d131d..244789c 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -20,6 +20,7 @@ use tinybus::broker::Broker; use tinybus::transport::memory::MemoryBus; use tinybus::{Connection, Interface}; use tinyvoice_bus::{BUS_NAME, OBJECT_PATH, names}; +use tinyvoice_bus::{ActivationMode, HotkeyFeedRequest, HotkeyHandleRequest, HotkeyReadRequest, HotkeyRequest, HotkeyReserveRequest, HotkeySource, HostKeyFact, SequencedHostFact}; /// A live bus with the interface served on it. /// @@ -895,3 +896,42 @@ async fn capture_shutdown_is_terminal_over_the_bus() -> tinybus::Result<()> { assert_eq!(reservation, Err(CaptureError::Closed)); Ok(()) } + +#[tokio::test] +async fn hotkey_contract_replays_events_and_shutdown_is_terminal() -> tinybus::Result<()> { + use tinyvoice_bus::{HotkeyError, HotkeyResult}; + let proxy = connect().await?; + let reserved: HotkeyResult = proxy.call( + names::methods::HOTKEY_RESERVE, + (HotkeyReserveRequest { request: HotkeyRequest { key: "Fn".into(), mode: ActivationMode::Push, source: HotkeySource::Host } },), + ).await?; + let handle = reserved.unwrap(); + let started: HotkeyResult = proxy.call( + names::methods::HOTKEY_START, + (HotkeyHandleRequest { handle: handle.clone() },), + ).await?; + let generation = started.unwrap().generation.unwrap(); + let fed: HotkeyResult = proxy.call( + names::methods::HOTKEY_FEED, + (HotkeyFeedRequest { handle: handle.clone(), generation, facts: vec![SequencedHostFact { sequence: 1, fact: HostKeyFact::Down }], overflow: false },), + ).await?; + assert!(fed.unwrap().active); + let read = HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }; + let first: HotkeyResult = proxy.call(names::methods::HOTKEY_READ, (read.clone(),)).await?; + let first = first.unwrap(); + assert_eq!(first.events[0].event, tinyvoice_bus::HotkeyEvent::Pressed); + let replay: HotkeyResult = proxy.call(names::methods::HOTKEY_READ, (read,)).await?; + assert_eq!(replay.unwrap(), first); + let stopped: HotkeyResult = proxy.call( + names::methods::HOTKEY_STOP, (HotkeyHandleRequest { handle },), + ).await?; + assert_eq!(stopped.unwrap().state, tinyvoice_bus::HotkeyState::Stopped); + let shutdown: HotkeyResult = proxy.call(names::methods::HOTKEY_SHUTDOWN, ()).await?; + assert_eq!(shutdown.unwrap().state, tinyvoice_bus::HotkeyState::Stopped); + let after: HotkeyResult = proxy.call( + names::methods::HOTKEY_RESERVE, + (HotkeyReserveRequest { request: HotkeyRequest { key: "Fn".into(), mode: ActivationMode::Tap, source: HotkeySource::Host } },), + ).await?; + assert_eq!(after, Err(HotkeyError::Closed)); + Ok(()) +} From 527693729af095893d2e5125214b41d68cbb005f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:21:21 +0300 Subject: [PATCH 06/25] Document module hotkey lifecycle Co-authored-by: Medulla --- README.md | 1 + docs/README.md | 5 ++ docs/plans/README.md | 3 + docs/plans/hotkey-module-lifecycle.md | 41 ++++++++++++++ docs/specs/README.md | 3 + docs/specs/hotkey-module-lifecycle.md | 81 +++++++++++++++++++++++++++ 6 files changed, 134 insertions(+) create mode 100644 docs/plans/hotkey-module-lifecycle.md create mode 100644 docs/specs/hotkey-module-lifecycle.md diff --git a/README.md b/README.md index 661384f..bf77d24 100644 --- a/README.md +++ b/README.md @@ -129,4 +129,5 @@ Contract 1.3 adds device enumeration, reserved startup/cancellation, one-shot re handles. The host supplies a microphone permission grant obtained through TinyComputer; the compiled module owns the device, audio preparation, bounded WAV reads, bounded raw chunk polling, cancellation and release. See [capture contract](docs/specs/module-capture.md). +The next module-owned lifecycle is specified in [hotkeys](docs/specs/hotkey-module-lifecycle.md). Hotkey bus lifecycles remain a separate migration. diff --git a/docs/README.md b/docs/README.md index 0c0f2b1..f2215c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,11 @@ The current module-release contract is in implementation sequence in [`plans/tinybus-module-release.md`](plans/tinybus-module-release.md). +The approved hotkey ownership and lifecycle contract is in +[`specs/hotkey-module-lifecycle.md`](specs/hotkey-module-lifecycle.md), with +its implementation sequence in +[`plans/hotkey-module-lifecycle.md`](plans/hotkey-module-lifecycle.md). + ## Conventions - Keep every Markdown file at 500 lines or fewer. When a topic outgrows that, diff --git a/docs/plans/README.md b/docs/plans/README.md index 0a5db16..673208c 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -20,3 +20,6 @@ code snippets when they remove ambiguity, but do not paste entire future files into the plan. See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample. + +The accepted hotkey lifecycle implementation plan is +[`hotkey-module-lifecycle.md`](hotkey-module-lifecycle.md). diff --git a/docs/plans/hotkey-module-lifecycle.md b/docs/plans/hotkey-module-lifecycle.md new file mode 100644 index 0000000..1a55c2f --- /dev/null +++ b/docs/plans/hotkey-module-lifecycle.md @@ -0,0 +1,41 @@ +# Implement module-owned hotkey lifecycle + +Specification: [`../specs/hotkey-module-lifecycle.md`](../specs/hotkey-module-lifecycle.md) + +## Goal and assumptions + +Add the six approved TinyVoice 1.4 hotkey members without changing current library behavior or host adapters. TinyComputer GlobeRead/GlobeShutdown is published at contract 2.12; Voice receives only generic sequenced FN facts. Linux implementation targets X11. No root gitlink, release pin, or host migration is part of this source slice. + +## Tasks + +1. **Pin activation semantics.** Add module tests for current key aliases, left/right modifiers, trigger-first/modifier-first, repeat press, tap toggle, push trigger release, and push modifier release. Confirm each test fails if the corresponding expected transition is inverted or omitted. Extract or expose the smallest pure reducer from `tinyvoice` if that avoids duplicating its existing behavior. +2. **Prototype and own Linux X11 cancellation.** Under ignored `target/`, keep the Xvfb RECORD prototype and its output. Add a native owner seam with startup readiness, bounded event handoff, cancellation handle, cleanup result, and join handle. Add fixture tests for cancellation while the record iterator is blocked, failed cleanup retaining ownership, retry, and shutdown waiting for join. Only after these regressions pass, implement the x11rb RECORD backend using the validated duplicated-FD shutdown path and physical keycode mapping. +3. **Add pure TinyVoice 1.4 vocabulary.** Add the six method names and request/status/error/event types under `crates/tinyvoice-bus/src/hotkey.rs`; add exact serde tests and bump `CONTRACT_VERSION` to `(1, 4)`. Keep names/catalogue and manifest method list synchronized. +4. **Build the listener lease manager.** Add bounded reservation, idempotent start, replayable Read snapshots, generation-fenced host Feed, event-sequence gap reset, retryable Stop, terminal Shutdown, and injected backend fixtures under `crates/tinyvoice-module/src/service/hotkey/`. Each state transition gets a failing fixture first. Run in-memory TinyBus wire tests after each contract/member change. +5. **Implement Windows ownership.** Add the owner-thread message loop, keyboard-only low-level hook, bounded callback handoff, owner-thread unregister, and joined shutdown. Use local fake/native-owner fixtures for startup race, cancellation, failed unregister, and retry. If the Windows binding requires unsafe code, isolate it in the one documented FFI file and explicitly limit the lint allowance there. +6. **Implement host-fed macOS source.** Reject native macOS registration. Accept only bounded typed FN facts for the generation returned by Start; pin duplicate, stale generation, lost reply, sequence gap, overflow reset, and next-release rearm behavior. Do not add a TinyComputer dependency. +7. **Verify and document the artifact.** Add the operations to module/manifest artifact verification and docs; test exact member arities/wires. Run library and module fmt, strict clippy, all-target builds, default/all tests, strict docs, pure dependency closures, and per-file coverage. Rebuild and run the actual module verifier. Save RED/GREEN logs, gate outputs, coverage, final diff, and artifact digest under ignored `target/`, then freeze the source head for independent review before extending canonical PR23. + +## Verification commands + +Run long commands from the TinyVoice owner root through `../scripts/ci-cancel-aware.sh` when available (otherwise the repository's equivalent cancel-aware helper): + +```sh +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo build --all-targets --all-features +cargo test --all-features +cargo test --manifest-path crates/tinyvoice-module/Cargo.toml --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features +``` + +Also run default tests, the module artifact verifier, dependency-closure checks for bus default/all features, and the per-file 90% coverage gate. Keep all command logs and built artifacts under the owner's `target/` tree. + +## Completion checklist + +- [ ] Linux cancellation prototype and owner fixtures pass. +- [ ] Contract and manifest both report 1.4 and the same method list. +- [ ] Linux, Windows, and macOS-feed behaviors meet the accepted specification. +- [ ] Stop/shutdown tests prove actual cleanup and join; fault tests prove retryability. +- [ ] Library and module gates, coverage, and actual artifact verification pass. +- [ ] Frozen diff/evidence is independently reviewed before PR23 extension. diff --git a/docs/specs/README.md b/docs/specs/README.md index a8286ae..beb8868 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -21,3 +21,6 @@ After the specification is accepted, create a linked implementation plan in the contract; production code still belongs under `src/`. See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. + +The accepted hotkey lifecycle is specified in +[`hotkey-module-lifecycle.md`](hotkey-module-lifecycle.md). diff --git a/docs/specs/hotkey-module-lifecycle.md b/docs/specs/hotkey-module-lifecycle.md new file mode 100644 index 0000000..70a9d67 --- /dev/null +++ b/docs/specs/hotkey-module-lifecycle.md @@ -0,0 +1,81 @@ +# Module-owned hotkey lifecycle + +**Status:** Accepted +**Owner:** TinyVoice module and bus + +## Problem + +The current rdev listener runs in a detached process thread. `stop` only suppresses callback output, Drop detaches the thread, and the process-wide listener guard prevents reconnect. That lifetime cannot safely be acknowledged by a dynamically loaded module or used across sign-out and listener replacement. + +## Goals + +- Run hotkey acquisition and activation inside the compiled TinyVoice module. +- Preserve supported key aliases, left/right modifiers, key repeat handling, and tap/push behavior. +- Bound listener reservations, native input queues, bus feed batches, and replay batches. +- Make startup idempotent after a lost reply, and make Stop/Shutdown success mean native callback registration is removed and its worker has joined. +- Support Linux X11 and Windows native listeners with local injectable lifecycle fixtures. +- Keep macOS input on the host-owned TinyComputer Globe listener and accept only bounded sequenced activation facts over the generic TinyVoice bus vocabulary. +- Keep `tinyvoice-bus` transport-free and free of device, runtime, native-key, and parsing behavior. + +## Non-goals + +- Adding native input behavior to the host or directly linking TinyComputer accessibility into TinyVoice. +- Global Wayland hotkeys; Linux X11 support does not imply Wayland support. +- Changing the existing pure-library `ActivationMode` snake_case representation or removing the compatibility `tinyvoice::hotkey` API. +- Registering permissions or deciding whether hotkey use is allowed; the host obtains platform permission through TinyComputer. +- Polling for each key sample or forwarding key names/native key codes over TinyBus. + +## Proposed bus surface + +Contract 1.4 adds six members: + +| Member | Input | Result | +| --- | --- | --- | +| `HotkeyReserve` | bounded `HotkeyRequest` with key text, activation mode, and source (`native` or `host`) | opaque `HotkeyHandle` | +| `HotkeyStart` | reserved handle | status plus a feed generation when host-fed | +| `HotkeyRead` | handle plus optional acknowledged batch | bounded replayable ordered Pressed/Released batch and status | +| `HotkeyFeed` | handle, generation, sequence, bounded Down/Up facts, and optional upstream-loss flag | feed acknowledgment/status | +| `HotkeyStop` | handle | status only after release and join | +| `HotkeyShutdown` | no arguments | terminal cleanup status only after every owned worker/resource is released | + +New enums use explicit snake_case serde names. The activation mode remains `tap`/`push`. Host feed is generic fact vocabulary, not a Computer handle or Computer DTO. Start returns a module-owned generation; stale generations reject. Sequence duplicates replay their prior acknowledgment, gaps mark continuity lost, and a reported upstream overflow resets activation inactive. Consumers must observe a fresh physical release before rearming after loss. + +`HotkeyRead` keeps the oldest-first batch until its exact batch number is acknowledged. A lost response is replayed unchanged; future acknowledgments fail without mutation. Queue overflow does not silently drop a release while preserving active state: the activation reducer resets inactive and the batch reports loss. Requests, strings, event batches, retained batches, reservations, and active listeners all have fixed documented limits. Released handles do not consume a lifetime admission counter. + +The module admits at most 16 live reservations, expires an unused reservation +after 60 seconds, and accepts key strings up to 128 UTF-8 bytes and opaque +handles up to 32 bytes. Each host feed and replay batch contains at most 64 +facts; the native and retained event queues hold at most 256 events. Reaching +an event-queue bound reports continuity loss and resets activation inactive. + +`HotkeyStop` is retryable after cleanup failure: the module retains the handle and native owner until unregistration and join complete. `HotkeyShutdown` closes admission first, cancels startup and waits for all listeners. The host calls it before generic ABI unload. Sign-out and replacement await per-handle Stop before starting a successor. + +## Platform behavior + +- **Linux/X11:** use the X RECORD extension over two owned x11rb connections. The data connection's duplicated socket descriptor is retained as a safe cancellation handle; shutdown wakes a blocked record iterator. The worker owns its X11 state and joins before Stop succeeds. Keycodes map to the same physical key vocabulary as the existing listener. Press/release, repeat, and modifier ordering are pinned with local fixtures. Wayland remains explicitly unsupported. +- **Windows:** one owner thread creates its message queue, installs `WH_KEYBOARD_LL`, and pumps messages. Its callback only maps key transitions into a bounded nonblocking queue. Stop runs on the owner thread, unregisters the hook, clears the callback target, and joins. If the safe Windows bindings cannot express owned unregister, unsafe is limited to a documented TinyVoice module FFI boundary; workspace and pure library unsafe policies remain forbidden. +- **macOS:** the module does not start rdev or another accessibility hook. The host owns TinyComputer Globe permission and lease, reads its reliable batches, and forwards sequenced FN Down/Up facts. Host sign-out awaits both TinyVoice Stop and Computer Stop. +- **Other targets:** return an explicit unsupported result without creating native resources. + +## Invariants + +- No native thread retaining module code survives a successful terminal shutdown. +- No callback performs parsing, blocking sends, bus calls, audio work, or logging of key payloads. +- Start reply loss is recoverable using the caller-known reservation; late workers cannot publish to retired handles. +- Stop and shutdown never report success before actual hook unregistration/socket teardown and worker join. +- A failed cleanup keeps ownership reachable for retry. +- Every continuity break resets activation state before new facts are interpreted. +- Only typed Pressed/Released facts and opaque handles cross the bus; native keys, connection state, parsers, and key-state algorithms stay in TinyVoice. + +## Acceptance criteria + +- Deterministic local tests cover aliases, tap/push transitions, left/right modifiers, repeat events, modifier release, ordering, bounded input/output, replay, acknowledgment, startup reply loss, cancellation, stale generations, sequence gaps, overflow reset, cleanup retry, sign-out, and reconnect. +- A Linux Xvfb prototype and local protocol fixture prove a blocked X RECORD reader wakes and joins when its duplicated socket is shut down. +- Native owner fixtures prove unregister-before-join and no successful acknowledgment after cleanup failure. Tests require no physical keyboard, display permission, backend, or network service. +- Default/all-feature bus vocabulary remains free of native/runtime dependencies; both module and library contract gates pass. +- The actual compiled artifact verifies all declared hotkey members and exercises denied, stale, replay, stop, and terminal paths. +- Hosts do not switch callers or pin the module until the source is accepted and a compatible artifact is released. + +## Open questions + +None blocking. The exact safe Windows API surface and Linux wire parsing are implementation details constrained by the invariants above; if a local native prototype disproves an assumption, revise the implementation plan and raise the smallest affected contract decision before proceeding. From 2f8e1503664a14b55ff855bc9470bff478fa1af2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:28:33 +0300 Subject: [PATCH 07/25] Preserve hotkey batch ack across reset Co-authored-by: Medulla --- .../src/service/hotkey/mod.rs | 1 - .../src/service/hotkey/mod_tests.rs | 27 +++++++++++++++++++ docs/specs/hotkey-module-lifecycle.md | 4 +-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/tinyvoice-module/src/service/hotkey/mod.rs b/crates/tinyvoice-module/src/service/hotkey/mod.rs index 3a8c148..4ad2f28 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod.rs @@ -252,7 +252,6 @@ fn reset(lease: &mut Lease) { lease.continuity_lost = true; lease.reset_pending = true; lease.events.clear(); - lease.pending_batch = None; } fn status(lease: &Lease) -> HotkeyStatus { diff --git a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs index b0fe1cd..ab96218 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs @@ -125,6 +125,33 @@ fn sequence_gap_resets_push_state_until_a_fresh_release() { assert!(feed(&hotkeys, &handle, generation, &[(6, HostKeyFact::Down)]).active); } +#[test] +fn acknowledging_prior_batch_after_host_gap_returns_reset_snapshot() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]); + let prior = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + assert!(prior.active); + assert_eq!(prior.events.len(), 1); + + assert_eq!( + hotkeys.feed(&HotkeyFeedRequest { + handle: handle.clone(), + generation, + facts: vec![SequencedHostFact { sequence: 3, fact: HostKeyFact::Down }], + overflow: false, + }), + Err(HotkeyError::SequenceGap) + ); + + let reset = hotkeys.read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: Some(prior.batch), + }).expect("the outstanding batch acknowledgment remains valid across a gap"); + assert!(reset.reset); + assert!(!reset.active); + assert!(reset.events.is_empty()); +} + #[test] fn upstream_overflow_resets_active_listener_and_requires_release() { let (hotkeys, handle, generation) = started(ActivationMode::Push); diff --git a/docs/specs/hotkey-module-lifecycle.md b/docs/specs/hotkey-module-lifecycle.md index 70a9d67..99842d5 100644 --- a/docs/specs/hotkey-module-lifecycle.md +++ b/docs/specs/hotkey-module-lifecycle.md @@ -40,7 +40,7 @@ Contract 1.4 adds six members: New enums use explicit snake_case serde names. The activation mode remains `tap`/`push`. Host feed is generic fact vocabulary, not a Computer handle or Computer DTO. Start returns a module-owned generation; stale generations reject. Sequence duplicates replay their prior acknowledgment, gaps mark continuity lost, and a reported upstream overflow resets activation inactive. Consumers must observe a fresh physical release before rearming after loss. -`HotkeyRead` keeps the oldest-first batch until its exact batch number is acknowledged. A lost response is replayed unchanged; future acknowledgments fail without mutation. Queue overflow does not silently drop a release while preserving active state: the activation reducer resets inactive and the batch reports loss. Requests, strings, event batches, retained batches, reservations, and active listeners all have fixed documented limits. Released handles do not consume a lifetime admission counter. +`HotkeyRead` keeps the oldest-first batch until its exact batch number is acknowledged. A lost response is replayed unchanged; future acknowledgments fail without mutation. A continuity reset does not invalidate an already-returned batch: its exact acknowledgment is accepted before the next read returns the reset snapshot. Queue overflow does not silently drop a release while preserving active state: the activation reducer resets inactive and the batch reports loss. Requests, strings, event batches, retained batches, reservations, and active listeners all have fixed documented limits. Released handles do not consume a lifetime admission counter. The module admits at most 16 live reservations, expires an unused reservation after 60 seconds, and accepts key strings up to 128 UTF-8 bytes and opaque @@ -53,7 +53,7 @@ an event-queue bound reports continuity loss and resets activation inactive. ## Platform behavior - **Linux/X11:** use the X RECORD extension over two owned x11rb connections. The data connection's duplicated socket descriptor is retained as a safe cancellation handle; shutdown wakes a blocked record iterator. The worker owns its X11 state and joins before Stop succeeds. Keycodes map to the same physical key vocabulary as the existing listener. Press/release, repeat, and modifier ordering are pinned with local fixtures. Wayland remains explicitly unsupported. -- **Windows:** one owner thread creates its message queue, installs `WH_KEYBOARD_LL`, and pumps messages. Its callback only maps key transitions into a bounded nonblocking queue. Stop runs on the owner thread, unregisters the hook, clears the callback target, and joins. If the safe Windows bindings cannot express owned unregister, unsafe is limited to a documented TinyVoice module FFI boundary; workspace and pure library unsafe policies remain forbidden. +- **Windows:** one owner thread creates its message queue, installs `WH_KEYBOARD_LL`, and pumps messages. Its callback only maps key transitions into a bounded nonblocking queue. Unexpected message-loop termination marks continuity lost; the owner stays reachable after failed unregistration so cleanup can be retried. Stop unregisters the hook, clears the callback target, and joins before success. Unsafe stays limited to the documented TinyVoice module FFI boundary; workspace and pure library unsafe policies remain forbidden. - **macOS:** the module does not start rdev or another accessibility hook. The host owns TinyComputer Globe permission and lease, reads its reliable batches, and forwards sequenced FN Down/Up facts. Host sign-out awaits both TinyVoice Stop and Computer Stop. - **Other targets:** return an explicit unsupported result without creating native resources. From 78c43a56dd5cfb740298955c4888659b3676b219 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:28:33 +0300 Subject: [PATCH 08/25] Share hotkey vocabulary with the bus Co-authored-by: Medulla --- crates/tinyvoice-bus/src/hotkey.rs | 3 ++- crates/tinyvoice-bus/src/hotkey_tests.rs | 5 +++++ crates/tinyvoice/src/hotkey/mod.rs | 21 +-------------------- crates/tinyvoice/src/hotkey/mod_tests.rs | 4 ++++ 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/crates/tinyvoice-bus/src/hotkey.rs b/crates/tinyvoice-bus/src/hotkey.rs index d9e7288..884d3a5 100644 --- a/crates/tinyvoice-bus/src/hotkey.rs +++ b/crates/tinyvoice-bus/src/hotkey.rs @@ -7,12 +7,13 @@ use serde::{Deserialize, Serialize}; pub struct HotkeyHandle(pub String); /// Existing tap-toggle or push-to-talk behavior. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ActivationMode { /// A fresh down toggles activation. Tap, /// A down activates until its matching up. + #[default] Push, } diff --git a/crates/tinyvoice-bus/src/hotkey_tests.rs b/crates/tinyvoice-bus/src/hotkey_tests.rs index 31dd24b..e417aa2 100644 --- a/crates/tinyvoice-bus/src/hotkey_tests.rs +++ b/crates/tinyvoice-bus/src/hotkey_tests.rs @@ -8,6 +8,11 @@ fn activation_mode_wire_names_remain_compatible() { assert_eq!(push, "\"push\""); } +#[test] +fn activation_mode_defaults_to_push() { + assert_eq!(ActivationMode::default(), ActivationMode::Push); +} + #[test] fn host_facts_have_a_generic_stable_wire_shape() { let fact = SequencedHostFact { diff --git a/crates/tinyvoice/src/hotkey/mod.rs b/crates/tinyvoice/src/hotkey/mod.rs index b4b71b5..38f2a16 100644 --- a/crates/tinyvoice/src/hotkey/mod.rs +++ b/crates/tinyvoice/src/hotkey/mod.rs @@ -22,31 +22,12 @@ use log::{debug, info, warn}; use parking_lot::Mutex; pub use rdev::Key; use rdev::{Event, EventType, listen}; +pub use tinyvoice_bus::hotkey::{ActivationMode, HotkeyEvent}; use tokio::sync::mpsc; const LOG_PREFIX: &str = "[voice_hotkey]"; static LISTENER_STARTED: OnceLock = OnceLock::new(); -/// Activation mode for the voice hotkey. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ActivationMode { - /// Single press toggles recording on/off. - Tap, - /// Hold to record, release to stop. - #[default] - Push, -} - -/// Events emitted by the hotkey listener. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HotkeyEvent { - /// The hotkey was pressed (start recording). - Pressed, - /// The hotkey was released (stop recording — only relevant in Push mode). - Released, -} - /// Parsed hotkey combination (e.g. Ctrl+Shift+Space). #[derive(Debug, Clone)] pub struct HotkeyCombination { diff --git a/crates/tinyvoice/src/hotkey/mod_tests.rs b/crates/tinyvoice/src/hotkey/mod_tests.rs index 68ba835..163fa3a 100644 --- a/crates/tinyvoice/src/hotkey/mod_tests.rs +++ b/crates/tinyvoice/src/hotkey/mod_tests.rs @@ -70,6 +70,10 @@ fn parse_unknown_key_errors() { #[test] fn activation_mode_default_is_push() { assert_eq!(ActivationMode::default(), ActivationMode::Push); + let shared_mode: tinyvoice_bus::ActivationMode = ActivationMode::Tap; + assert_eq!(shared_mode, tinyvoice_bus::ActivationMode::Tap); + let shared_event: tinyvoice_bus::HotkeyEvent = HotkeyEvent::Pressed; + assert_eq!(shared_event, tinyvoice_bus::HotkeyEvent::Pressed); } #[test] From c22a867fe7f7c2d24661fae284125c771277cd20 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:28:33 +0300 Subject: [PATCH 09/25] Recover ended Windows hotkey owners Co-authored-by: Medulla --- crates/tinyvoice-hotkey-win/src/lib.rs | 57 +++++---- crates/tinyvoice-hotkey-win/src/lifecycle.rs | 47 ++++++++ .../src/lifecycle_tests.rs | 110 ++++++++++++++++++ 3 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 crates/tinyvoice-hotkey-win/src/lifecycle.rs create mode 100644 crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs diff --git a/crates/tinyvoice-hotkey-win/src/lib.rs b/crates/tinyvoice-hotkey-win/src/lib.rs index bc8e773..679db73 100644 --- a/crates/tinyvoice-hotkey-win/src/lib.rs +++ b/crates/tinyvoice-hotkey-win/src/lib.rs @@ -21,6 +21,8 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ WM_APP, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN, WM_SYSKEYUP, }; +mod lifecycle; + const STOP_MESSAGE: u32 = WM_APP + 0x41; const STOP_REPLY_TIMEOUT: Duration = Duration::from_secs(1); @@ -57,17 +59,11 @@ impl Listener { /// Unhooks on the owner thread and joins it before returning success. pub fn stop(&mut self) -> Result<(), HookError> { - if self.worker.is_none() { return Ok(()); } - let posted = unsafe { PostThreadMessageW(self.thread_id, STOP_MESSAGE, 0, 0) }; - if posted == 0 { return Err(HookError); } - match self.replies.recv_timeout(STOP_REPLY_TIMEOUT) { - Ok(Ok(())) => { - if let Some(worker) = self.worker.take() { worker.join().map_err(|_| HookError)??; } - Ok(()) - } - Ok(Err(error)) => Err(error), - Err(_) => Err(HookError), - } + lifecycle::stop_worker( + &mut self.worker, + || unsafe { PostThreadMessageW(self.thread_id, STOP_MESSAGE, 0, 0) != 0 }, + || self.replies.recv_timeout(STOP_REPLY_TIMEOUT).map_err(|_| ()), + ) } } @@ -128,28 +124,45 @@ fn owner_loop( let module = unsafe { GetModuleHandleW(std::ptr::null()) }; let hook = unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_callback), module, 0) }; if hook.is_null() { let _ = ready.send(Err(HookError)); return Err(HookError); } - CALLBACK.with(|target| *target.borrow_mut() = Some(CallbackState { chord, pressed: [false; 256], sender, overflow })); + CALLBACK.with(|target| *target.borrow_mut() = Some(CallbackState { chord, pressed: [false; 256], sender, overflow: overflow.clone() })); if ready.send(Ok(())).is_err() { let result = unsafe { UnhookWindowsHookEx(hook) }; CALLBACK.with(|target| *target.borrow_mut() = None); return if result != 0 { Ok(()) } else { Err(HookError) }; } loop { - let result = unsafe { GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) }; - if result <= 0 { break; } + let message_result = unsafe { GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) }; + if message_result <= 0 { + lifecycle::mark_unexpected_exit(message_result, &overflow); + let unhooked = unsafe { UnhookWindowsHookEx(hook) }; + if unhooked != 0 { + CALLBACK.with(|target| *target.borrow_mut() = None); + let _ = replies.try_send(Ok(())); + return Ok(()); + } + let _ = replies.try_send(Err(HookError)); + if message_result < 0 { thread::sleep(Duration::from_millis(50)); } + continue; + } if message.message == STOP_MESSAGE { - let result = unsafe { UnhookWindowsHookEx(hook) }; - if result != 0 { + let unhooked = unsafe { UnhookWindowsHookEx(hook) }; + if unhooked != 0 { CALLBACK.with(|target| *target.borrow_mut() = None); - let _ = replies.send(Ok(())); + let _ = replies.try_send(Ok(())); return Ok(()); } - let _ = replies.send(Err(HookError)); - } else if message.message == WM_QUIT { break; } + let _ = replies.try_send(Err(HookError)); + } else if message.message == WM_QUIT { + lifecycle::mark_unexpected_exit(0, &overflow); + let unhooked = unsafe { UnhookWindowsHookEx(hook) }; + if unhooked != 0 { + CALLBACK.with(|target| *target.borrow_mut() = None); + let _ = replies.try_send(Ok(())); + return Ok(()); + } + let _ = replies.try_send(Err(HookError)); + } } - let result = unsafe { UnhookWindowsHookEx(hook) }; - CALLBACK.with(|target| *target.borrow_mut() = None); - if result != 0 { Ok(()) } else { Err(HookError) } } unsafe extern "system" fn keyboard_callback(code: i32, message: WPARAM, data: LPARAM) -> LRESULT { diff --git a/crates/tinyvoice-hotkey-win/src/lifecycle.rs b/crates/tinyvoice-hotkey-win/src/lifecycle.rs new file mode 100644 index 0000000..802160f --- /dev/null +++ b/crates/tinyvoice-hotkey-win/src/lifecycle.rs @@ -0,0 +1,47 @@ +//! Testable worker completion and continuity handling for the native hook. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; + +use crate::HookError; + +pub(crate) fn mark_unexpected_exit(message_result: i32, overflow: &AtomicBool) { + if message_result <= 0 { + overflow.store(true, Ordering::SeqCst); + } +} + +fn join_finished( + worker: &mut Option>>, +) -> Option> { + if !worker.as_ref().is_some_and(JoinHandle::is_finished) { + return None; + } + let worker = worker.take()?; + Some(worker.join().map_err(|_| HookError).and_then(|result| result)) +} + +pub(crate) fn stop_worker( + worker: &mut Option>>, + mut post_stop: impl FnMut() -> bool, + mut receive_reply: impl FnMut() -> Result, ()>, +) -> Result<(), HookError> { + if worker.is_none() { return Ok(()); } + if let Some(result) = join_finished(worker) { + return result; + } + if !post_stop() { + return join_finished(worker).unwrap_or(Err(HookError)); + } + match receive_reply() { + Ok(Ok(())) => { + let Some(worker) = worker.take() else { return Ok(()); }; + worker.join().map_err(|_| HookError)? + } + Ok(Err(error)) => Err(error), + Err(()) => join_finished(worker).unwrap_or(Err(HookError)), + } +} + +#[cfg(test)] +#[path = "lifecycle_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs b/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs new file mode 100644 index 0000000..d47ed49 --- /dev/null +++ b/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs @@ -0,0 +1,110 @@ +//! Regression tests for owner exit and retryable Windows hook cleanup. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc; +use std::time::Duration; + +#[test] +fn unexpected_message_loop_exit_marks_continuity_lost() { + let overflow = AtomicBool::new(false); + mark_unexpected_exit(1, &overflow); + assert!(!overflow.load(Ordering::SeqCst)); + mark_unexpected_exit(0, &overflow); + assert!(overflow.load(Ordering::SeqCst)); +} + +#[test] +fn stop_joins_an_owner_that_already_finished_after_unhook() { + let mut worker = Some(std::thread::spawn(|| Ok(()))); + while !worker.as_ref().unwrap().is_finished() { + std::thread::yield_now(); + } + + let result = stop_worker(&mut worker, || false, || panic!("finished owner has no reply")); + + assert_eq!(result, Ok(())); + assert!(worker.is_none()); +} + +#[test] +fn failed_unhook_reply_retains_worker_until_retry_completes_cleanup() { + let (release_tx, release_rx) = mpsc::sync_channel::<()>(1); + let mut worker = Some(std::thread::spawn(move || { + release_rx.recv().expect("fixture keeps the owner alive after unhook failure"); + Ok(()) + })); + + assert_eq!( + stop_worker(&mut worker, || true, || Ok(Err(HookError))), + Err(HookError) + ); + assert!(worker.is_some(), "failed unhook cannot consume owner ownership"); + + release_tx.send(()).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while !worker.as_ref().unwrap().is_finished() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert_eq!(stop_worker(&mut worker, || false, || panic!("finished owner has no reply")), Ok(())); + assert!(worker.is_none()); +} + +#[test] +fn failed_stop_post_retains_live_owner() { + let (release_tx, release_rx) = mpsc::sync_channel::<()>(1); + let mut worker = Some(std::thread::spawn(move || { + release_rx.recv().expect("fixture keeps the owner alive after failed post"); + Ok(()) + })); + + assert_eq!(stop_worker(&mut worker, || false, || panic!("failed post has no reply")), Err(HookError)); + assert!(worker.is_some()); + + release_tx.send(()).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while !worker.as_ref().unwrap().is_finished() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert_eq!(stop_worker(&mut worker, || false, || panic!("finished owner has no reply")), Ok(())); +} + +#[test] +fn successful_unhook_reply_joins_owner_before_acknowledging_stop() { + let (release_tx, release_rx) = mpsc::sync_channel::<()>(1); + let mut worker = Some(std::thread::spawn(move || { + release_rx.recv().expect("fixture waits for the successful unhook reply"); + Ok(()) + })); + + let result = stop_worker( + &mut worker, + || true, + || { + release_tx.send(()).unwrap(); + Ok(Ok(())) + }, + ); + assert_eq!(result, Ok(())); + assert!(worker.is_none()); +} + +#[test] +fn reply_timeout_retains_live_owner_until_later_join() { + let (release_tx, release_rx) = mpsc::sync_channel::<()>(1); + let mut worker = Some(std::thread::spawn(move || { + release_rx.recv().expect("fixture keeps the owner alive after reply timeout"); + Ok(()) + })); + + assert_eq!(stop_worker(&mut worker, || true, || Err(())), Err(HookError)); + assert!(worker.is_some()); + + release_tx.send(()).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while !worker.as_ref().unwrap().is_finished() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert_eq!(stop_worker(&mut worker, || false, || panic!("finished owner has no reply")), Ok(())); +} From b007cc68fdc925784fafeb927847861182ea8074 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:38:50 +0300 Subject: [PATCH 10/25] docs(tinyvoice): document module-owned capture and hotkey lifecycles Co-authored-by: Medulla --- crates/tinyvoice-module/src/lib.rs | 38 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/tinyvoice-module/src/lib.rs b/crates/tinyvoice-module/src/lib.rs index 9f9dc44..ec71c98 100644 --- a/crates/tinyvoice-module/src/lib.rs +++ b/crates/tinyvoice-module/src/lib.rs @@ -4,7 +4,7 @@ //! the independently publishable `tinyvoice` crate. Its `cdylib` output is the //! target-specific binary distributed in GitHub releases. //! -//! # A call costs about 13 microseconds +//! # Measured call cost //! //! Measured in-process with `examples/bench_call.rs`, on the real loaded //! module: **13.3 µs per round trip**, against a 20 ms audio frame — 0.066% of @@ -15,29 +15,29 @@ //! crates/tinyvoice-module/target/release/libtinyvoice_module.so //! ``` //! -//! This is worth stating plainly because an earlier revision of these docs -//! asserted the opposite — that a per-frame call was too expensive and a -//! realtime host should link the `tinyvoice` rlib instead. That claim was never -//! measured, and it is wrong. A `TinyBus` module shares the host's address -//! space; a call is a channel send and a JSON hop, not IPC. +//! This benchmark measures one in-process method round trip on one machine. It +//! is an example measurement, not a reason to call the module for every audio +//! sample or frame. //! -//! So a live capture loop **can** drive the VAD through this interface, and the -//! session methods exist for exactly that. +//! # Capture and hotkey ownership //! -//! # What still belongs on the host's side +//! The module owns bounded capture and recording leases, device callbacks and +//! their worker handoff, plus native hotkey listener lifetimes. Hosts reserve, +//! start, poll or read, and stop these leases through the bus. Audio callbacks +//! keep to the realtime-safe capture handoff; host code does not forward raw +//! samples through a per-sample bus loop. Hotkey reads return bounded batches +//! with sequence and continuity facts so a host can recover from a gap. //! -//! One thing, and it is about thread discipline rather than cost: whatever runs -//! inside the audio callback. `cpal` delivers on a realtime thread where the -//! correct amount of work is as little as possible and blocking is a dropout. -//! A host should forward raw interleaved samples from the callback to its own -//! worker and call `PrepareFrames` from there — which is less -//! work in the callback than downmixing in place, not more. +//! The host owns product policy and composition. In particular, macOS hotkey +//! feeds can be composed from approved Computer accessibility facts by the +//! host; TinyVoice does not link Computer or own accessibility algorithms. //! -//! # Session state +//! # Bounded session state //! -//! The VAD is the only thing this module remembers between calls, because it is -//! the only thing that is a state machine over successive frames. See -//! [`VoiceService`] for why it is bounded and why ids are never reused. +//! `VoiceService` retains capture, recording, VAD and hotkey owners between bus +//! calls. Their leases, payloads, queues and replay windows are bounded, and +//! identifiers are not reused within a module instance. Shutdown stops new +//! work and joins native owners before the module is unloaded. mod service; From ba3cee8c8b3d380cb2c265cb23d16101735e506e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:54:57 +0300 Subject: [PATCH 11/25] test(tinyvoice): validate formatted hotkey lifecycle coverage Co-authored-by: Medulla --- crates/tinyvoice-hotkey-win/src/lib.rs | 116 +++- crates/tinyvoice-hotkey-win/src/lifecycle.rs | 15 +- .../src/lifecycle_tests.rs | 64 ++- .../examples/verify_module.rs | 133 ++++- crates/tinyvoice-module/src/lib.rs | 2 +- .../src/service/hotkey/mod.rs | 215 ++++++-- .../src/service/hotkey/mod_tests.rs | 494 +++++++++++++++--- .../src/service/hotkey/native/linux.rs | 345 +++++++++--- .../src/service/hotkey/native/linux_tests.rs | 190 ++++++- .../src/service/hotkey/native/mod.rs | 47 +- .../src/service/hotkey/native/windows.rs | 187 +++++-- .../service/hotkey/native/windows_tests.rs | 27 + crates/tinyvoice-module/src/service/mod.rs | 38 +- .../tinyvoice-module/src/service/mod_tests.rs | 91 +++- 14 files changed, 1610 insertions(+), 354 deletions(-) create mode 100644 crates/tinyvoice-module/src/service/hotkey/native/windows_tests.rs diff --git a/crates/tinyvoice-hotkey-win/src/lib.rs b/crates/tinyvoice-hotkey-win/src/lib.rs index 679db73..fe46003 100644 --- a/crates/tinyvoice-hotkey-win/src/lib.rs +++ b/crates/tinyvoice-hotkey-win/src/lib.rs @@ -9,16 +9,20 @@ #![allow(unsafe_code)] use std::cell::RefCell; -use std::sync::{Arc, atomic::{AtomicBool, Ordering}, mpsc}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, +}; use std::thread::{self, JoinHandle}; use std::time::Duration; use windows_sys::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; use windows_sys::Win32::System::Threading::GetCurrentThreadId; use windows_sys::Win32::UI::WindowsAndMessaging::{ - CallNextHookEx, GetMessageW, PeekMessageW, PostThreadMessageW, SetWindowsHookExW, - UnhookWindowsHookEx, HC_ACTION, KBDLLHOOKSTRUCT, MSG, PM_NOREMOVE, WH_KEYBOARD_LL, - WM_APP, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN, WM_SYSKEYUP, + CallNextHookEx, GetMessageW, HC_ACTION, KBDLLHOOKSTRUCT, MSG, PM_NOREMOVE, PeekMessageW, + PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, WH_KEYBOARD_LL, WM_APP, WM_KEYDOWN, + WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN, WM_SYSKEYUP, }; mod lifecycle; @@ -53,16 +57,24 @@ pub struct Listener { impl Listener { /// Receives the bounded activation facts observed by the hook callback. - pub fn take_events(&mut self) -> Option> { self.events.take() } + pub fn take_events(&mut self) -> Option> { + self.events.take() + } /// Shared overflow bit, set when bounded callback handoff fills. - pub fn overflow(&self) -> Arc { self.overflow.clone() } + pub fn overflow(&self) -> Arc { + self.overflow.clone() + } /// Unhooks on the owner thread and joins it before returning success. pub fn stop(&mut self) -> Result<(), HookError> { lifecycle::stop_worker( &mut self.worker, || unsafe { PostThreadMessageW(self.thread_id, STOP_MESSAGE, 0, 0) != 0 }, - || self.replies.recv_timeout(STOP_REPLY_TIMEOUT).map_err(|_| ()), + || { + self.replies + .recv_timeout(STOP_REPLY_TIMEOUT) + .map_err(|_| ()) + }, ) } } @@ -72,7 +84,9 @@ impl Drop for Listener { // If the owner is being destroyed, keep its code mapped until Windows // confirms unregistration; an unbounded wait is safer than detaching a // callback into an unloadable module. - while self.worker.is_some() && self.stop().is_err() { thread::sleep(Duration::from_millis(50)); } + while self.worker.is_some() && self.stop().is_err() { + thread::sleep(Duration::from_millis(50)); + } } } @@ -94,18 +108,40 @@ pub fn start(chord: Chord) -> Result { let overflow = Arc::new(AtomicBool::new(false)); let worker_overflow = overflow.clone(); let (thread_id_tx, thread_id_rx) = mpsc::sync_channel(1); - let worker = thread::Builder::new().name("tinyvoice-hotkey-win".into()).spawn(move || { - owner_loop(chord, events_tx, worker_overflow, ready_tx, reply_tx, thread_id_tx) - }).map_err(|_| HookError)?; + let worker = thread::Builder::new() + .name("tinyvoice-hotkey-win".into()) + .spawn(move || { + owner_loop( + chord, + events_tx, + worker_overflow, + ready_tx, + reply_tx, + thread_id_tx, + ) + }) + .map_err(|_| HookError)?; let thread_id = match thread_id_rx.recv() { Ok(thread_id) => thread_id, - Err(_) => { let _ = worker.join(); return Err(HookError); } + Err(_) => { + let _ = worker.join(); + return Err(HookError); + } }; match ready_rx.recv() { Ok(Ok(())) => {} - _ => { let _ = worker.join(); return Err(HookError); } + _ => { + let _ = worker.join(); + return Err(HookError); + } } - Ok(Listener { events: Some(events), overflow, thread_id, worker: Some(worker), replies }) + Ok(Listener { + events: Some(events), + overflow, + thread_id, + worker: Some(worker), + replies, + }) } fn owner_loop( @@ -119,12 +155,24 @@ fn owner_loop( let thread_id = unsafe { GetCurrentThreadId() }; let mut message = MSG::default(); // Force creation of this thread's message queue before the handle is published. - unsafe { PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_NOREMOVE); } + unsafe { + PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_NOREMOVE); + } let _ = thread_id_sender.send(thread_id); let module = unsafe { GetModuleHandleW(std::ptr::null()) }; let hook = unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_callback), module, 0) }; - if hook.is_null() { let _ = ready.send(Err(HookError)); return Err(HookError); } - CALLBACK.with(|target| *target.borrow_mut() = Some(CallbackState { chord, pressed: [false; 256], sender, overflow: overflow.clone() })); + if hook.is_null() { + let _ = ready.send(Err(HookError)); + return Err(HookError); + } + CALLBACK.with(|target| { + *target.borrow_mut() = Some(CallbackState { + chord, + pressed: [false; 256], + sender, + overflow: overflow.clone(), + }) + }); if ready.send(Ok(())).is_err() { let result = unsafe { UnhookWindowsHookEx(hook) }; CALLBACK.with(|target| *target.borrow_mut() = None); @@ -141,7 +189,9 @@ fn owner_loop( return Ok(()); } let _ = replies.try_send(Err(HookError)); - if message_result < 0 { thread::sleep(Duration::from_millis(50)); } + if message_result < 0 { + thread::sleep(Duration::from_millis(50)); + } continue; } if message.message == STOP_MESSAGE { @@ -177,22 +227,42 @@ unsafe extern "system" fn keyboard_callback(code: i32, message: WPARAM, data: LP if let Some(state) = target.borrow_mut().as_mut() { let key = event.vkCode; let index = key as usize; - if index >= state.pressed.len() { return; } + if index >= state.pressed.len() { + return; + } if down { let fresh = !state.pressed[index]; state.pressed[index] = true; if fresh && key == state.chord.trigger - && state.chord.modifiers.iter().all(|modifier| state.pressed.get(*modifier as usize).copied().unwrap_or(false)) + && state.chord.modifiers.iter().all(|modifier| { + state + .pressed + .get(*modifier as usize) + .copied() + .unwrap_or(false) + }) && state.sender.try_send(true).is_err() { state.overflow.store(true, Ordering::SeqCst); } } else { state.pressed[index] = false; - if key == state.chord.trigger || (state.chord.push && state.chord.modifiers.contains(&key) && state.pressed.get(state.chord.trigger as usize).copied().unwrap_or(false)) { - if state.chord.push && state.chord.modifiers.contains(&key) { state.pressed[state.chord.trigger as usize] = false; } - if state.sender.try_send(false).is_err() { state.overflow.store(true, Ordering::SeqCst); } + if key == state.chord.trigger + || (state.chord.push + && state.chord.modifiers.contains(&key) + && state + .pressed + .get(state.chord.trigger as usize) + .copied() + .unwrap_or(false)) + { + if state.chord.push && state.chord.modifiers.contains(&key) { + state.pressed[state.chord.trigger as usize] = false; + } + if state.sender.try_send(false).is_err() { + state.overflow.store(true, Ordering::SeqCst); + } } } } diff --git a/crates/tinyvoice-hotkey-win/src/lifecycle.rs b/crates/tinyvoice-hotkey-win/src/lifecycle.rs index 802160f..10e3230 100644 --- a/crates/tinyvoice-hotkey-win/src/lifecycle.rs +++ b/crates/tinyvoice-hotkey-win/src/lifecycle.rs @@ -17,7 +17,12 @@ fn join_finished( return None; } let worker = worker.take()?; - Some(worker.join().map_err(|_| HookError).and_then(|result| result)) + Some( + worker + .join() + .map_err(|_| HookError) + .and_then(|result| result), + ) } pub(crate) fn stop_worker( @@ -25,7 +30,9 @@ pub(crate) fn stop_worker( mut post_stop: impl FnMut() -> bool, mut receive_reply: impl FnMut() -> Result, ()>, ) -> Result<(), HookError> { - if worker.is_none() { return Ok(()); } + if worker.is_none() { + return Ok(()); + } if let Some(result) = join_finished(worker) { return result; } @@ -34,7 +41,9 @@ pub(crate) fn stop_worker( } match receive_reply() { Ok(Ok(())) => { - let Some(worker) = worker.take() else { return Ok(()); }; + let Some(worker) = worker.take() else { + return Ok(()); + }; worker.join().map_err(|_| HookError)? } Ok(Err(error)) => Err(error), diff --git a/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs b/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs index d47ed49..812aa8c 100644 --- a/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs +++ b/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs @@ -22,7 +22,11 @@ fn stop_joins_an_owner_that_already_finished_after_unhook() { std::thread::yield_now(); } - let result = stop_worker(&mut worker, || false, || panic!("finished owner has no reply")); + let result = stop_worker( + &mut worker, + || false, + || panic!("finished owner has no reply"), + ); assert_eq!(result, Ok(())); assert!(worker.is_none()); @@ -32,7 +36,9 @@ fn stop_joins_an_owner_that_already_finished_after_unhook() { fn failed_unhook_reply_retains_worker_until_retry_completes_cleanup() { let (release_tx, release_rx) = mpsc::sync_channel::<()>(1); let mut worker = Some(std::thread::spawn(move || { - release_rx.recv().expect("fixture keeps the owner alive after unhook failure"); + release_rx + .recv() + .expect("fixture keeps the owner alive after unhook failure"); Ok(()) })); @@ -40,14 +46,24 @@ fn failed_unhook_reply_retains_worker_until_retry_completes_cleanup() { stop_worker(&mut worker, || true, || Ok(Err(HookError))), Err(HookError) ); - assert!(worker.is_some(), "failed unhook cannot consume owner ownership"); + assert!( + worker.is_some(), + "failed unhook cannot consume owner ownership" + ); release_tx.send(()).unwrap(); let deadline = std::time::Instant::now() + Duration::from_secs(1); while !worker.as_ref().unwrap().is_finished() && std::time::Instant::now() < deadline { std::thread::yield_now(); } - assert_eq!(stop_worker(&mut worker, || false, || panic!("finished owner has no reply")), Ok(())); + assert_eq!( + stop_worker( + &mut worker, + || false, + || panic!("finished owner has no reply") + ), + Ok(()) + ); assert!(worker.is_none()); } @@ -55,11 +71,16 @@ fn failed_unhook_reply_retains_worker_until_retry_completes_cleanup() { fn failed_stop_post_retains_live_owner() { let (release_tx, release_rx) = mpsc::sync_channel::<()>(1); let mut worker = Some(std::thread::spawn(move || { - release_rx.recv().expect("fixture keeps the owner alive after failed post"); + release_rx + .recv() + .expect("fixture keeps the owner alive after failed post"); Ok(()) })); - assert_eq!(stop_worker(&mut worker, || false, || panic!("failed post has no reply")), Err(HookError)); + assert_eq!( + stop_worker(&mut worker, || false, || panic!("failed post has no reply")), + Err(HookError) + ); assert!(worker.is_some()); release_tx.send(()).unwrap(); @@ -67,14 +88,23 @@ fn failed_stop_post_retains_live_owner() { while !worker.as_ref().unwrap().is_finished() && std::time::Instant::now() < deadline { std::thread::yield_now(); } - assert_eq!(stop_worker(&mut worker, || false, || panic!("finished owner has no reply")), Ok(())); + assert_eq!( + stop_worker( + &mut worker, + || false, + || panic!("finished owner has no reply") + ), + Ok(()) + ); } #[test] fn successful_unhook_reply_joins_owner_before_acknowledging_stop() { let (release_tx, release_rx) = mpsc::sync_channel::<()>(1); let mut worker = Some(std::thread::spawn(move || { - release_rx.recv().expect("fixture waits for the successful unhook reply"); + release_rx + .recv() + .expect("fixture waits for the successful unhook reply"); Ok(()) })); @@ -94,11 +124,16 @@ fn successful_unhook_reply_joins_owner_before_acknowledging_stop() { fn reply_timeout_retains_live_owner_until_later_join() { let (release_tx, release_rx) = mpsc::sync_channel::<()>(1); let mut worker = Some(std::thread::spawn(move || { - release_rx.recv().expect("fixture keeps the owner alive after reply timeout"); + release_rx + .recv() + .expect("fixture keeps the owner alive after reply timeout"); Ok(()) })); - assert_eq!(stop_worker(&mut worker, || true, || Err(())), Err(HookError)); + assert_eq!( + stop_worker(&mut worker, || true, || Err(())), + Err(HookError) + ); assert!(worker.is_some()); release_tx.send(()).unwrap(); @@ -106,5 +141,12 @@ fn reply_timeout_retains_live_owner_until_later_join() { while !worker.as_ref().unwrap().is_finished() && std::time::Instant::now() < deadline { std::thread::yield_now(); } - assert_eq!(stop_worker(&mut worker, || false, || panic!("finished owner has no reply")), Ok(())); + assert_eq!( + stop_worker( + &mut worker, + || false, + || panic!("finished owner has no reply") + ), + Ok(()) + ); } diff --git a/crates/tinyvoice-module/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index 509a764..9260d63 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -127,42 +127,80 @@ async fn main() -> Result<(), Box> { async fn verify_hotkeys(proxy: &tinybus::Proxy) -> Result<(), Box> { let hotkey: tinyvoice_bus::HotkeyResult = proxy - .call(tinyvoice_bus::names::methods::HOTKEY_RESERVE, (host_hotkey_request(),)) + .call( + tinyvoice_bus::names::methods::HOTKEY_RESERVE, + (host_hotkey_request(),), + ) .await?; - let hotkey = hotkey.map_err(|error| io::Error::other(format!("hotkey reserve failed: {error:?}")))?; - let start_request = tinyvoice_bus::HotkeyHandleRequest { handle: hotkey.clone() }; + let hotkey = + hotkey.map_err(|error| io::Error::other(format!("hotkey reserve failed: {error:?}")))?; + let start_request = tinyvoice_bus::HotkeyHandleRequest { + handle: hotkey.clone(), + }; let started: tinyvoice_bus::HotkeyResult = proxy - .call(tinyvoice_bus::names::methods::HOTKEY_START, (start_request.clone(),)) + .call( + tinyvoice_bus::names::methods::HOTKEY_START, + (start_request.clone(),), + ) .await?; - let started = started.map_err(|error| io::Error::other(format!("hotkey start failed: {error:?}")))?; + let started = + started.map_err(|error| io::Error::other(format!("hotkey start failed: {error:?}")))?; let retried: tinyvoice_bus::HotkeyResult = proxy - .call(tinyvoice_bus::names::methods::HOTKEY_START, (start_request,)) + .call( + tinyvoice_bus::names::methods::HOTKEY_START, + (start_request,), + ) .await?; - if retried.map_err(|error| io::Error::other(format!("hotkey start retry failed: {error:?}")))? != started { + if retried.map_err(|error| io::Error::other(format!("hotkey start retry failed: {error:?}")))? + != started + { return Err(io::Error::other("hotkey start retry changed its generation or state").into()); } - let generation = started.generation.ok_or_else(|| io::Error::other("hotkey start omitted generation"))?; + let generation = started + .generation + .ok_or_else(|| io::Error::other("hotkey start omitted generation"))?; let stale: tinyvoice_bus::HotkeyResult = proxy - .call(tinyvoice_bus::names::methods::HOTKEY_FEED, (feed_request(hotkey.clone(), generation.saturating_add(1), vec![]),)) + .call( + tinyvoice_bus::names::methods::HOTKEY_FEED, + (feed_request( + hotkey.clone(), + generation.saturating_add(1), + vec![], + ),), + ) .await?; if stale != Err(tinyvoice_bus::HotkeyError::StaleGeneration) { return Err(io::Error::other("hotkey feed accepted a stale generation").into()); } verify_hotkey_replay(proxy, hotkey.clone(), generation).await?; let stopped: tinyvoice_bus::HotkeyResult = proxy - .call(tinyvoice_bus::names::methods::HOTKEY_STOP, (tinyvoice_bus::HotkeyHandleRequest { handle: hotkey },)) + .call( + tinyvoice_bus::names::methods::HOTKEY_STOP, + (tinyvoice_bus::HotkeyHandleRequest { handle: hotkey },), + ) .await?; - if stopped.map_err(|error| io::Error::other(format!("hotkey stop failed: {error:?}")))?.state != tinyvoice_bus::HotkeyState::Stopped { + if stopped + .map_err(|error| io::Error::other(format!("hotkey stop failed: {error:?}")))? + .state + != tinyvoice_bus::HotkeyState::Stopped + { return Err(io::Error::other("hotkey stop did not finish the lease").into()); } let closed: tinyvoice_bus::HotkeyResult = proxy .call(tinyvoice_bus::names::methods::HOTKEY_SHUTDOWN, ()) .await?; - if closed.map_err(|error| io::Error::other(format!("hotkey shutdown failed: {error:?}")))?.state != tinyvoice_bus::HotkeyState::Stopped { + if closed + .map_err(|error| io::Error::other(format!("hotkey shutdown failed: {error:?}")))? + .state + != tinyvoice_bus::HotkeyState::Stopped + { return Err(io::Error::other("hotkey shutdown did not finish").into()); } let after_shutdown: tinyvoice_bus::HotkeyResult = proxy - .call(tinyvoice_bus::names::methods::HOTKEY_RESERVE, (host_hotkey_request(),)) + .call( + tinyvoice_bus::names::methods::HOTKEY_RESERVE, + (host_hotkey_request(),), + ) .await?; if after_shutdown != Err(tinyvoice_bus::HotkeyError::Closed) { return Err(io::Error::other("hotkey shutdown left admission open").into()); @@ -176,33 +214,69 @@ async fn verify_hotkey_replay( generation: u64, ) -> Result<(), Box> { let facts = vec![ - tinyvoice_bus::SequencedHostFact { sequence: 1, fact: tinyvoice_bus::HostKeyFact::Down }, - tinyvoice_bus::SequencedHostFact { sequence: 2, fact: tinyvoice_bus::HostKeyFact::Up }, + tinyvoice_bus::SequencedHostFact { + sequence: 1, + fact: tinyvoice_bus::HostKeyFact::Down, + }, + tinyvoice_bus::SequencedHostFact { + sequence: 2, + fact: tinyvoice_bus::HostKeyFact::Up, + }, ]; let fed: tinyvoice_bus::HotkeyResult = proxy - .call(tinyvoice_bus::names::methods::HOTKEY_FEED, (feed_request(handle.clone(), generation, facts),)) + .call( + tinyvoice_bus::names::methods::HOTKEY_FEED, + (feed_request(handle.clone(), generation, facts),), + ) .await?; fed.map_err(|error| io::Error::other(format!("hotkey feed failed: {error:?}")))?; - let read_request = tinyvoice_bus::HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }; + let read_request = tinyvoice_bus::HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }; let first: tinyvoice_bus::HotkeyResult = proxy - .call(tinyvoice_bus::names::methods::HOTKEY_READ, (read_request.clone(),)) + .call( + tinyvoice_bus::names::methods::HOTKEY_READ, + (read_request.clone(),), + ) .await?; - let first = first.map_err(|error| io::Error::other(format!("hotkey read failed: {error:?}")))?; + let first = + first.map_err(|error| io::Error::other(format!("hotkey read failed: {error:?}")))?; let replay: tinyvoice_bus::HotkeyResult = proxy .call(tinyvoice_bus::names::methods::HOTKEY_READ, (read_request,)) .await?; - if replay.map_err(|error| io::Error::other(format!("hotkey replay failed: {error:?}")))? != first - || first.events.iter().map(|event| event.event).collect::>() - != [tinyvoice_bus::HotkeyEvent::Pressed, tinyvoice_bus::HotkeyEvent::Released] + if replay.map_err(|error| io::Error::other(format!("hotkey replay failed: {error:?}")))? + != first + || first + .events + .iter() + .map(|event| event.event) + .collect::>() + != [ + tinyvoice_bus::HotkeyEvent::Pressed, + tinyvoice_bus::HotkeyEvent::Released, + ] { - return Err(io::Error::other("hotkey read did not replay the ordered activation batch").into()); + return Err( + io::Error::other("hotkey read did not replay the ordered activation batch").into(), + ); } let ack: tinyvoice_bus::HotkeyResult = proxy - .call(tinyvoice_bus::names::methods::HOTKEY_READ, (tinyvoice_bus::HotkeyReadRequest { handle, acknowledged_batch: Some(first.batch) },)) + .call( + tinyvoice_bus::names::methods::HOTKEY_READ, + (tinyvoice_bus::HotkeyReadRequest { + handle, + acknowledged_batch: Some(first.batch), + },), + ) .await?; - let ack = ack.map_err(|error| io::Error::other(format!("hotkey acknowledgment failed: {error:?}")))?; + let ack = + ack.map_err(|error| io::Error::other(format!("hotkey acknowledgment failed: {error:?}")))?; if !ack.events.is_empty() || ack.active { - return Err(io::Error::other("hotkey acknowledgment did not advance to the inactive snapshot").into()); + return Err(io::Error::other( + "hotkey acknowledgment did not advance to the inactive snapshot", + ) + .into()); } Ok(()) } @@ -222,7 +296,12 @@ fn feed_request( generation: u64, facts: Vec, ) -> tinyvoice_bus::HotkeyFeedRequest { - tinyvoice_bus::HotkeyFeedRequest { handle, generation, facts, overflow: false } + tinyvoice_bus::HotkeyFeedRequest { + handle, + generation, + facts, + overflow: false, + } } fn module_argument() -> Result { diff --git a/crates/tinyvoice-module/src/lib.rs b/crates/tinyvoice-module/src/lib.rs index ec71c98..0b572a4 100644 --- a/crates/tinyvoice-module/src/lib.rs +++ b/crates/tinyvoice-module/src/lib.rs @@ -30,7 +30,7 @@ //! //! The host owns product policy and composition. In particular, macOS hotkey //! feeds can be composed from approved Computer accessibility facts by the -//! host; TinyVoice does not link Computer or own accessibility algorithms. +//! host; `TinyVoice` does not link `Computer` or own accessibility algorithms. //! //! # Bounded session state //! diff --git a/crates/tinyvoice-module/src/service/hotkey/mod.rs b/crates/tinyvoice-module/src/service/hotkey/mod.rs index 4ad2f28..6b70605 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod.rs @@ -1,6 +1,9 @@ //! Owned hotkey leases, activation sequencing, and replayable event batches. use std::collections::{HashMap, VecDeque}; -use std::sync::{Mutex, atomic::{AtomicBool, Ordering}}; +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, +}; use tinyvoice_bus::{ ActivationMode, HotkeyBatch, HotkeyError, HotkeyEvent, HotkeyFeedRequest, HotkeyHandle, HotkeyHandleRequest, HotkeyReadRequest, HotkeyReply, HotkeyRequest, HotkeyReserveRequest, @@ -46,22 +49,37 @@ pub(super) struct Hotkeys { } impl Default for Hotkeys { - fn default() -> Self { Self::with_backend(native::backend()) } + fn default() -> Self { + Self::with_backend(native::backend()) + } } impl Hotkeys { fn with_backend(backend: std::sync::Arc) -> Self { - Self { closed: AtomicBool::new(false), next_generation: std::sync::atomic::AtomicU64::new(0), leases: Mutex::new(HashMap::new()), backend } + Self { + closed: AtomicBool::new(false), + next_generation: std::sync::atomic::AtomicU64::new(0), + leases: Mutex::new(HashMap::new()), + backend, + } } pub(super) fn reserve(&self, request: HotkeyReserveRequest) -> HotkeyResult { let request = request.request; - if request.key.is_empty() || request.key.len() > MAX_KEY_BYTES || self.closed.load(Ordering::SeqCst) { - return Err(if self.closed.load(Ordering::SeqCst) { HotkeyError::Closed } else { HotkeyError::InvalidRequest }); + if request.key.is_empty() + || request.key.len() > MAX_KEY_BYTES + || self.closed.load(Ordering::SeqCst) + { + return Err(if self.closed.load(Ordering::SeqCst) { + HotkeyError::Closed + } else { + HotkeyError::InvalidRequest + }); } // Use the existing parser so aliases and key spelling stay one contract. if request.source == HotkeySource::Native { #[cfg(feature = "native-hotkeys")] - tinyvoice::hotkey::parse_hotkey(&request.key).map_err(|_| HotkeyError::InvalidRequest)?; + tinyvoice::hotkey::parse_hotkey(&request.key) + .map_err(|_| HotkeyError::InvalidRequest)?; #[cfg(not(feature = "native-hotkeys"))] return Err(HotkeyError::Unsupported); } else if request.key != "Fn" && request.key != "fn" { @@ -73,36 +91,68 @@ impl Hotkeys { HotkeyState::Stopped => false, _ => true, }); - if leases.len() >= MAX_RESERVATIONS { return Err(HotkeyError::LimitExceeded); } + if leases.len() >= MAX_RESERVATIONS { + return Err(HotkeyError::LimitExceeded); + } let handle = new_handle()?; - leases.insert(handle.clone(), Lease { - request, created: std::time::Instant::now(), state: HotkeyState::Reserved, - generation: None, source_sequence: 0, last_feed: None, active: false, - armed: true, source_down: false, continuity_lost: false, event_sequence: 0, next_batch: 1, - events: VecDeque::new(), pending_batch: None, reset_pending: false, native: None, - }); + leases.insert( + handle.clone(), + Lease { + request, + created: std::time::Instant::now(), + state: HotkeyState::Reserved, + generation: None, + source_sequence: 0, + last_feed: None, + active: false, + armed: true, + source_down: false, + continuity_lost: false, + event_sequence: 0, + next_batch: 1, + events: VecDeque::new(), + pending_batch: None, + reset_pending: false, + native: None, + }, + ); Ok(handle) } pub(super) fn start(&self, request: &HotkeyHandleRequest) -> HotkeyResult { validate_handle(&request.handle)?; let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; - if leases.get(&request.handle).is_some_and(|lease| + if leases.get(&request.handle).is_some_and(|lease| { lease.state == HotkeyState::Reserved && lease.created.elapsed() >= RESERVATION_TTL - ) { + }) { leases.remove(&request.handle); return Err(HotkeyError::UnknownHandle); } - let lease = leases.get_mut(&request.handle).ok_or(HotkeyError::UnknownHandle)?; - if lease.state == HotkeyState::Running { return Ok(status(lease)); } - if lease.state != HotkeyState::Reserved { return Err(HotkeyError::Closed); } - if self.closed.load(Ordering::SeqCst) { return Err(HotkeyError::Closed); } - let generation = self.next_generation.fetch_add(1, Ordering::SeqCst).checked_add(1).ok_or(HotkeyError::LimitExceeded)?; + let lease = leases + .get_mut(&request.handle) + .ok_or(HotkeyError::UnknownHandle)?; + if lease.state == HotkeyState::Running { + return Ok(status(lease)); + } + if lease.state != HotkeyState::Reserved { + return Err(HotkeyError::Closed); + } + if self.closed.load(Ordering::SeqCst) { + return Err(HotkeyError::Closed); + } + let generation = self + .next_generation + .fetch_add(1, Ordering::SeqCst) + .checked_add(1) + .ok_or(HotkeyError::LimitExceeded)?; if lease.request.source == HotkeySource::Native { lease.state = HotkeyState::Starting; match self.backend.start(&lease.request) { Ok(listener) => lease.native = Some(listener), - Err(error) => { lease.state = HotkeyState::Reserved; return Err(error); } + Err(error) => { + lease.state = HotkeyState::Reserved; + return Err(error); + } } } lease.generation = Some(generation); @@ -112,12 +162,22 @@ impl Hotkeys { pub(super) fn feed(&self, request: &HotkeyFeedRequest) -> HotkeyResult { validate_handle(&request.handle)?; - if request.facts.len() > MAX_FEED_FACTS { return Err(HotkeyError::LimitExceeded); } + if request.facts.len() > MAX_FEED_FACTS { + return Err(HotkeyError::LimitExceeded); + } let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; - let lease = leases.get_mut(&request.handle).ok_or(HotkeyError::UnknownHandle)?; - if lease.request.source != HotkeySource::Host { return Err(HotkeyError::InvalidRequest); } - if lease.state != HotkeyState::Running { return Err(HotkeyError::Closed); } - if lease.generation != Some(request.generation) { return Err(HotkeyError::StaleGeneration); } + let lease = leases + .get_mut(&request.handle) + .ok_or(HotkeyError::UnknownHandle)?; + if lease.request.source != HotkeySource::Host { + return Err(HotkeyError::InvalidRequest); + } + if lease.state != HotkeyState::Running { + return Err(HotkeyError::Closed); + } + if lease.generation != Some(request.generation) { + return Err(HotkeyError::StaleGeneration); + } let signature = (request.facts.clone(), request.overflow); if let Some((prior, prior_overflow)) = &lease.last_feed && prior == &request.facts @@ -127,12 +187,17 @@ impl Hotkeys { } if request.overflow { reset(lease); - lease.source_sequence = request.facts.last().map_or(lease.source_sequence, |fact| fact.sequence); + lease.source_sequence = request + .facts + .last() + .map_or(lease.source_sequence, |fact| fact.sequence); lease.last_feed = Some(signature); return Ok(status(lease)); } for fact in &request.facts { - if fact.sequence <= lease.source_sequence { return Err(HotkeyError::SequenceGap); } + if fact.sequence <= lease.source_sequence { + return Err(HotkeyError::SequenceGap); + } if fact.sequence != lease.source_sequence.saturating_add(1) { reset(lease); lease.source_sequence = fact.sequence; @@ -149,18 +214,26 @@ impl Hotkeys { pub(super) fn read(&self, request: &HotkeyReadRequest) -> HotkeyResult { validate_handle(&request.handle)?; let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; - let lease = leases.get_mut(&request.handle).ok_or(HotkeyError::UnknownHandle)?; - if lease.state != HotkeyState::Running { return Err(HotkeyError::Closed); } - let overflow = lease.native.as_ref().is_some_and(|listener| listener.overflow.swap(false, Ordering::SeqCst)); + let lease = leases + .get_mut(&request.handle) + .ok_or(HotkeyError::UnknownHandle)?; + if lease.state != HotkeyState::Running { + return Err(HotkeyError::Closed); + } + let overflow = lease + .native + .as_ref() + .is_some_and(|listener| listener.overflow.swap(false, Ordering::SeqCst)); if overflow { if let Some(listener) = lease.native.as_ref() { while listener.events.try_recv().is_ok() {} } reset(lease); - } - else if let Some(listener) = lease.native.as_mut() { + } else if let Some(listener) = lease.native.as_mut() { let facts: Vec<_> = std::iter::from_fn(|| listener.events.try_recv().ok()).collect(); - for down in facts { apply_fact(lease, down); } + for down in facts { + apply_fact(lease, down); + } } if let Some(batch) = &lease.pending_batch { match request.acknowledged_batch { @@ -174,7 +247,11 @@ impl Hotkeys { let generation = lease.generation.ok_or(HotkeyError::Closed)?; let events: Vec<_> = lease.events.drain(..).collect(); let batch = HotkeyBatch { - generation, batch: lease.next_batch, events, reset: std::mem::take(&mut lease.reset_pending), active: lease.active, + generation, + batch: lease.next_batch, + events, + reset: std::mem::take(&mut lease.reset_pending), + active: lease.active, }; lease.next_batch = lease.next_batch.saturating_add(1); lease.pending_batch = Some(batch.clone()); @@ -184,9 +261,16 @@ impl Hotkeys { pub(super) fn stop(&self, request: &HotkeyHandleRequest) -> HotkeyResult { validate_handle(&request.handle)?; let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; - let lease = leases.get_mut(&request.handle).ok_or(HotkeyError::UnknownHandle)?; - if lease.state == HotkeyState::Running { reset(lease); lease.state = HotkeyState::Stopping; } - if lease.state == HotkeyState::Stopped { return Ok(status(lease)); } + let lease = leases + .get_mut(&request.handle) + .ok_or(HotkeyError::UnknownHandle)?; + if lease.state == HotkeyState::Running { + reset(lease); + lease.state = HotkeyState::Stopping; + } + if lease.state == HotkeyState::Stopped { + return Ok(status(lease)); + } if let Some(listener) = lease.native.as_mut() { listener.stop()?; lease.native = None; @@ -201,7 +285,10 @@ impl Hotkeys { self.closed.store(true, Ordering::SeqCst); let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; for lease in leases.values_mut() { - if lease.state == HotkeyState::Running { reset(lease); lease.state = HotkeyState::Stopping; } + if lease.state == HotkeyState::Running { + reset(lease); + lease.state = HotkeyState::Stopping; + } if let Some(listener) = lease.native.as_mut() { listener.stop()?; lease.native = None; @@ -209,29 +296,52 @@ impl Hotkeys { lease.state = HotkeyState::Stopped; } leases.clear(); - Ok(HotkeyStatus { state: HotkeyState::Stopped, generation: None, active: false, continuity_lost: false }) + Ok(HotkeyStatus { + state: HotkeyState::Stopped, + generation: None, + active: false, + continuity_lost: false, + }) } } fn apply_fact(lease: &mut Lease, down: bool) { if !down { lease.source_down = false; - if !lease.armed { lease.armed = true; return; } - if lease.request.mode == ActivationMode::Push && lease.active { emit(lease, HotkeyEvent::Released); } + if !lease.armed { + lease.armed = true; + return; + } + if lease.request.mode == ActivationMode::Push && lease.active { + emit(lease, HotkeyEvent::Released); + } + return; + } + if lease.source_down { return; } - if lease.source_down { return; } lease.source_down = true; - if !lease.armed { return; } + if !lease.armed { + return; + } match lease.request.mode { - ActivationMode::Tap => emit(lease, if lease.active { HotkeyEvent::Released } else { HotkeyEvent::Pressed }), + ActivationMode::Tap => emit( + lease, + if lease.active { + HotkeyEvent::Released + } else { + HotkeyEvent::Pressed + }, + ), ActivationMode::Push if !lease.active => emit(lease, HotkeyEvent::Pressed), ActivationMode::Push => {} } } fn validate_handle(handle: &HotkeyHandle) -> HotkeyResult<()> { - if handle.0.len() > MAX_HANDLE_BYTES { return Err(HotkeyError::LimitExceeded); } + if handle.0.len() > MAX_HANDLE_BYTES { + return Err(HotkeyError::LimitExceeded); + } Ok(()) } @@ -242,7 +352,11 @@ fn emit(lease: &mut Lease, event: HotkeyEvent) { } lease.active = event == HotkeyEvent::Pressed; lease.event_sequence = lease.event_sequence.saturating_add(1); - lease.events.push_back(SequencedHotkeyEvent { generation: lease.generation.unwrap_or_default(), sequence: lease.event_sequence, event }); + lease.events.push_back(SequencedHotkeyEvent { + generation: lease.generation.unwrap_or_default(), + sequence: lease.event_sequence, + event, + }); } fn reset(lease: &mut Lease) { @@ -255,7 +369,12 @@ fn reset(lease: &mut Lease) { } fn status(lease: &Lease) -> HotkeyStatus { - HotkeyStatus { state: lease.state, generation: lease.generation, active: lease.active, continuity_lost: lease.continuity_lost } + HotkeyStatus { + state: lease.state, + generation: lease.generation, + active: lease.active, + continuity_lost: lease.continuity_lost, + } } fn new_handle() -> HotkeyResult { diff --git a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs index ab96218..58f796a 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs @@ -1,30 +1,61 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; use tinyvoice_bus::{HostKeyFact, SequencedHostFact}; -use std::sync::{Arc, atomic::{AtomicBool, AtomicUsize, Ordering}}; fn started(mode: ActivationMode) -> (Hotkeys, HotkeyHandle, u64) { let hotkeys = Hotkeys::default(); - let handle = hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { - key: "Fn".into(), mode, source: HotkeySource::Host, - }}).unwrap(); - let status = hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }).unwrap(); + let handle = hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "Fn".into(), + mode, + source: HotkeySource::Host, + }, + }) + .unwrap(); + let status = hotkeys + .start(&HotkeyHandleRequest { + handle: handle.clone(), + }) + .unwrap(); (hotkeys, handle, status.generation.unwrap()) } -fn feed(hotkeys: &Hotkeys, handle: &HotkeyHandle, generation: u64, facts: &[(u64, HostKeyFact)]) -> HotkeyReply { - hotkeys.feed(&HotkeyFeedRequest { - handle: handle.clone(), generation, - facts: facts.iter().map(|(sequence, fact)| SequencedHostFact { sequence: *sequence, fact: *fact }).collect(), - overflow: false, - }).unwrap() +fn feed( + hotkeys: &Hotkeys, + handle: &HotkeyHandle, + generation: u64, + facts: &[(u64, HostKeyFact)], +) -> HotkeyReply { + hotkeys + .feed(&HotkeyFeedRequest { + handle: handle.clone(), + generation, + facts: facts + .iter() + .map(|(sequence, fact)| SequencedHostFact { + sequence: *sequence, + fact: *fact, + }) + .collect(), + overflow: false, + }) + .unwrap() } #[test] fn start_retry_and_duplicate_host_feed_are_idempotent() { let (hotkeys, handle, generation) = started(ActivationMode::Push); - let retry = hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }).unwrap(); + let retry = hotkeys + .start(&HotkeyHandleRequest { + handle: handle.clone(), + }) + .unwrap(); assert_eq!(retry.generation, Some(generation)); assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); @@ -33,45 +64,218 @@ fn start_retry_and_duplicate_host_feed_are_idempotent() { #[test] fn expired_reservation_cannot_be_started_and_releases_capacity() { let hotkeys = Hotkeys::default(); - let request = HotkeyRequest { key: "Fn".into(), mode: ActivationMode::Push, source: HotkeySource::Host }; - let handle = hotkeys.reserve(HotkeyReserveRequest { request: request.clone() }).unwrap(); - hotkeys.leases.lock().unwrap().get_mut(&handle).unwrap().created = std::time::Instant::now() + let request = HotkeyRequest { + key: "Fn".into(), + mode: ActivationMode::Push, + source: HotkeySource::Host, + }; + let handle = hotkeys + .reserve(HotkeyReserveRequest { + request: request.clone(), + }) + .unwrap(); + hotkeys + .leases + .lock() + .unwrap() + .get_mut(&handle) + .unwrap() + .created = std::time::Instant::now() .checked_sub(RESERVATION_TTL) .expect("test TTL fits in the monotonic clock range"); - assert_eq!(hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }), Err(HotkeyError::UnknownHandle)); + assert_eq!( + hotkeys.start(&HotkeyHandleRequest { + handle: handle.clone() + }), + Err(HotkeyError::UnknownHandle) + ); assert!(hotkeys.reserve(HotkeyReserveRequest { request }).is_ok()); } +#[derive(Debug, Default)] +struct FailOnceBackend(AtomicUsize); + +impl native::Backend for FailOnceBackend { + fn start(&self, _request: &HotkeyRequest) -> Result { + if self.0.fetch_add(1, Ordering::SeqCst) == 0 { + return Err(HotkeyError::Unsupported); + } + let (sender, events) = std::sync::mpsc::sync_channel(1); + drop(sender); + Ok(native::NativeListener::new( + events, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + Box::new(NoopOwner), + )) + } +} + +#[derive(Debug)] +struct NoopOwner; + +impl native::NativeOwner for NoopOwner { + fn stop(&mut self) -> Result<(), HotkeyError> { + Ok(()) + } +} + +#[test] +fn failed_native_start_restores_the_reservation_for_retry() { + let hotkeys = Hotkeys::with_backend(Arc::new(FailOnceBackend::default())); + let handle = hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "ctrl+space".into(), + mode: ActivationMode::Push, + source: HotkeySource::Native, + }, + }) + .unwrap(); + let request = HotkeyHandleRequest { + handle: handle.clone(), + }; + assert_eq!(hotkeys.start(&request), Err(HotkeyError::Unsupported)); + assert_eq!( + hotkeys.leases.lock().unwrap()[&handle].state, + HotkeyState::Reserved + ); + assert_eq!(hotkeys.start(&request).unwrap().state, HotkeyState::Running); + assert_eq!(hotkeys.stop(&request).unwrap().state, HotkeyState::Stopped); +} + #[test] fn host_source_accepts_only_the_function_key_aliases() { let hotkeys = Hotkeys::default(); - let invalid = Hotkeys::default().reserve(HotkeyReserveRequest { request: HotkeyRequest { - key: "ctrl+space".into(), mode: ActivationMode::Push, source: HotkeySource::Host, - }}); + let invalid = Hotkeys::default().reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "ctrl+space".into(), + mode: ActivationMode::Push, + source: HotkeySource::Host, + }, + }); assert_eq!(invalid, Err(HotkeyError::InvalidRequest)); - assert!(hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { - key: "fn".into(), mode: ActivationMode::Push, source: HotkeySource::Host, - }}).is_ok()); + assert!( + hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "fn".into(), + mode: ActivationMode::Push, + source: HotkeySource::Host, + } + }) + .is_ok() + ); +} + +#[test] +fn reservations_validate_keys_and_closed_leases_before_native_start() { + let hotkeys = Hotkeys::default(); + for key in [String::new(), "x".repeat(MAX_KEY_BYTES + 1)] { + assert_eq!( + hotkeys.reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key, + mode: ActivationMode::Push, + source: HotkeySource::Host, + }, + }), + Err(HotkeyError::InvalidRequest) + ); + } + + let native = hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "ctrl+space".into(), + mode: ActivationMode::Push, + source: HotkeySource::Native, + }, + }) + .unwrap(); + assert_eq!( + hotkeys.feed(&HotkeyFeedRequest { + handle: native.clone(), + generation: 0, + facts: vec![], + overflow: false, + }), + Err(HotkeyError::InvalidRequest) + ); + assert_eq!( + hotkeys.read(&HotkeyReadRequest { + handle: native.clone(), + acknowledged_batch: None, + }), + Err(HotkeyError::Closed) + ); + + hotkeys.closed.store(true, Ordering::SeqCst); + assert_eq!( + hotkeys.start(&HotkeyHandleRequest { handle: native }), + Err(HotkeyError::Closed) + ); + assert_eq!( + hotkeys.reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "Fn".into(), + mode: ActivationMode::Push, + source: HotkeySource::Host, + }, + }), + Err(HotkeyError::Closed) + ); } #[test] fn oversized_handles_are_rejected_before_lookup() { let hotkeys = Hotkeys::default(); - let request = HotkeyHandleRequest { handle: HotkeyHandle("x".repeat(MAX_HANDLE_BYTES + 1)) }; + let request = HotkeyHandleRequest { + handle: HotkeyHandle("x".repeat(MAX_HANDLE_BYTES + 1)), + }; assert_eq!(hotkeys.start(&request), Err(HotkeyError::LimitExceeded)); assert_eq!(hotkeys.stop(&request), Err(HotkeyError::LimitExceeded)); - assert_eq!(hotkeys.read(&HotkeyReadRequest { handle: request.handle.clone(), acknowledged_batch: None }), Err(HotkeyError::LimitExceeded)); - assert_eq!(hotkeys.feed(&HotkeyFeedRequest { handle: request.handle, generation: 0, facts: vec![], overflow: false }), Err(HotkeyError::LimitExceeded)); + assert_eq!( + hotkeys.read(&HotkeyReadRequest { + handle: request.handle.clone(), + acknowledged_batch: None + }), + Err(HotkeyError::LimitExceeded) + ); + assert_eq!( + hotkeys.feed(&HotkeyFeedRequest { + handle: request.handle, + generation: 0, + facts: vec![], + overflow: false + }), + Err(HotkeyError::LimitExceeded) + ); } #[test] fn read_replays_identical_batch_until_exact_acknowledgment() { let (hotkeys, handle, generation) = started(ActivationMode::Push); feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]); - let first = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); - let replay = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + let first = hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }) + .unwrap(); + let replay = hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }) + .unwrap(); assert_eq!(first, replay); - let next = hotkeys.read(&HotkeyReadRequest { handle, acknowledged_batch: Some(first.batch) }).unwrap(); + let next = hotkeys + .read(&HotkeyReadRequest { + handle, + acknowledged_batch: Some(first.batch), + }) + .unwrap(); assert!(next.events.is_empty()); assert!(next.active); } @@ -80,7 +284,10 @@ fn read_replays_identical_batch_until_exact_acknowledgment() { fn future_acknowledgment_does_not_mutate_the_retained_batch() { let (hotkeys, handle, generation) = started(ActivationMode::Push); feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]); - let request = HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }; + let request = HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }; let first = hotkeys.read(&request).unwrap(); assert_eq!( hotkeys.read(&HotkeyReadRequest { @@ -97,12 +304,24 @@ fn output_overflow_resets_inactive_and_rearms_only_after_release() { let (hotkeys, handle, generation) = started(ActivationMode::Push); let mut facts = Vec::new(); for sequence in 1..=257 { - facts.push((sequence, if sequence % 2 == 1 { HostKeyFact::Down } else { HostKeyFact::Up })); + facts.push(( + sequence, + if sequence % 2 == 1 { + HostKeyFact::Down + } else { + HostKeyFact::Up + }, + )); } for batch in facts.chunks(MAX_FEED_FACTS) { feed(&hotkeys, &handle, generation, batch); } - let reset = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + let reset = hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }) + .unwrap(); assert!(reset.reset); assert!(!reset.active); assert!(reset.events.is_empty()); @@ -114,10 +333,24 @@ fn output_overflow_resets_inactive_and_rearms_only_after_release() { fn sequence_gap_resets_push_state_until_a_fresh_release() { let (hotkeys, handle, generation) = started(ActivationMode::Push); assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); - let gap = hotkeys.feed(&HotkeyFeedRequest { handle: handle.clone(), generation, - facts: vec![SequencedHostFact { sequence: 3, fact: HostKeyFact::Down }], overflow: false }).unwrap_err(); + let gap = hotkeys + .feed(&HotkeyFeedRequest { + handle: handle.clone(), + generation, + facts: vec![SequencedHostFact { + sequence: 3, + fact: HostKeyFact::Down, + }], + overflow: false, + }) + .unwrap_err(); assert_eq!(gap, HotkeyError::SequenceGap); - let reset = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + let reset = hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }) + .unwrap(); assert!(reset.reset); assert!(!reset.active); assert!(!feed(&hotkeys, &handle, generation, &[(4, HostKeyFact::Down)]).active); @@ -129,7 +362,12 @@ fn sequence_gap_resets_push_state_until_a_fresh_release() { fn acknowledging_prior_batch_after_host_gap_returns_reset_snapshot() { let (hotkeys, handle, generation) = started(ActivationMode::Push); feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]); - let prior = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + let prior = hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }) + .unwrap(); assert!(prior.active); assert_eq!(prior.events.len(), 1); @@ -137,16 +375,21 @@ fn acknowledging_prior_batch_after_host_gap_returns_reset_snapshot() { hotkeys.feed(&HotkeyFeedRequest { handle: handle.clone(), generation, - facts: vec![SequencedHostFact { sequence: 3, fact: HostKeyFact::Down }], + facts: vec![SequencedHostFact { + sequence: 3, + fact: HostKeyFact::Down + }], overflow: false, }), Err(HotkeyError::SequenceGap) ); - let reset = hotkeys.read(&HotkeyReadRequest { - handle: handle.clone(), - acknowledged_batch: Some(prior.batch), - }).expect("the outstanding batch acknowledgment remains valid across a gap"); + let reset = hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: Some(prior.batch), + }) + .expect("the outstanding batch acknowledgment remains valid across a gap"); assert!(reset.reset); assert!(!reset.active); assert!(reset.events.is_empty()); @@ -156,8 +399,14 @@ fn acknowledging_prior_batch_after_host_gap_returns_reset_snapshot() { fn upstream_overflow_resets_active_listener_and_requires_release() { let (hotkeys, handle, generation) = started(ActivationMode::Push); assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); - let status = hotkeys.feed(&HotkeyFeedRequest { handle: handle.clone(), generation, - facts: vec![], overflow: true }).unwrap(); + let status = hotkeys + .feed(&HotkeyFeedRequest { + handle: handle.clone(), + generation, + facts: vec![], + overflow: true, + }) + .unwrap(); assert!(!status.active); assert!(status.continuity_lost); assert!(!feed(&hotkeys, &handle, generation, &[(2, HostKeyFact::Down)]).active); @@ -171,7 +420,9 @@ struct EndedReaderBackend; impl native::Backend for EndedReaderBackend { fn start(&self, _request: &HotkeyRequest) -> Result { let (events_tx, events) = std::sync::mpsc::sync_channel(8); - events_tx.send(true).expect("fixture queue accepts Down before EOF"); + events_tx + .send(true) + .expect("fixture queue accepts Down before EOF"); drop(events_tx); let overflow = Arc::new(AtomicBool::new(true)); Ok(native::NativeListener::new( @@ -187,22 +438,53 @@ impl native::Backend for EndedReaderBackend { struct EndedReaderOwner; impl native::NativeOwner for EndedReaderOwner { - fn stop(&mut self) -> Result<(), HotkeyError> { Ok(()) } + fn stop(&mut self) -> Result<(), HotkeyError> { + Ok(()) + } } #[test] fn native_reader_eof_after_down_returns_inactive_reset_snapshot() { let hotkeys = Hotkeys::with_backend(Arc::new(EndedReaderBackend)); - let handle = hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { - key: "ctrl+space".into(), mode: ActivationMode::Push, source: HotkeySource::Native, - }}).unwrap(); - hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }).unwrap(); - let snapshot = hotkeys.read(&HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }).unwrap(); + let handle = hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "ctrl+space".into(), + mode: ActivationMode::Push, + source: HotkeySource::Native, + }, + }) + .unwrap(); + hotkeys + .start(&HotkeyHandleRequest { + handle: handle.clone(), + }) + .unwrap(); + let snapshot = hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }) + .unwrap(); assert!(snapshot.reset, "EOF reports a continuity reset"); - assert!(!snapshot.active, "queued Down cannot survive an ended reader"); - assert!(snapshot.events.is_empty(), "the incomplete event stream is discarded"); - let after_ack = hotkeys.read(&HotkeyReadRequest { handle, acknowledged_batch: Some(snapshot.batch) }).unwrap(); - assert!(!after_ack.active, "stale queued Down is discarded with the reset snapshot"); + assert!( + !snapshot.active, + "queued Down cannot survive an ended reader" + ); + assert!( + snapshot.events.is_empty(), + "the incomplete event stream is discarded" + ); + let after_ack = hotkeys + .read(&HotkeyReadRequest { + handle, + acknowledged_batch: Some(snapshot.batch), + }) + .unwrap(); + assert!( + !after_ack.active, + "stale queued Down is discarded with the reset snapshot" + ); assert!(after_ack.events.is_empty()); } @@ -228,11 +510,23 @@ impl native::Backend for FakeBackend { fn start(&self, _request: &HotkeyRequest) -> Result { let (cancel, wait) = std::sync::mpsc::channel::<()>(); let joined = self.0.clone(); - let worker = std::thread::spawn(move || { let _ = wait.recv(); joined.joined.store(true, Ordering::SeqCst); }); + let worker = std::thread::spawn(move || { + let _ = wait.recv(); + joined.joined.store(true, Ordering::SeqCst); + }); let (events_tx, events) = std::sync::mpsc::sync_channel(8); drop(events_tx); - let owner = FakeOwner { state: self.0.clone(), cancel, worker: Some(worker) }; - Ok(native::NativeListener::new(events, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), Box::new(owner))) + let owner = FakeOwner { + state: self.0.clone(), + cancel, + worker: Some(worker), + }; + Ok(native::NativeListener::new( + events, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + Box::new(owner), + )) } } @@ -246,8 +540,12 @@ struct FakeOwner { impl native::NativeOwner for FakeOwner { fn stop(&mut self) -> Result<(), HotkeyError> { let _ = self.cancel.send(()); - if let Some(worker) = self.worker.take() { worker.join().map_err(|_| HotkeyError::CleanupFailed)?; } - if self.state.stop_calls.fetch_add(1, Ordering::SeqCst) == 0 { return Err(HotkeyError::CleanupFailed); } + if let Some(worker) = self.worker.take() { + worker.join().map_err(|_| HotkeyError::CleanupFailed)?; + } + if self.state.stop_calls.fetch_add(1, Ordering::SeqCst) == 0 { + return Err(HotkeyError::CleanupFailed); + } Ok(()) } } @@ -263,7 +561,11 @@ impl native::Backend for BlockingBackend { fn start(&self, _request: &HotkeyRequest) -> Result { self.starts.fetch_add(1, Ordering::SeqCst); self.entered.send(()).map_err(|_| HotkeyError::Closed)?; - self.release.lock().map_err(|_| HotkeyError::Closed)?.recv().map_err(|_| HotkeyError::Closed)?; + self.release + .lock() + .map_err(|_| HotkeyError::Closed)? + .recv() + .map_err(|_| HotkeyError::Closed)?; let (sender, events) = std::sync::mpsc::sync_channel(8); drop(sender); Ok(native::NativeListener::new( @@ -285,15 +587,25 @@ async fn canceled_start_reply_leaves_running_lease_reachable_for_retry() { release: std::sync::Mutex::new(release_rx), starts: starts.clone(), }))); - let handle = hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { - key: "ctrl+space".into(), mode: ActivationMode::Push, source: HotkeySource::Native, - }}).unwrap(); - let request = HotkeyHandleRequest { handle: handle.clone() }; + let handle = hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "ctrl+space".into(), + mode: ActivationMode::Push, + source: HotkeySource::Native, + }, + }) + .unwrap(); + let request = HotkeyHandleRequest { + handle: handle.clone(), + }; let first_hotkeys = hotkeys.clone(); let canceled_waiter = tokio::spawn(async move { let _ = tokio::task::spawn_blocking(move || first_hotkeys.start(&request)).await; }); - tokio::task::spawn_blocking(move || entered_rx.recv().unwrap()).await.unwrap(); + tokio::task::spawn_blocking(move || entered_rx.recv().unwrap()) + .await + .unwrap(); canceled_waiter.abort(); release_tx.send(()).unwrap(); @@ -302,23 +614,55 @@ async fn canceled_start_reply_leaves_running_lease_reachable_for_retry() { let retried = tokio::time::timeout( std::time::Duration::from_secs(2), tokio::task::spawn_blocking(move || retry_hotkeys.start(&retry_request)), - ).await.unwrap().unwrap().unwrap(); + ) + .await + .unwrap() + .unwrap() + .unwrap(); assert_eq!(retried.state, HotkeyState::Running); assert_eq!(retried.generation, Some(1)); - assert_eq!(starts.load(Ordering::SeqCst), 1, "retry must return the retained listener"); + assert_eq!( + starts.load(Ordering::SeqCst), + 1, + "retry must return the retained listener" + ); } #[test] fn failed_native_cleanup_keeps_retryable_ownership_and_never_acknowledges_stop() { let state = Arc::new(NativeFixtureState::default()); let hotkeys = Hotkeys::with_backend(Arc::new(FakeBackend(state.clone()))); - let handle = hotkeys.reserve(HotkeyReserveRequest { request: HotkeyRequest { - key: "ctrl+space".into(), mode: ActivationMode::Push, source: HotkeySource::Native, - }}).unwrap(); - hotkeys.start(&HotkeyHandleRequest { handle: handle.clone() }).unwrap(); - assert_eq!(hotkeys.stop(&HotkeyHandleRequest { handle: handle.clone() }), Err(HotkeyError::CleanupFailed)); - assert!(state.joined.load(Ordering::SeqCst), "stop waits for the native reader"); - assert_eq!(hotkeys.stop(&HotkeyHandleRequest { handle }), Ok(HotkeyStatus { - state: HotkeyState::Stopped, generation: Some(1), active: false, continuity_lost: true, - })); + let handle = hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "ctrl+space".into(), + mode: ActivationMode::Push, + source: HotkeySource::Native, + }, + }) + .unwrap(); + hotkeys + .start(&HotkeyHandleRequest { + handle: handle.clone(), + }) + .unwrap(); + assert_eq!( + hotkeys.stop(&HotkeyHandleRequest { + handle: handle.clone() + }), + Err(HotkeyError::CleanupFailed) + ); + assert!( + state.joined.load(Ordering::SeqCst), + "stop waits for the native reader" + ); + assert_eq!( + hotkeys.stop(&HotkeyHandleRequest { handle }), + Ok(HotkeyStatus { + state: HotkeyState::Stopped, + generation: Some(1), + active: false, + continuity_lost: true, + }) + ); } diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs index 23b4026..12a0dc0 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs @@ -2,35 +2,45 @@ use super::{NativeListener, NativeOwner}; use std::collections::HashSet; use std::os::fd::AsFd; -use std::sync::{Arc, atomic::{AtomicBool, Ordering}, mpsc}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, +}; use std::thread; -use tinyvoice_bus::{HotkeyError, HotkeyRequest}; use tinyvoice::hotkey::{ActivationMode, HotkeyCombination, Key}; -use x11rb::{connection::Connection, protocol::record::{self, ConnectionExt as _}}; +use tinyvoice_bus::{HotkeyError, HotkeyRequest}; +use x11rb::{ + connection::Connection, + protocol::record::{self, ConnectionExt as _}, + rust_connection::RustConnection, +}; const CONTROL_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300); pub(super) fn start(request: &HotkeyRequest) -> Result { - let combo = tinyvoice::hotkey::parse_hotkey(&request.key).map_err(|_| HotkeyError::InvalidRequest)?; - if combo.trigger == Key::Function || combo.modifiers.contains(&Key::Function) { return Err(HotkeyError::Unsupported); } + let combo = + tinyvoice::hotkey::parse_hotkey(&request.key).map_err(|_| HotkeyError::InvalidRequest)?; + if combo.trigger == Key::Function || combo.modifiers.contains(&Key::Function) { + return Err(HotkeyError::Unsupported); + } let (control, _) = x11rb::connect(None).map_err(|_| HotkeyError::Unsupported)?; - rustix::net::sockopt::set_socket_timeout(control.stream(), rustix::net::sockopt::Timeout::Recv, Some(CONTROL_TIMEOUT)).map_err(|_| HotkeyError::Unsupported)?; - rustix::net::sockopt::set_socket_timeout(control.stream(), rustix::net::sockopt::Timeout::Send, Some(CONTROL_TIMEOUT)).map_err(|_| HotkeyError::Unsupported)?; + rustix::net::sockopt::set_socket_timeout( + control.stream(), + rustix::net::sockopt::Timeout::Recv, + Some(CONTROL_TIMEOUT), + ) + .map_err(|_| HotkeyError::Unsupported)?; + rustix::net::sockopt::set_socket_timeout( + control.stream(), + rustix::net::sockopt::Timeout::Send, + Some(CONTROL_TIMEOUT), + ) + .map_err(|_| HotkeyError::Unsupported)?; let (data, _) = x11rb::connect(None).map_err(|_| HotkeyError::Unsupported)?; - let context = control.generate_id().map_err(|_| HotkeyError::Unsupported)?; - let empty = record::Range8 { first: 0, last: 0 }; - let range = record::Range { - core_requests: empty, - core_replies: empty, - ext_requests: record::ExtRange { major: empty, minor: record::Range16 { first: 0, last: 0 } }, - ext_replies: record::ExtRange { major: empty, minor: record::Range16 { first: 0, last: 0 } }, - delivered_events: empty, - device_events: record::Range8 { first: 2, last: 3 }, - errors: empty, - client_started: false, - client_died: false, - }; - control.record_create_context(context, 0, &[record::CS::ALL_CLIENTS.into()], &[range]) - .map_err(|_| HotkeyError::Unsupported)?.check().map_err(|_| HotkeyError::Unsupported)?; + let context = control + .generate_id() + .map_err(|_| HotkeyError::Unsupported)?; + create_record_context(&control, context)?; let wake = rustix::io::dup(data.stream().as_fd()).map_err(|_| HotkeyError::Unsupported)?; let (events_tx, events) = mpsc::sync_channel(256); let overflow = Arc::new(AtomicBool::new(false)); @@ -43,26 +53,84 @@ pub(super) fn start(request: &HotkeyRequest) -> Result ActivationMode::Push, }; let combo_for_thread = combo.clone(); - let worker = thread::Builder::new().name("tinyvoice-hotkey-x11".into()).spawn(move || { - let Ok(mut replies) = data.record_enable_context(context) else { - let _ = ready_tx.send(Err(())); - return; - }; - let _ = ready_tx.send(Ok(())); - let mut down = HashSet::new(); - while let Some(Ok(reply)) = replies.next() { - if reply.category == 5 { break; } - process_record_bytes(&reply.data, &combo_for_thread, mode, &mut down, &events_tx, &worker_overflow); - } - if !worker_closing.load(Ordering::SeqCst) { worker_overflow.store(true, Ordering::SeqCst); } - }).map_err(|_| HotkeyError::Unsupported)?; + let worker = thread::Builder::new() + .name("tinyvoice-hotkey-x11".into()) + .spawn(move || { + let Ok(mut replies) = data.record_enable_context(context) else { + let _ = ready_tx.send(Err(())); + return; + }; + let _ = ready_tx.send(Ok(())); + let mut down = HashSet::new(); + while let Some(Ok(reply)) = replies.next() { + if reply.category == 5 { + break; + } + process_record_bytes( + &reply.data, + &combo_for_thread, + mode, + &mut down, + &events_tx, + &worker_overflow, + ); + } + if !worker_closing.load(Ordering::SeqCst) { + worker_overflow.store(true, Ordering::SeqCst); + } + }) + .map_err(|_| HotkeyError::Unsupported)?; let Ok(Ok(())) = ready_rx.recv() else { - let mut owner = XRecordOwner { control, context: Some(context), disable_sent: false, wake, worker: Some(worker) }; + let mut owner = XRecordOwner { + control, + context: Some(context), + disable_sent: false, + wake, + worker: Some(worker), + }; let _ = owner.stop(); return Err(HotkeyError::Unsupported); }; - let owner = XRecordOwner { control, context: Some(context), disable_sent: false, wake, worker: Some(worker) }; - Ok(NativeListener::new(events, overflow, closing, Box::new(owner))) + let owner = XRecordOwner { + control, + context: Some(context), + disable_sent: false, + wake, + worker: Some(worker), + }; + Ok(NativeListener::new( + events, + overflow, + closing, + Box::new(owner), + )) +} + +fn create_record_context(control: &RustConnection, context: u32) -> Result<(), HotkeyError> { + let empty = record::Range8 { first: 0, last: 0 }; + let range = record::Range { + core_requests: empty, + core_replies: empty, + ext_requests: record::ExtRange { + major: empty, + minor: record::Range16 { first: 0, last: 0 }, + }, + ext_replies: record::ExtRange { + major: empty, + minor: record::Range16 { first: 0, last: 0 }, + }, + delivered_events: empty, + device_events: record::Range8 { first: 2, last: 3 }, + errors: empty, + client_started: false, + client_died: false, + }; + control + .record_create_context(context, 0, &[record::CS::ALL_CLIENTS.into()], &[range]) + .map_err(|_| HotkeyError::Unsupported)? + .check() + .map_err(|_| HotkeyError::Unsupported)?; + Ok(()) } #[derive(Debug)] @@ -84,13 +152,20 @@ impl NativeOwner for XRecordOwner { } Err(_) => Err(HotkeyError::CleanupFailed), } - } else { Ok(()) }; + } else { + Ok(()) + }; let _ = rustix::net::shutdown(&self.wake, rustix::net::Shutdown::Both); - if let Some(worker) = self.worker.take() { worker.join().map_err(|_| HotkeyError::CleanupFailed)?; } + if let Some(worker) = self.worker.take() { + worker.join().map_err(|_| HotkeyError::CleanupFailed)?; + } disable_result?; if let Some(context) = self.context { - self.control.record_free_context(context).map_err(|_| HotkeyError::CleanupFailed)? - .check().map_err(|_| HotkeyError::CleanupFailed)?; + self.control + .record_free_context(context) + .map_err(|_| HotkeyError::CleanupFailed)? + .check() + .map_err(|_| HotkeyError::CleanupFailed)?; self.context = None; } Ok(()) @@ -111,9 +186,11 @@ fn process_record_bytes( let frame = frames[index]; let kind = frame[0] & 0x7f; let code = frame[1]; - if kind == 3 && frames.get(index + 1).is_some_and(|next| { - next[0] & 0x7f == 2 && next[1] == code && next[4..8] == frame[4..8] - }) { + if kind == 3 + && frames.get(index + 1).is_some_and(|next| { + next[0] & 0x7f == 2 && next[1] == code && next[4..8] == frame[4..8] + }) + { // X11 auto-repeat is a same-time release/press pair. Preserve the // held state so repeats do not toggle tap or release push mode. index += 2; @@ -130,15 +207,34 @@ fn process_record_bytes( } } -fn process_event(key: Key, pressed: bool, combo: &HotkeyCombination, mode: ActivationMode, down: &mut HashSet) -> Option { +fn process_event( + key: Key, + pressed: bool, + combo: &HotkeyCombination, + mode: ActivationMode, + down: &mut HashSet, +) -> Option { if pressed { let is_new = down.insert(key); - if !is_new || key != combo.trigger || !combo.modifiers.iter().all(|modifier| down.contains(modifier)) { return None; } + if !is_new + || key != combo.trigger + || !combo + .modifiers + .iter() + .all(|modifier| down.contains(modifier)) + { + return None; + } return Some(true); } down.remove(&key); - if key == combo.trigger { return Some(false); } - if mode == ActivationMode::Push && combo.modifiers.contains(&key) && down.contains(&combo.trigger) { + if key == combo.trigger { + return Some(false); + } + if mode == ActivationMode::Push + && combo.modifiers.contains(&key) + && down.contains(&combo.trigger) + { down.remove(&combo.trigger); return Some(false); } @@ -146,26 +242,139 @@ fn process_event(key: Key, pressed: bool, combo: &HotkeyCombination, mode: Activ } fn key_from_x_keycode(code: u32) -> Option { + modifier_key(code) + .or_else(|| navigation_key(code)) + .or_else(|| function_key(code)) + .or_else(|| character_key(code)) + .or_else(|| keypad_key(code)) +} + +fn modifier_key(code: u32) -> Option { + Some(match code { + 64 => Key::Alt, + 108 => Key::AltGr, + 37 => Key::ControlLeft, + 105 => Key::ControlRight, + 133 => Key::MetaLeft, + 134 => Key::MetaRight, + 50 => Key::ShiftLeft, + 62 => Key::ShiftRight, + _ => return None, + }) +} + +fn navigation_key(code: u32) -> Option { + Some(match code { + 22 => Key::Backspace, + 66 => Key::CapsLock, + 119 => Key::Delete, + 116 => Key::DownArrow, + 115 => Key::End, + 9 => Key::Escape, + 110 => Key::Home, + 113 => Key::LeftArrow, + 117 => Key::PageDown, + 112 => Key::PageUp, + 36 => Key::Return, + 114 => Key::RightArrow, + 65 => Key::Space, + 23 => Key::Tab, + 111 => Key::UpArrow, + 118 => Key::Insert, + _ => return None, + }) +} + +fn function_key(code: u32) -> Option { + Some(match code { + 67 => Key::F1, + 68 => Key::F2, + 69 => Key::F3, + 70 => Key::F4, + 71 => Key::F5, + 72 => Key::F6, + 73 => Key::F7, + 74 => Key::F8, + 75 => Key::F9, + 76 => Key::F10, + 95 => Key::F11, + 96 => Key::F12, + _ => return None, + }) +} + +fn character_key(code: u32) -> Option { + Some(match code { + 49 => Key::BackQuote, + 10 => Key::Num1, + 11 => Key::Num2, + 12 => Key::Num3, + 13 => Key::Num4, + 14 => Key::Num5, + 15 => Key::Num6, + 16 => Key::Num7, + 17 => Key::Num8, + 18 => Key::Num9, + 19 => Key::Num0, + 20 => Key::Minus, + 21 => Key::Equal, + 24 => Key::KeyQ, + 25 => Key::KeyW, + 26 => Key::KeyE, + 27 => Key::KeyR, + 28 => Key::KeyT, + 29 => Key::KeyY, + 30 => Key::KeyU, + 31 => Key::KeyI, + 32 => Key::KeyO, + 33 => Key::KeyP, + 34 => Key::LeftBracket, + 35 => Key::RightBracket, + 38 => Key::KeyA, + 39 => Key::KeyS, + 40 => Key::KeyD, + 41 => Key::KeyF, + 42 => Key::KeyG, + 43 => Key::KeyH, + 44 => Key::KeyJ, + 45 => Key::KeyK, + 46 => Key::KeyL, + 47 => Key::SemiColon, + 48 => Key::Quote, + 51 => Key::BackSlash, + 94 => Key::IntlBackslash, + 52 => Key::KeyZ, + 53 => Key::KeyX, + 54 => Key::KeyC, + 55 => Key::KeyV, + 56 => Key::KeyB, + 57 => Key::KeyN, + 58 => Key::KeyM, + 59 => Key::Comma, + 60 => Key::Dot, + 61 => Key::Slash, + _ => return None, + }) +} + +fn keypad_key(code: u32) -> Option { Some(match code { - 64 => Key::Alt, 108 => Key::AltGr, 22 => Key::Backspace, 66 => Key::CapsLock, - 37 => Key::ControlLeft, 105 => Key::ControlRight, 119 => Key::Delete, 116 => Key::DownArrow, - 115 => Key::End, 9 => Key::Escape, 67 => Key::F1, 76 => Key::F10, 95 => Key::F11, 96 => Key::F12, - 68 => Key::F2, 69 => Key::F3, 70 => Key::F4, 71 => Key::F5, 72 => Key::F6, 73 => Key::F7, - 74 => Key::F8, 75 => Key::F9, 110 => Key::Home, 113 => Key::LeftArrow, 133 => Key::MetaLeft, 134 => Key::MetaRight, - 117 => Key::PageDown, 112 => Key::PageUp, 36 => Key::Return, 114 => Key::RightArrow, - 50 => Key::ShiftLeft, 62 => Key::ShiftRight, 65 => Key::Space, 23 => Key::Tab, 111 => Key::UpArrow, - 49 => Key::BackQuote, 10 => Key::Num1, 11 => Key::Num2, 12 => Key::Num3, 13 => Key::Num4, - 14 => Key::Num5, 15 => Key::Num6, 16 => Key::Num7, 17 => Key::Num8, 18 => Key::Num9, 19 => Key::Num0, - 20 => Key::Minus, 21 => Key::Equal, 24 => Key::KeyQ, 25 => Key::KeyW, 26 => Key::KeyE, 27 => Key::KeyR, - 28 => Key::KeyT, 29 => Key::KeyY, 30 => Key::KeyU, 31 => Key::KeyI, 32 => Key::KeyO, 33 => Key::KeyP, - 34 => Key::LeftBracket, 35 => Key::RightBracket, 38 => Key::KeyA, 39 => Key::KeyS, 40 => Key::KeyD, - 41 => Key::KeyF, 42 => Key::KeyG, 43 => Key::KeyH, 44 => Key::KeyJ, 45 => Key::KeyK, 46 => Key::KeyL, - 47 => Key::SemiColon, 48 => Key::Quote, 51 => Key::BackSlash, 94 => Key::IntlBackslash, - 52 => Key::KeyZ, 53 => Key::KeyX, 54 => Key::KeyC, 55 => Key::KeyV, 56 => Key::KeyB, 57 => Key::KeyN, - 58 => Key::KeyM, 59 => Key::Comma, 60 => Key::Dot, 61 => Key::Slash, 118 => Key::Insert, - 104 => Key::KpReturn, 82 => Key::KpMinus, 86 => Key::KpPlus, 63 => Key::KpMultiply, 106 => Key::KpDivide, - 90 => Key::Kp0, 87 => Key::Kp1, 88 => Key::Kp2, 89 => Key::Kp3, 83 => Key::Kp4, 84 => Key::Kp5, - 85 => Key::Kp6, 79 => Key::Kp7, 80 => Key::Kp8, 81 => Key::Kp9, 91 => Key::KpDelete, + 104 => Key::KpReturn, + 82 => Key::KpMinus, + 86 => Key::KpPlus, + 63 => Key::KpMultiply, + 106 => Key::KpDivide, + 90 => Key::Kp0, + 87 => Key::Kp1, + 88 => Key::Kp2, + 89 => Key::Kp3, + 83 => Key::Kp4, + 84 => Key::Kp5, + 85 => Key::Kp6, + 79 => Key::Kp7, + 80 => Key::Kp8, + 81 => Key::Kp9, + 91 => Key::KpDelete, _ => return None, }) } diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs index bcec8d1..71e4a11 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs @@ -5,7 +5,9 @@ use std::collections::HashSet; #[test] fn local_xrecord_listener_cancels_blocked_reader_and_joins() { - if std::env::var_os("DISPLAY").is_none() { return; } + if std::env::var_os("DISPLAY").is_none() { + return; + } let request = HotkeyRequest { key: "ctrl+space".to_owned(), mode: tinyvoice_bus::ActivationMode::Push, @@ -15,7 +17,9 @@ fn local_xrecord_listener_cancels_blocked_reader_and_joins() { .start(&request) .expect("Xvfb X RECORD startup"); assert!(listener.events.try_recv().is_err()); - listener.stop().expect("X RECORD stop disables context and joins reader"); + listener + .stop() + .expect("X RECORD stop disables context and joins reader"); } #[test] @@ -29,34 +33,183 @@ fn autorepeat_release_press_pair_does_not_end_or_retoggle_activation() { let (sender, receiver) = mpsc::sync_channel(8); let mut down = HashSet::new(); let overflow = AtomicBool::new(false); - process_record_bytes(&bytes, &combo, ActivationMode::Push, &mut down, &sender, &overflow); + process_record_bytes( + &bytes, + &combo, + ActivationMode::Push, + &mut down, + &sender, + &overflow, + ); assert_eq!(receiver.try_iter().collect::>(), vec![true, false]); assert!(!overflow.load(Ordering::SeqCst)); } #[test] +#[allow( + clippy::too_many_lines, + reason = "the complete physical-key map is one fixture" +)] fn physical_x_keycodes_keep_the_existing_key_vocabulary() { let mappings = [ - (64, Key::Alt), (37, Key::ControlLeft), (9, Key::Escape), (68, Key::F2), - (110, Key::Home), (117, Key::PageDown), (50, Key::ShiftLeft), (49, Key::BackQuote), - (10, Key::Num1), (20, Key::Minus), (24, Key::KeyQ), (34, Key::LeftBracket), - (41, Key::KeyF), (47, Key::SemiColon), (52, Key::KeyZ), (118, Key::Insert), - (104, Key::KpReturn), (85, Key::Kp6), (91, Key::KpDelete), + (64, Key::Alt), + (108, Key::AltGr), + (22, Key::Backspace), + (66, Key::CapsLock), + (37, Key::ControlLeft), + (105, Key::ControlRight), + (119, Key::Delete), + (116, Key::DownArrow), + (115, Key::End), + (9, Key::Escape), + (67, Key::F1), + (76, Key::F10), + (95, Key::F11), + (96, Key::F12), + (68, Key::F2), + (69, Key::F3), + (70, Key::F4), + (71, Key::F5), + (72, Key::F6), + (73, Key::F7), + (74, Key::F8), + (75, Key::F9), + (110, Key::Home), + (113, Key::LeftArrow), + (133, Key::MetaLeft), + (134, Key::MetaRight), + (117, Key::PageDown), + (112, Key::PageUp), + (36, Key::Return), + (114, Key::RightArrow), + (50, Key::ShiftLeft), + (62, Key::ShiftRight), + (65, Key::Space), + (23, Key::Tab), + (111, Key::UpArrow), + (49, Key::BackQuote), + (10, Key::Num1), + (11, Key::Num2), + (12, Key::Num3), + (13, Key::Num4), + (14, Key::Num5), + (15, Key::Num6), + (16, Key::Num7), + (17, Key::Num8), + (18, Key::Num9), + (19, Key::Num0), + (20, Key::Minus), + (21, Key::Equal), + (24, Key::KeyQ), + (25, Key::KeyW), + (26, Key::KeyE), + (27, Key::KeyR), + (28, Key::KeyT), + (29, Key::KeyY), + (30, Key::KeyU), + (31, Key::KeyI), + (32, Key::KeyO), + (33, Key::KeyP), + (34, Key::LeftBracket), + (35, Key::RightBracket), + (38, Key::KeyA), + (39, Key::KeyS), + (40, Key::KeyD), + (41, Key::KeyF), + (42, Key::KeyG), + (43, Key::KeyH), + (44, Key::KeyJ), + (45, Key::KeyK), + (46, Key::KeyL), + (47, Key::SemiColon), + (48, Key::Quote), + (51, Key::BackSlash), + (94, Key::IntlBackslash), + (52, Key::KeyZ), + (53, Key::KeyX), + (54, Key::KeyC), + (55, Key::KeyV), + (56, Key::KeyB), + (57, Key::KeyN), + (58, Key::KeyM), + (59, Key::Comma), + (60, Key::Dot), + (61, Key::Slash), + (118, Key::Insert), + (104, Key::KpReturn), + (82, Key::KpMinus), + (86, Key::KpPlus), + (63, Key::KpMultiply), + (106, Key::KpDivide), + (90, Key::Kp0), + (87, Key::Kp1), + (88, Key::Kp2), + (89, Key::Kp3), + (83, Key::Kp4), + (84, Key::Kp5), + (85, Key::Kp6), + (79, Key::Kp7), + (80, Key::Kp8), + (81, Key::Kp9), + (91, Key::KpDelete), ]; + assert_key_mappings(&mappings); + assert_eq!(key_from_x_keycode(0), None); +} + +fn assert_key_mappings(mappings: &[(u32, Key)]) { for (keycode, expected) in mappings { - assert_eq!(key_from_x_keycode(keycode), Some(expected)); + assert_eq!(key_from_x_keycode(*keycode), Some(*expected)); + let combo = HotkeyCombination { + modifiers: HashSet::new(), + trigger: *expected, + }; + let mut down = HashSet::new(); + assert_eq!( + process_event(*expected, true, &combo, ActivationMode::Tap, &mut down,), + Some(true), + "X keycode {keycode} should activate its mapped key" + ); + assert_eq!( + process_event(*expected, false, &combo, ActivationMode::Tap, &mut down,), + Some(false), + "X keycode {keycode} should release its mapped key" + ); } - assert_eq!(key_from_x_keycode(0), None); } #[test] fn push_modifier_release_ends_activation_before_trigger_release() { let combo = tinyvoice::hotkey::parse_hotkey("ctrl+space").unwrap(); let mut down = HashSet::new(); - assert_eq!(process_event(Key::ControlLeft, true, &combo, ActivationMode::Push, &mut down), None); - assert_eq!(process_event(Key::Space, true, &combo, ActivationMode::Push, &mut down), Some(true)); - assert_eq!(process_event(Key::ControlLeft, false, &combo, ActivationMode::Push, &mut down), Some(false)); - assert_eq!(process_event(Key::Space, false, &combo, ActivationMode::Push, &mut down), Some(false)); + assert_eq!( + process_event( + Key::ControlLeft, + true, + &combo, + ActivationMode::Push, + &mut down + ), + None + ); + assert_eq!( + process_event(Key::Space, true, &combo, ActivationMode::Push, &mut down), + Some(true) + ); + assert_eq!( + process_event( + Key::ControlLeft, + false, + &combo, + ActivationMode::Push, + &mut down + ), + Some(false) + ); + assert_eq!( + process_event(Key::Space, false, &combo, ActivationMode::Push, &mut down), + Some(false) + ); } #[test] @@ -68,7 +221,14 @@ fn a_full_native_event_queue_reports_continuity_loss() { let (sender, receiver) = mpsc::sync_channel(1); let mut down = HashSet::new(); let overflow = AtomicBool::new(false); - process_record_bytes(&bytes, &combo, ActivationMode::Push, &mut down, &sender, &overflow); + process_record_bytes( + &bytes, + &combo, + ActivationMode::Push, + &mut down, + &sender, + &overflow, + ); assert!(overflow.load(Ordering::SeqCst)); assert_eq!(receiver.try_iter().collect::>(), vec![true]); } diff --git a/crates/tinyvoice-module/src/service/hotkey/native/mod.rs b/crates/tinyvoice-module/src/service/hotkey/native/mod.rs index a886d0d..c0e4736 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/mod.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/mod.rs @@ -19,20 +19,33 @@ pub(super) trait Backend: std::fmt::Debug + Send + Sync { } impl NativeListener { - pub(super) fn new(events: mpsc::Receiver, overflow: Arc, closing: Arc, owner: Box) -> Self { - Self { events, overflow, closing, owner } + pub(super) fn new( + events: mpsc::Receiver, + overflow: Arc, + closing: Arc, + owner: Box, + ) -> Self { + Self { + events, + overflow, + closing, + owner, + } } } impl NativeListener { pub(super) fn stop(&mut self) -> Result<(), HotkeyError> { - self.closing.store(true, std::sync::atomic::Ordering::SeqCst); + self.closing + .store(true, std::sync::atomic::Ordering::SeqCst); self.owner.stop() } } impl Drop for NativeListener { - fn drop(&mut self) { let _ = self.stop(); } + fn drop(&mut self) { + let _ = self.stop(); + } } #[cfg(target_os = "linux")] @@ -44,18 +57,32 @@ mod windows; struct PlatformBackend; impl Backend for PlatformBackend { - fn start(&self, request: &HotkeyRequest) -> Result { start_platform(request) } + fn start(&self, request: &HotkeyRequest) -> Result { + start_platform(request) + } } -pub(super) fn backend() -> Arc { Arc::new(PlatformBackend) } +pub(super) fn backend() -> Arc { + Arc::new(PlatformBackend) +} fn start_platform(request: &HotkeyRequest) -> Result { #[cfg(target_os = "linux")] - { linux::start(request) } + { + linux::start(request) + } #[cfg(target_os = "windows")] - { windows::start(request) } + { + windows::start(request) + } #[cfg(target_os = "macos")] - { let _ = request; Err(HotkeyError::Unsupported) } + { + let _ = request; + Err(HotkeyError::Unsupported) + } #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - { let _ = request; Err(HotkeyError::Unsupported) } + { + let _ = request; + Err(HotkeyError::Unsupported) + } } diff --git a/crates/tinyvoice-module/src/service/hotkey/native/windows.rs b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs index 8a64730..edc2b5b 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/windows.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs @@ -6,69 +6,176 @@ use tinyvoice_bus::{ActivationMode, HotkeyError, HotkeyRequest}; use tinyvoice_hotkey_win::{Chord, Listener}; pub(super) fn start(request: &HotkeyRequest) -> Result { - let combination = tinyvoice::hotkey::parse_hotkey(&request.key).map_err(|_| HotkeyError::InvalidRequest)?; + let combination = + tinyvoice::hotkey::parse_hotkey(&request.key).map_err(|_| HotkeyError::InvalidRequest)?; let trigger = virtual_key(combination.trigger).ok_or(HotkeyError::Unsupported)?; - let modifiers = combination.modifiers.iter().map(|key| virtual_key(*key).ok_or(HotkeyError::Unsupported)).collect::, _>>()?; - let chord = Chord { trigger, modifiers, push: request.mode == ActivationMode::Push }; + let modifiers = combination + .modifiers + .iter() + .map(|key| virtual_key(*key).ok_or(HotkeyError::Unsupported)) + .collect::, _>>()?; + let chord = Chord { + trigger, + modifiers, + push: request.mode == ActivationMode::Push, + }; let mut listener = tinyvoice_hotkey_win::start(chord).map_err(|_| HotkeyError::Unsupported)?; let events = listener.take_events().ok_or(HotkeyError::Unsupported)?; let (sender, receiver) = std::sync::mpsc::sync_channel(256); let overflow = listener.overflow(); let bridge_overflow = overflow.clone(); - let bridge = std::thread::Builder::new().name("tinyvoice-hotkey-win-bridge".into()).spawn(move || { - while let Ok(event) = events.recv() { - if sender.try_send(event).is_err() { bridge_overflow.store(true, std::sync::atomic::Ordering::SeqCst); } - } - }).map_err(|_| HotkeyError::Unsupported)?; - Ok(NativeListener::new(receiver, overflow, Arc::new(AtomicBool::new(false)), Box::new(WindowsOwner { listener, bridge: Some(bridge) }))) + let bridge = std::thread::Builder::new() + .name("tinyvoice-hotkey-win-bridge".into()) + .spawn(move || { + while let Ok(event) = events.recv() { + if sender.try_send(event).is_err() { + bridge_overflow.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + }) + .map_err(|_| HotkeyError::Unsupported)?; + Ok(NativeListener::new( + receiver, + overflow, + Arc::new(AtomicBool::new(false)), + Box::new(WindowsOwner { + listener, + bridge: Some(bridge), + }), + )) } #[derive(Debug)] -struct WindowsOwner { listener: Listener, bridge: Option> } +struct WindowsOwner { + listener: Listener, + bridge: Option>, +} impl NativeOwner for WindowsOwner { fn stop(&mut self) -> Result<(), HotkeyError> { - self.listener.stop().map_err(|_| HotkeyError::CleanupFailed)?; - if let Some(bridge) = self.bridge.take() { bridge.join().map_err(|_| HotkeyError::CleanupFailed)?; } + self.listener + .stop() + .map_err(|_| HotkeyError::CleanupFailed)?; + if let Some(bridge) = self.bridge.take() { + bridge.join().map_err(|_| HotkeyError::CleanupFailed)?; + } Ok(()) } } impl Drop for WindowsOwner { fn drop(&mut self) { - while self.stop().is_err() { std::thread::sleep(std::time::Duration::from_millis(50)); } + while self.stop().is_err() { + std::thread::sleep(std::time::Duration::from_millis(50)); + } } } fn virtual_key(key: Key) -> Option { Some(match key { - Key::Alt => 0xA4, Key::AltGr => 0xA5, Key::Backspace => 0x08, Key::CapsLock => 0x14, - Key::ControlLeft => 0xA2, Key::ControlRight => 0xA3, Key::Delete => 0x2E, Key::DownArrow => 0x28, - Key::End => 0x23, Key::Escape => 0x1B, Key::F1 => 0x70, Key::F2 => 0x71, Key::F3 => 0x72, - Key::F4 => 0x73, Key::F5 => 0x74, Key::F6 => 0x75, Key::F7 => 0x76, Key::F8 => 0x77, - Key::F9 => 0x78, Key::F10 => 0x79, Key::F11 => 0x7A, Key::F12 => 0x7B, Key::Home => 0x24, - Key::LeftArrow => 0x25, Key::MetaLeft => 0x5B, Key::MetaRight => 0x5C, Key::PageDown => 0x22, - Key::PageUp => 0x21, Key::Return | Key::KpReturn => 0x0D, Key::RightArrow => 0x27, - Key::ShiftLeft => 0xA0, Key::ShiftRight => 0xA1, Key::Space => 0x20, Key::Tab => 0x09, - Key::UpArrow => 0x26, Key::PrintScreen => 0x2C, Key::ScrollLock => 0x91, Key::Pause => 0x13, - Key::NumLock => 0x90, Key::BackQuote => 0xC0, Key::Num0 => 0x30, Key::Num1 => 0x31, - Key::Num2 => 0x32, Key::Num3 => 0x33, Key::Num4 => 0x34, Key::Num5 => 0x35, Key::Num6 => 0x36, - Key::Num7 => 0x37, Key::Num8 => 0x38, Key::Num9 => 0x39, Key::Minus => 0xBD, Key::Equal => 0xBB, - Key::KeyA => b'A' as u32, Key::KeyB => b'B' as u32, Key::KeyC => b'C' as u32, - Key::KeyD => b'D' as u32, Key::KeyE => b'E' as u32, Key::KeyF => b'F' as u32, - Key::KeyG => b'G' as u32, Key::KeyH => b'H' as u32, Key::KeyI => b'I' as u32, - Key::KeyJ => b'J' as u32, Key::KeyK => b'K' as u32, Key::KeyL => b'L' as u32, - Key::KeyM => b'M' as u32, Key::KeyN => b'N' as u32, Key::KeyO => b'O' as u32, - Key::KeyP => b'P' as u32, Key::KeyQ => b'Q' as u32, Key::KeyR => b'R' as u32, - Key::KeyS => b'S' as u32, Key::KeyT => b'T' as u32, Key::KeyU => b'U' as u32, - Key::KeyV => b'V' as u32, Key::KeyW => b'W' as u32, Key::KeyX => b'X' as u32, - Key::KeyY => b'Y' as u32, Key::KeyZ => b'Z' as u32, Key::LeftBracket => 0xDB, - Key::RightBracket => 0xDD, Key::SemiColon => 0xBA, Key::Quote => 0xDE, Key::BackSlash => 0xDC, - Key::IntlBackslash => 0xE2, Key::Comma => 0xBC, Key::Dot => 0xBE, Key::Slash => 0xBF, - Key::Insert => 0x2D, Key::KpMinus => 0x6D, Key::KpPlus => 0x6B, Key::KpMultiply => 0x6A, - Key::KpDivide => 0x6F, Key::Kp0 => 0x60, Key::Kp1 => 0x61, Key::Kp2 => 0x62, Key::Kp3 => 0x63, - Key::Kp4 => 0x64, Key::Kp5 => 0x65, Key::Kp6 => 0x66, Key::Kp7 => 0x67, Key::Kp8 => 0x68, - Key::Kp9 => 0x69, Key::KpDelete => 0x6E, + Key::Alt => 0xA4, + Key::AltGr => 0xA5, + Key::Backspace => 0x08, + Key::CapsLock => 0x14, + Key::ControlLeft => 0xA2, + Key::ControlRight => 0xA3, + Key::Delete => 0x2E, + Key::DownArrow => 0x28, + Key::End => 0x23, + Key::Escape => 0x1B, + Key::F1 => 0x70, + Key::F2 => 0x71, + Key::F3 => 0x72, + Key::F4 => 0x73, + Key::F5 => 0x74, + Key::F6 => 0x75, + Key::F7 => 0x76, + Key::F8 => 0x77, + Key::F9 => 0x78, + Key::F10 => 0x79, + Key::F11 => 0x7A, + Key::F12 => 0x7B, + Key::Home => 0x24, + Key::LeftArrow => 0x25, + Key::MetaLeft => 0x5B, + Key::MetaRight => 0x5C, + Key::PageDown => 0x22, + Key::PageUp => 0x21, + Key::Return | Key::KpReturn => 0x0D, + Key::RightArrow => 0x27, + Key::ShiftLeft => 0xA0, + Key::ShiftRight => 0xA1, + Key::Space => 0x20, + Key::Tab => 0x09, + Key::UpArrow => 0x26, + Key::PrintScreen => 0x2C, + Key::ScrollLock => 0x91, + Key::Pause => 0x13, + Key::NumLock => 0x90, + Key::BackQuote => 0xC0, + Key::Num0 => 0x30, + Key::Num1 => 0x31, + Key::Num2 => 0x32, + Key::Num3 => 0x33, + Key::Num4 => 0x34, + Key::Num5 => 0x35, + Key::Num6 => 0x36, + Key::Num7 => 0x37, + Key::Num8 => 0x38, + Key::Num9 => 0x39, + Key::Minus => 0xBD, + Key::Equal => 0xBB, + Key::KeyA => b'A' as u32, + Key::KeyB => b'B' as u32, + Key::KeyC => b'C' as u32, + Key::KeyD => b'D' as u32, + Key::KeyE => b'E' as u32, + Key::KeyF => b'F' as u32, + Key::KeyG => b'G' as u32, + Key::KeyH => b'H' as u32, + Key::KeyI => b'I' as u32, + Key::KeyJ => b'J' as u32, + Key::KeyK => b'K' as u32, + Key::KeyL => b'L' as u32, + Key::KeyM => b'M' as u32, + Key::KeyN => b'N' as u32, + Key::KeyO => b'O' as u32, + Key::KeyP => b'P' as u32, + Key::KeyQ => b'Q' as u32, + Key::KeyR => b'R' as u32, + Key::KeyS => b'S' as u32, + Key::KeyT => b'T' as u32, + Key::KeyU => b'U' as u32, + Key::KeyV => b'V' as u32, + Key::KeyW => b'W' as u32, + Key::KeyX => b'X' as u32, + Key::KeyY => b'Y' as u32, + Key::KeyZ => b'Z' as u32, + Key::LeftBracket => 0xDB, + Key::RightBracket => 0xDD, + Key::SemiColon => 0xBA, + Key::Quote => 0xDE, + Key::BackSlash => 0xDC, + Key::IntlBackslash => 0xE2, + Key::Comma => 0xBC, + Key::Dot => 0xBE, + Key::Slash => 0xBF, + Key::Insert => 0x2D, + Key::KpMinus => 0x6D, + Key::KpPlus => 0x6B, + Key::KpMultiply => 0x6A, + Key::KpDivide => 0x6F, + Key::Kp0 => 0x60, + Key::Kp1 => 0x61, + Key::Kp2 => 0x62, + Key::Kp3 => 0x63, + Key::Kp4 => 0x64, + Key::Kp5 => 0x65, + Key::Kp6 => 0x66, + Key::Kp7 => 0x67, + Key::Kp8 => 0x68, + Key::Kp9 => 0x69, + Key::KpDelete => 0x6E, Key::Function | Key::Unknown(_) => return None, }) } diff --git a/crates/tinyvoice-module/src/service/hotkey/native/windows_tests.rs b/crates/tinyvoice-module/src/service/hotkey/native/windows_tests.rs new file mode 100644 index 0000000..ca682d0 --- /dev/null +++ b/crates/tinyvoice-module/src/service/hotkey/native/windows_tests.rs @@ -0,0 +1,27 @@ +//! Pure key-map checks for the Windows native adapter. +use super::virtual_key; +use tinyvoice::hotkey::Key; + +#[test] +fn left_and_right_modifier_keys_keep_distinct_virtual_codes() { + assert_eq!(virtual_key(Key::ControlLeft), Some(0xA2)); + assert_eq!(virtual_key(Key::ControlRight), Some(0xA3)); + assert_eq!(virtual_key(Key::ShiftLeft), Some(0xA0)); + assert_eq!(virtual_key(Key::ShiftRight), Some(0xA1)); + assert_eq!(virtual_key(Key::Alt), Some(0xA4)); + assert_eq!(virtual_key(Key::AltGr), Some(0xA5)); +} + +#[test] +fn printable_and_keypad_keys_map_to_their_windows_codes() { + assert_eq!(virtual_key(Key::KeyA), Some(u32::from(b'A'))); + assert_eq!(virtual_key(Key::Num0), Some(0x30)); + assert_eq!(virtual_key(Key::Kp0), Some(0x60)); + assert_eq!(virtual_key(Key::Return), Some(0x0D)); + assert_eq!(virtual_key(Key::KpReturn), Some(0x0D)); +} + +#[test] +fn function_keys_without_a_windows_code_are_rejected() { + assert_eq!(virtual_key(Key::Function), None); +} diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index b6412ed..66955db 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -7,8 +7,10 @@ mod test; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use tinybus::{Connection, Result as TinyBusResult}; +use tinyvoice_bus::{ + HotkeyFeedRequest, HotkeyHandleRequest, HotkeyReadRequest, HotkeyReserveRequest, HotkeyResult, +}; use tinyvoice_bus::{IndexedVadEvent, names}; -use tinyvoice_bus::{HotkeyFeedRequest, HotkeyHandleRequest, HotkeyReadRequest, HotkeyReserveRequest, HotkeyResult}; use tinyvoice::audio::{self, SilenceGateConfig}; use tinyvoice::intent; @@ -589,34 +591,52 @@ impl VoiceService { Ok(BASE64.encode(wav)) } /// Reserve bounded hotkey state before native listener startup. - async fn hotkey_reserve(&self, request: HotkeyReserveRequest) -> TinyBusResult> { + async fn hotkey_reserve( + &self, + request: HotkeyReserveRequest, + ) -> TinyBusResult> { Ok(self.hotkeys.reserve(request)) } /// Start a reserved listener; retries return the same running lease. - async fn hotkey_start(&self, request: HotkeyHandleRequest) -> TinyBusResult> { + async fn hotkey_start( + &self, + request: HotkeyHandleRequest, + ) -> TinyBusResult> { let hotkeys = self.hotkeys.clone(); tokio::task::spawn_blocking(move || hotkeys.start(&request)) - .await.map_err(|_| tinybus::Error::failed("hotkey startup worker failed")) + .await + .map_err(|_| tinybus::Error::failed("hotkey startup worker failed")) } /// Read the retained oldest activation batch until it is acknowledged. - async fn hotkey_read(&self, request: HotkeyReadRequest) -> TinyBusResult> { + async fn hotkey_read( + &self, + request: HotkeyReadRequest, + ) -> TinyBusResult> { Ok(self.hotkeys.read(&request)) } /// Feed generic, bounded host-owned key facts. - async fn hotkey_feed(&self, request: HotkeyFeedRequest) -> TinyBusResult> { + async fn hotkey_feed( + &self, + request: HotkeyFeedRequest, + ) -> TinyBusResult> { Ok(self.hotkeys.feed(&request)) } /// Stop a listener after its owned cleanup completes. - async fn hotkey_stop(&self, request: HotkeyHandleRequest) -> TinyBusResult> { + async fn hotkey_stop( + &self, + request: HotkeyHandleRequest, + ) -> TinyBusResult> { let hotkeys = self.hotkeys.clone(); tokio::task::spawn_blocking(move || hotkeys.stop(&request)) - .await.map_err(|_| tinybus::Error::failed("hotkey cleanup worker failed")) + .await + .map_err(|_| tinybus::Error::failed("hotkey cleanup worker failed")) } /// Close admission and stop every listener before unloading the module. async fn hotkey_shutdown(&self) -> TinyBusResult> { let hotkeys = self.hotkeys.clone(); tokio::task::spawn_blocking(move || hotkeys.shutdown()) - .await.map_err(|_| tinybus::Error::failed("hotkey shutdown worker failed")) + .await + .map_err(|_| tinybus::Error::failed("hotkey shutdown worker failed")) } } diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index 244789c..2f8273c 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -19,8 +19,11 @@ use base64::engine::general_purpose::STANDARD as BASE64; use tinybus::broker::Broker; use tinybus::transport::memory::MemoryBus; use tinybus::{Connection, Interface}; +use tinyvoice_bus::{ + ActivationMode, HostKeyFact, HotkeyFeedRequest, HotkeyHandleRequest, HotkeyReadRequest, + HotkeyRequest, HotkeyReserveRequest, HotkeySource, SequencedHostFact, +}; use tinyvoice_bus::{BUS_NAME, OBJECT_PATH, names}; -use tinyvoice_bus::{ActivationMode, HotkeyFeedRequest, HotkeyHandleRequest, HotkeyReadRequest, HotkeyRequest, HotkeyReserveRequest, HotkeySource, HostKeyFact, SequencedHostFact}; /// A live bus with the interface served on it. /// @@ -901,37 +904,77 @@ async fn capture_shutdown_is_terminal_over_the_bus() -> tinybus::Result<()> { async fn hotkey_contract_replays_events_and_shutdown_is_terminal() -> tinybus::Result<()> { use tinyvoice_bus::{HotkeyError, HotkeyResult}; let proxy = connect().await?; - let reserved: HotkeyResult = proxy.call( - names::methods::HOTKEY_RESERVE, - (HotkeyReserveRequest { request: HotkeyRequest { key: "Fn".into(), mode: ActivationMode::Push, source: HotkeySource::Host } },), - ).await?; + let reserved: HotkeyResult = proxy + .call( + names::methods::HOTKEY_RESERVE, + (HotkeyReserveRequest { + request: HotkeyRequest { + key: "Fn".into(), + mode: ActivationMode::Push, + source: HotkeySource::Host, + }, + },), + ) + .await?; let handle = reserved.unwrap(); - let started: HotkeyResult = proxy.call( - names::methods::HOTKEY_START, - (HotkeyHandleRequest { handle: handle.clone() },), - ).await?; + let started: HotkeyResult = proxy + .call( + names::methods::HOTKEY_START, + (HotkeyHandleRequest { + handle: handle.clone(), + },), + ) + .await?; let generation = started.unwrap().generation.unwrap(); - let fed: HotkeyResult = proxy.call( - names::methods::HOTKEY_FEED, - (HotkeyFeedRequest { handle: handle.clone(), generation, facts: vec![SequencedHostFact { sequence: 1, fact: HostKeyFact::Down }], overflow: false },), - ).await?; + let fed: HotkeyResult = proxy + .call( + names::methods::HOTKEY_FEED, + (HotkeyFeedRequest { + handle: handle.clone(), + generation, + facts: vec![SequencedHostFact { + sequence: 1, + fact: HostKeyFact::Down, + }], + overflow: false, + },), + ) + .await?; assert!(fed.unwrap().active); - let read = HotkeyReadRequest { handle: handle.clone(), acknowledged_batch: None }; - let first: HotkeyResult = proxy.call(names::methods::HOTKEY_READ, (read.clone(),)).await?; + let read = HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }; + let first: HotkeyResult = proxy + .call(names::methods::HOTKEY_READ, (read.clone(),)) + .await?; let first = first.unwrap(); assert_eq!(first.events[0].event, tinyvoice_bus::HotkeyEvent::Pressed); - let replay: HotkeyResult = proxy.call(names::methods::HOTKEY_READ, (read,)).await?; + let replay: HotkeyResult = + proxy.call(names::methods::HOTKEY_READ, (read,)).await?; assert_eq!(replay.unwrap(), first); - let stopped: HotkeyResult = proxy.call( - names::methods::HOTKEY_STOP, (HotkeyHandleRequest { handle },), - ).await?; + let stopped: HotkeyResult = proxy + .call( + names::methods::HOTKEY_STOP, + (HotkeyHandleRequest { handle },), + ) + .await?; assert_eq!(stopped.unwrap().state, tinyvoice_bus::HotkeyState::Stopped); - let shutdown: HotkeyResult = proxy.call(names::methods::HOTKEY_SHUTDOWN, ()).await?; + let shutdown: HotkeyResult = + proxy.call(names::methods::HOTKEY_SHUTDOWN, ()).await?; assert_eq!(shutdown.unwrap().state, tinyvoice_bus::HotkeyState::Stopped); - let after: HotkeyResult = proxy.call( - names::methods::HOTKEY_RESERVE, - (HotkeyReserveRequest { request: HotkeyRequest { key: "Fn".into(), mode: ActivationMode::Tap, source: HotkeySource::Host } },), - ).await?; + let after: HotkeyResult = proxy + .call( + names::methods::HOTKEY_RESERVE, + (HotkeyReserveRequest { + request: HotkeyRequest { + key: "Fn".into(), + mode: ActivationMode::Tap, + source: HotkeySource::Host, + }, + },), + ) + .await?; assert_eq!(after, Err(HotkeyError::Closed)); Ok(()) } From 3fc932b38b74f98cdc297b6b5d26a9401a97280d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 00:11:58 +0300 Subject: [PATCH 12/25] fix(module): satisfy stable clippy for module workspace Co-authored-by: Medulla --- crates/tinyvoice-module/src/service/capture/mod_tests.rs | 2 +- crates/tinyvoice-module/src/service/hotkey/mod_tests.rs | 8 ++++---- .../tinyvoice-module/src/service/hotkey/native/linux.rs | 2 +- crates/tinyvoice-module/src/service/mod.rs | 4 ++++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/tinyvoice-module/src/service/capture/mod_tests.rs b/crates/tinyvoice-module/src/service/capture/mod_tests.rs index d2630f6..a872cba 100644 --- a/crates/tinyvoice-module/src/service/capture/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/capture/mod_tests.rs @@ -395,7 +395,7 @@ fn chunk_poll_preserves_order_and_limits_batch_size() -> Result<(), Box, overflow: &AtomicBool, ) { - let frames = bytes.chunks_exact(32).collect::>(); + let (frames, _) = bytes.as_chunks::<32>(); let mut index = 0; while index < frames.len() { let frame = frames[index]; diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 66955db..d6c0e71 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -179,6 +179,10 @@ fn to_json(value: &T) -> TinyBusResult { clippy::unused_async, reason = "TinyBus interface methods require async signatures" )] +#[expect( + clippy::unused_async_trait_impl, + reason = "TinyBus interface methods must retain async signatures at this dynamic module boundary" +)] #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.Voice")] impl VoiceService { /// Enumerate devices inside the module without opening a recording. From b2e43a6e542ffadc1217b197075ce4ad98fe84ce Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 00:20:53 +0300 Subject: [PATCH 13/25] fix(tinyvoice): harden hotkey replay and startup races Co-authored-by: Medulla --- .../src/service/hotkey/mod.rs | 183 +++++++++++++++--- .../src/service/hotkey/mod_tests.rs | 177 +++++++++++++++++ 2 files changed, 336 insertions(+), 24 deletions(-) diff --git a/crates/tinyvoice-module/src/service/hotkey/mod.rs b/crates/tinyvoice-module/src/service/hotkey/mod.rs index 6b70605..c591f87 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod.rs @@ -1,7 +1,7 @@ //! Owned hotkey leases, activation sequencing, and replayable event batches. use std::collections::{HashMap, VecDeque}; use std::sync::{ - Mutex, + Condvar, Mutex, atomic::{AtomicBool, Ordering}, }; use tinyvoice_bus::{ @@ -17,6 +17,7 @@ const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_secs(60); const MAX_KEY_BYTES: usize = 128; const MAX_HANDLE_BYTES: usize = 32; const MAX_FEED_FACTS: usize = 64; +const MAX_BATCH_EVENTS: usize = 64; const MAX_EVENTS: usize = 256; #[derive(Debug)] @@ -27,7 +28,11 @@ struct Lease { state: HotkeyState, generation: Option, source_sequence: u64, - last_feed: Option<(Vec, bool)>, + last_feed: Option<( + Vec, + bool, + Option, + )>, active: bool, armed: bool, source_down: bool, @@ -44,6 +49,8 @@ struct Lease { pub(super) struct Hotkeys { closed: AtomicBool, next_generation: std::sync::atomic::AtomicU64, + starts_in_progress: Mutex, + start_changed: Condvar, leases: Mutex>, backend: std::sync::Arc, } @@ -59,16 +66,18 @@ impl Hotkeys { Self { closed: AtomicBool::new(false), next_generation: std::sync::atomic::AtomicU64::new(0), + starts_in_progress: Mutex::new(0), + start_changed: Condvar::new(), leases: Mutex::new(HashMap::new()), backend, } } pub(super) fn reserve(&self, request: HotkeyReserveRequest) -> HotkeyResult { let request = request.request; - if request.key.is_empty() - || request.key.len() > MAX_KEY_BYTES - || self.closed.load(Ordering::SeqCst) - { + if request.key.is_empty() || request.key.len() > MAX_KEY_BYTES { + return Err(HotkeyError::InvalidRequest); + } + if self.closed.load(Ordering::SeqCst) { return Err(if self.closed.load(Ordering::SeqCst) { HotkeyError::Closed } else { @@ -85,7 +94,14 @@ impl Hotkeys { } else if request.key != "Fn" && request.key != "fn" { return Err(HotkeyError::InvalidRequest); } + self.reserve_validated(request) + } + + fn reserve_validated(&self, request: HotkeyRequest) -> HotkeyResult { let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + if self.closed.load(Ordering::SeqCst) { + return Err(HotkeyError::Closed); + } leases.retain(|_, lease| match lease.state { HotkeyState::Reserved => lease.created.elapsed() < RESERVATION_TTL, HotkeyState::Stopped => false, @@ -121,6 +137,10 @@ impl Hotkeys { pub(super) fn start(&self, request: &HotkeyHandleRequest) -> HotkeyResult { validate_handle(&request.handle)?; + let mut starts = self + .starts_in_progress + .lock() + .map_err(|_| HotkeyError::Closed)?; let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; if leases.get(&request.handle).is_some_and(|lease| { lease.state == HotkeyState::Reserved && lease.created.elapsed() >= RESERVATION_TTL @@ -145,21 +165,101 @@ impl Hotkeys { .fetch_add(1, Ordering::SeqCst) .checked_add(1) .ok_or(HotkeyError::LimitExceeded)?; + lease.generation = Some(generation); if lease.request.source == HotkeySource::Native { lease.state = HotkeyState::Starting; - match self.backend.start(&lease.request) { - Ok(listener) => lease.native = Some(listener), - Err(error) => { - lease.state = HotkeyState::Reserved; - return Err(error); - } - } + let native_request = lease.request.clone(); + *starts += 1; + drop(leases); + drop(starts); + let result = self.backend.start(&native_request); + return self.finish_native_start(&request.handle, generation, result); } - lease.generation = Some(generation); lease.state = HotkeyState::Running; Ok(status(lease)) } + fn finish_native_start( + &self, + handle: &HotkeyHandle, + generation: u64, + result: Result, + ) -> HotkeyResult { + match result { + Err(error) => { + let mut starts = self + .starts_in_progress + .lock() + .map_err(|_| HotkeyError::Closed)?; + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + let closed = self.closed.load(Ordering::SeqCst); + if let Some(lease) = leases.get_mut(handle) { + if lease.generation == Some(generation) + && lease.state == HotkeyState::Starting + && !closed + { + lease.state = HotkeyState::Reserved; + } else { + lease.state = HotkeyState::Stopped; + } + } + Self::finish_startup(&mut starts, &self.start_changed); + if closed { + Err(HotkeyError::Closed) + } else { + Err(error) + } + } + Ok(mut listener) => { + let mut starts = self + .starts_in_progress + .lock() + .map_err(|_| HotkeyError::Closed)?; + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + let publish = !self.closed.load(Ordering::SeqCst) + && leases.get(handle).is_some_and(|lease| { + lease.generation == Some(generation) && lease.state == HotkeyState::Starting + }); + if publish { + let lease = leases.get_mut(handle).ok_or(HotkeyError::Closed)?; + lease.native = Some(listener); + lease.state = HotkeyState::Running; + let status = status(lease); + Self::finish_startup(&mut starts, &self.start_changed); + return Ok(status); + } + if let Some(lease) = leases.get_mut(handle) { + lease.state = HotkeyState::Stopping; + } + drop(leases); + drop(starts); + + let cleanup = listener.stop(); + let mut starts = self + .starts_in_progress + .lock() + .map_err(|_| HotkeyError::Closed)?; + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + if let Some(lease) = leases.get_mut(handle) { + if cleanup.is_err() { + lease.native = Some(listener); + lease.state = HotkeyState::Stopping; + } else { + lease.state = HotkeyState::Stopped; + } + } + Self::finish_startup(&mut starts, &self.start_changed); + cleanup.map_err(|_| HotkeyError::CleanupFailed)?; + Err(HotkeyError::Closed) + } + } + } + + fn finish_startup(starts: &mut usize, changed: &Condvar) { + *starts = starts.saturating_sub(1); + changed.notify_all(); + } + pub(super) fn feed(&self, request: &HotkeyFeedRequest) -> HotkeyResult { validate_handle(&request.handle)?; if request.facts.len() > MAX_FEED_FACTS { @@ -179,19 +279,21 @@ impl Hotkeys { return Err(HotkeyError::StaleGeneration); } let signature = (request.facts.clone(), request.overflow); - if let Some((prior, prior_overflow)) = &lease.last_feed + if let Some((prior, prior_overflow, prior_error)) = &lease.last_feed && prior == &request.facts && *prior_overflow == request.overflow { + if let Some(error) = prior_error { + return Err(*error); + } return Ok(status(lease)); } if request.overflow { reset(lease); - lease.source_sequence = request - .facts - .last() - .map_or(lease.source_sequence, |fact| fact.sequence); - lease.last_feed = Some(signature); + lease.source_sequence = request.facts.last().map_or(lease.source_sequence, |fact| { + lease.source_sequence.max(fact.sequence) + }); + lease.last_feed = Some((signature.0, signature.1, None)); return Ok(status(lease)); } for fact in &request.facts { @@ -201,13 +303,13 @@ impl Hotkeys { if fact.sequence != lease.source_sequence.saturating_add(1) { reset(lease); lease.source_sequence = fact.sequence; - lease.last_feed = Some(signature); + lease.last_feed = Some((signature.0, signature.1, Some(HotkeyError::SequenceGap))); return Err(HotkeyError::SequenceGap); } lease.source_sequence = fact.sequence; apply_fact(lease, matches!(fact.fact, tinyvoice_bus::HostKeyFact::Down)); } - lease.last_feed = Some(signature); + lease.last_feed = Some((signature.0, signature.1, None)); Ok(status(lease)) } @@ -245,7 +347,8 @@ impl Hotkeys { return Err(HotkeyError::InvalidRequest); } let generation = lease.generation.ok_or(HotkeyError::Closed)?; - let events: Vec<_> = lease.events.drain(..).collect(); + let event_count = lease.events.len().min(MAX_BATCH_EVENTS); + let events: Vec<_> = lease.events.drain(..event_count).collect(); let batch = HotkeyBatch { generation, batch: lease.next_batch, @@ -260,7 +363,25 @@ impl Hotkeys { pub(super) fn stop(&self, request: &HotkeyHandleRequest) -> HotkeyResult { validate_handle(&request.handle)?; + let mut starts = self + .starts_in_progress + .lock() + .map_err(|_| HotkeyError::Closed)?; let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + let lease = leases + .get_mut(&request.handle) + .ok_or(HotkeyError::UnknownHandle)?; + if lease.state == HotkeyState::Starting { + lease.state = HotkeyState::Stopping; + drop(leases); + while *starts > 0 { + starts = self + .start_changed + .wait(starts) + .map_err(|_| HotkeyError::Closed)?; + } + leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + } let lease = leases .get_mut(&request.handle) .ok_or(HotkeyError::UnknownHandle)?; @@ -282,13 +403,27 @@ impl Hotkeys { } pub(super) fn shutdown(&self) -> HotkeyResult { + let mut starts = self + .starts_in_progress + .lock() + .map_err(|_| HotkeyError::Closed)?; self.closed.store(true, Ordering::SeqCst); let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; for lease in leases.values_mut() { - if lease.state == HotkeyState::Running { + if matches!(lease.state, HotkeyState::Running | HotkeyState::Starting) { reset(lease); lease.state = HotkeyState::Stopping; } + } + drop(leases); + while *starts > 0 { + starts = self + .start_changed + .wait(starts) + .map_err(|_| HotkeyError::Closed)?; + } + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + for lease in leases.values_mut() { if let Some(listener) = lease.native.as_mut() { listener.stop()?; lease.native = None; diff --git a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs index 1626746..a0ee8c2 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs @@ -168,6 +168,22 @@ fn host_source_accepts_only_the_function_key_aliases() { ); } +#[test] +fn terminal_hotkey_state_is_rechecked_when_committing_a_reservation() { + let hotkeys = Hotkeys::default(); + hotkeys.closed.store(true, Ordering::SeqCst); + + assert_eq!( + hotkeys.reserve_validated(HotkeyRequest { + key: "Fn".into(), + mode: ActivationMode::Push, + source: HotkeySource::Host, + }), + Err(HotkeyError::Closed) + ); + assert!(hotkeys.leases.lock().unwrap().is_empty()); +} + #[test] fn reservations_validate_keys_and_closed_leases_before_native_start() { let hotkeys = Hotkeys::default(); @@ -358,6 +374,82 @@ fn sequence_gap_resets_push_state_until_a_fresh_release() { assert!(feed(&hotkeys, &handle, generation, &[(6, HostKeyFact::Down)]).active); } +#[test] +fn retrying_a_rejected_sequence_gap_replays_the_error() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]); + let gap = HotkeyFeedRequest { + handle: handle.clone(), + generation, + facts: vec![SequencedHostFact { + sequence: 3, + fact: HostKeyFact::Down, + }], + overflow: false, + }; + + assert_eq!(hotkeys.feed(&gap), Err(HotkeyError::SequenceGap)); + assert_eq!(hotkeys.feed(&gap), Err(HotkeyError::SequenceGap)); +} + +#[test] +fn late_overflow_feed_does_not_rewind_source_sequence() { + let (hotkeys, handle, generation) = started(ActivationMode::Tap); + feed( + &hotkeys, + &handle, + generation, + &[(1, HostKeyFact::Down), (2, HostKeyFact::Up)], + ); + let overflow = HotkeyFeedRequest { + handle: handle.clone(), + generation, + facts: vec![SequencedHostFact { + sequence: 1, + fact: HostKeyFact::Up, + }], + overflow: true, + }; + hotkeys.feed(&overflow).unwrap(); + + assert!(feed(&hotkeys, &handle, generation, &[(3, HostKeyFact::Up)]).continuity_lost); +} + +#[test] +fn replay_batches_are_limited_to_sixty_four_events() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + let facts: Vec<_> = (1..=256) + .map(|sequence| { + ( + sequence, + if sequence % 2 == 1 { + HostKeyFact::Down + } else { + HostKeyFact::Up + }, + ) + }) + .collect(); + for batch in facts.chunks(MAX_FEED_FACTS) { + feed(&hotkeys, &handle, generation, batch); + } + + let first = hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }) + .unwrap(); + assert_eq!(first.events.len(), 64); + let second = hotkeys + .read(&HotkeyReadRequest { + handle, + acknowledged_batch: Some(first.batch), + }) + .unwrap(); + assert_eq!(second.events.len(), 64); +} + #[test] fn acknowledging_prior_batch_after_host_gap_returns_reset_snapshot() { let (hotkeys, handle, generation) = started(ActivationMode::Push); @@ -577,6 +669,91 @@ impl native::Backend for BlockingBackend { } } +#[derive(Debug)] +struct LockProbeBackend { + hotkeys: std::sync::Mutex>, + lock_was_free: std::sync::mpsc::SyncSender, +} + +impl native::Backend for LockProbeBackend { + fn start(&self, _request: &HotkeyRequest) -> Result { + let lock_was_free = self + .hotkeys + .lock() + .unwrap() + .upgrade() + .is_some_and(|hotkeys| hotkeys.leases.try_lock().is_ok()); + let _ = self.lock_was_free.send(lock_was_free); + let (sender, events) = std::sync::mpsc::sync_channel(8); + drop(sender); + Ok(native::NativeListener::new( + events, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + Box::new(EndedReaderOwner), + )) + } +} + +#[test] +fn native_start_does_not_hold_the_lease_lock() { + let (checked_tx, checked_rx) = std::sync::mpsc::sync_channel(1); + let backend = Arc::new(LockProbeBackend { + hotkeys: std::sync::Mutex::new(std::sync::Weak::new()), + lock_was_free: checked_tx, + }); + let hotkeys = Arc::new(Hotkeys::with_backend(backend.clone())); + *backend.hotkeys.lock().unwrap() = Arc::downgrade(&hotkeys); + let handle = hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "ctrl+space".into(), + mode: ActivationMode::Push, + source: HotkeySource::Native, + }, + }) + .unwrap(); + let start_hotkeys = hotkeys.clone(); + let start = std::thread::spawn(move || start_hotkeys.start(&HotkeyHandleRequest { handle })); + + assert!(checked_rx.recv().unwrap()); + assert_eq!(start.join().unwrap().unwrap().state, HotkeyState::Running); +} + +#[test] +fn shutdown_cancels_a_native_start_that_has_not_published() { + let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let starts = Arc::new(AtomicUsize::new(0)); + let hotkeys = Arc::new(Hotkeys::with_backend(Arc::new(BlockingBackend { + entered: entered_tx, + release: std::sync::Mutex::new(release_rx), + starts, + }))); + let handle = hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "ctrl+space".into(), + mode: ActivationMode::Push, + source: HotkeySource::Native, + }, + }) + .unwrap(); + let start_hotkeys = hotkeys.clone(); + let start = std::thread::spawn(move || start_hotkeys.start(&HotkeyHandleRequest { handle })); + entered_rx.recv().unwrap(); + + let shutdown_hotkeys = hotkeys.clone(); + let shutdown = std::thread::spawn(move || shutdown_hotkeys.shutdown()); + while !hotkeys.closed.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + release_tx.send(()).unwrap(); + + assert_eq!(start.join().unwrap(), Err(HotkeyError::Closed)); + shutdown.join().unwrap().unwrap(); +} + #[tokio::test] async fn canceled_start_reply_leaves_running_lease_reachable_for_retry() { let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1); From 649756ccbf90c24780e3f3edb9d153e28deb5983 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 00:28:21 +0300 Subject: [PATCH 14/25] fix(tinyvoice): wait for native hotkey startup retries Co-authored-by: Medulla --- .../src/service/hotkey/mod.rs | 75 ++++++++++--------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/crates/tinyvoice-module/src/service/hotkey/mod.rs b/crates/tinyvoice-module/src/service/hotkey/mod.rs index c591f87..859bed8 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod.rs @@ -141,42 +141,49 @@ impl Hotkeys { .starts_in_progress .lock() .map_err(|_| HotkeyError::Closed)?; - let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; - if leases.get(&request.handle).is_some_and(|lease| { - lease.state == HotkeyState::Reserved && lease.created.elapsed() >= RESERVATION_TTL - }) { - leases.remove(&request.handle); - return Err(HotkeyError::UnknownHandle); - } - let lease = leases - .get_mut(&request.handle) - .ok_or(HotkeyError::UnknownHandle)?; - if lease.state == HotkeyState::Running { + loop { + let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + if leases.get(&request.handle).is_some_and(|lease| { + lease.state == HotkeyState::Reserved && lease.created.elapsed() >= RESERVATION_TTL + }) { + leases.remove(&request.handle); + return Err(HotkeyError::UnknownHandle); + } + let lease = leases + .get_mut(&request.handle) + .ok_or(HotkeyError::UnknownHandle)?; + if lease.state == HotkeyState::Running { + return Ok(status(lease)); + } + if lease.state == HotkeyState::Starting { + drop(leases); + starts = self + .start_changed + .wait(starts) + .map_err(|_| HotkeyError::Closed)?; + continue; + } + if lease.state != HotkeyState::Reserved || self.closed.load(Ordering::SeqCst) { + return Err(HotkeyError::Closed); + } + let generation = self + .next_generation + .fetch_add(1, Ordering::SeqCst) + .checked_add(1) + .ok_or(HotkeyError::LimitExceeded)?; + lease.generation = Some(generation); + if lease.request.source == HotkeySource::Native { + lease.state = HotkeyState::Starting; + let native_request = lease.request.clone(); + *starts += 1; + drop(leases); + drop(starts); + let result = self.backend.start(&native_request); + return self.finish_native_start(&request.handle, generation, result); + } + lease.state = HotkeyState::Running; return Ok(status(lease)); } - if lease.state != HotkeyState::Reserved { - return Err(HotkeyError::Closed); - } - if self.closed.load(Ordering::SeqCst) { - return Err(HotkeyError::Closed); - } - let generation = self - .next_generation - .fetch_add(1, Ordering::SeqCst) - .checked_add(1) - .ok_or(HotkeyError::LimitExceeded)?; - lease.generation = Some(generation); - if lease.request.source == HotkeySource::Native { - lease.state = HotkeyState::Starting; - let native_request = lease.request.clone(); - *starts += 1; - drop(leases); - drop(starts); - let result = self.backend.start(&native_request); - return self.finish_native_start(&request.handle, generation, result); - } - lease.state = HotkeyState::Running; - Ok(status(lease)) } fn finish_native_start( From 294e3482eb2572426155cdc0ea82b4db1da4625c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 00:33:08 +0300 Subject: [PATCH 15/25] test(tinyvoice): clarify tap repeat semantics Co-authored-by: Medulla --- crates/tinyvoice-module/src/service/hotkey/mod_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs index a0ee8c2..cb99e0e 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs @@ -581,7 +581,7 @@ fn native_reader_eof_after_down_returns_inactive_reset_snapshot() { } #[test] -fn tap_mode_repeats_toggle_without_changing_activation_alias_semantics() { +fn tap_mode_ignores_repeated_down_and_toggles_on_fresh_down() { let (hotkeys, handle, generation) = started(ActivationMode::Tap); assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); assert!(feed(&hotkeys, &handle, generation, &[(2, HostKeyFact::Down)]).active); From 82d92dac732e54c124885049e3626045229939ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 00:50:42 +0300 Subject: [PATCH 16/25] fix(tinyvoice): close capture and native hotkey gaps Co-authored-by: Medulla --- .github/workflows/release.yml | 22 ++++++- README.md | 4 +- .../src/service/capture/mod.rs | 13 +++-- .../src/service/capture/mod_tests.rs | 58 +++++++++++++++++++ .../src/service/capture/startup.rs | 19 +++--- .../src/service/hotkey/native/linux.rs | 7 ++- .../src/service/hotkey/native/linux_tests.rs | 23 ++++++++ .../src/service/hotkey/native/windows.rs | 23 +------- crates/tinyvoice/Cargo.toml | 3 +- crates/tinyvoice/src/capture/chunks.rs | 8 ++- crates/tinyvoice/src/capture/chunks_tests.rs | 13 +++++ docs/specs/module-capture.md | 8 ++- 12 files changed, 157 insertions(+), 44 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5b5f1e..026b301 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -293,7 +293,11 @@ jobs: package_root="dist/${package_name}" mkdir -p "$package_root" install -m 755 "$module" "$package_root/" - install -m 644 LICENSE README.md docs/specs/tinybus-module.md "$package_root/" + install -m 644 LICENSE README.md docs/specs/tinybus-module.md \ + docs/specs/module-capture.md docs/specs/hotkey-module-lifecycle.md \ + "$package_root/" + test -s "$package_root/module-capture.md" + test -s "$package_root/hotkey-module-lifecycle.md" module_name="$(basename "$module")" module_hash="$(sha256sum "$package_root/$module_name" | awk '{print $1}')" printf '"%s" = "%s"\n' "$module_name" "$module_hash" \ @@ -315,7 +319,21 @@ jobs: $packageName = "tinyvoice-module-$env:VERSION-$env:BUNDLE_ID" $packageRoot = "dist/$packageName" New-Item -ItemType Directory -Force $packageRoot | Out-Null - Copy-Item -LiteralPath $module, 'LICENSE', 'README.md' -Destination $packageRoot + $packageFiles = @( + $module + 'LICENSE' + 'README.md' + 'docs/specs/tinybus-module.md' + 'docs/specs/module-capture.md' + 'docs/specs/hotkey-module-lifecycle.md' + ) + Copy-Item -LiteralPath $packageFiles -Destination $packageRoot + if (-not (Test-Path -LiteralPath "$packageRoot/module-capture.md" -PathType Leaf)) { + throw 'The package is missing module-capture.md.' + } + if (-not (Test-Path -LiteralPath "$packageRoot/hotkey-module-lifecycle.md" -PathType Leaf)) { + throw 'The package is missing hotkey-module-lifecycle.md.' + } $hash = (Get-FileHash -LiteralPath $module -Algorithm SHA256).Hash.ToLowerInvariant() $moduleName = Split-Path -Leaf $module "`"$moduleName`" = `"$hash`"`n" | diff --git a/README.md b/README.md index bf77d24..aa06e85 100644 --- a/README.md +++ b/README.md @@ -129,5 +129,5 @@ Contract 1.3 adds device enumeration, reserved startup/cancellation, one-shot re handles. The host supplies a microphone permission grant obtained through TinyComputer; the compiled module owns the device, audio preparation, bounded WAV reads, bounded raw chunk polling, cancellation and release. See [capture contract](docs/specs/module-capture.md). -The next module-owned lifecycle is specified in [hotkeys](docs/specs/hotkey-module-lifecycle.md). -Hotkey bus lifecycles remain a separate migration. +Module-owned hotkeys are specified in [hotkey lifecycle](docs/specs/hotkey-module-lifecycle.md); the optional +`tinyvoice::hotkey` listener remains available to library hosts. diff --git a/crates/tinyvoice-module/src/service/capture/mod.rs b/crates/tinyvoice-module/src/service/capture/mod.rs index a87b825..fdbb8ac 100644 --- a/crates/tinyvoice-module/src/service/capture/mod.rs +++ b/crates/tinyvoice-module/src/service/capture/mod.rs @@ -204,14 +204,14 @@ impl Capture { &self, request: RecordingFinishRequest, ) -> CaptureResult { - if !request.gate_threshold.is_finite() || request.gate_threshold < 0.0 { - return Err(CaptureError::InvalidParameters); - } let (recording, busy, canceled) = { let mut finishing = self.finishing.lock().map_err(|_| state_error())?; if self.closed.load(Ordering::SeqCst) { return Err(CaptureError::Closed); } + if !request.gate_threshold.is_finite() || request.gate_threshold < 0.0 { + return Err(CaptureError::InvalidParameters); + } let Lease { recording, busy } = self.take(&request.handle)?; let canceled = Arc::new(AtomicBool::new(false)); finishing.insert(request.handle.clone(), canceled.clone()); @@ -423,7 +423,12 @@ fn prepare(raw: &RawRecording, threshold: f32) -> CaptureResult> { audio::STT_SAMPLE_RATE, ) .map_err(|error| CaptureError::Device(error.to_string()))?; - gate.push(&samples) + let frame_samples = (audio::STT_SAMPLE_RATE as usize / 100).max(1); + let mut gated = Vec::with_capacity(samples.len()); + for frame in samples.chunks(frame_samples) { + gated.extend(gate.push(frame)); + } + gated } else { samples }; diff --git a/crates/tinyvoice-module/src/service/capture/mod_tests.rs b/crates/tinyvoice-module/src/service/capture/mod_tests.rs index a872cba..134f35d 100644 --- a/crates/tinyvoice-module/src/service/capture/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/capture/mod_tests.rs @@ -107,6 +107,40 @@ async fn permission_busy_cancel_and_drop_release_device_ownership() -> CaptureRe Ok(()) } +#[tokio::test] +async fn capture_shutdown_takes_precedence_over_invalid_requests() -> CaptureResult<()> { + let (capture, _) = fixture(false); + let capture = Arc::new(capture); + capture.shutdown().await?; + + assert_eq!( + capture.reserve(MicrophonePermission::Denied), + Err(CaptureError::Closed) + ); + assert!(matches!( + capture + .start_reserved( + RecordingStartRequest { + permission: MicrophonePermission::Denied, + handle: None, + }, + false, + ) + .await, + Err(CaptureError::Closed) + )); + assert_eq!( + capture + .finish(RecordingFinishRequest { + handle: CaptureHandle("unknown".into()), + gate_threshold: f32::NAN, + }) + .await, + Err(CaptureError::Closed) + ); + Ok(()) +} + #[tokio::test] async fn finish_holds_prepared_wav_for_bounded_reads_and_explicit_release() -> CaptureResult<()> { let (capture, _) = fixture(false); @@ -166,6 +200,30 @@ async fn finish_holds_prepared_wav_for_bounded_reads_and_explicit_release() -> C Ok(()) } +#[test] +fn silence_gate_preserves_short_speech_before_a_long_silent_tail() -> CaptureResult<()> { + let mut samples = vec![0.1; 1_000]; + samples.extend(vec![0.0; 16_000]); + let wav = prepare( + &RawRecording { + samples, + source_rate: 16_000, + channels: 1, + }, + 0.03, + )?; + + assert!(wav.len() > 44, "speech must survive silence gating"); + assert!( + wav[44..] + .as_chunks::<2>() + .0 + .iter() + .any(|sample| *sample != [0, 0]) + ); + Ok(()) +} + #[tokio::test] async fn output_capacity_and_setup_faults_do_not_leak_recording_slots() -> CaptureResult<()> { let (capture, _) = fixture(false); diff --git a/crates/tinyvoice-module/src/service/capture/startup.rs b/crates/tinyvoice-module/src/service/capture/startup.rs index 4114d30..1c30d34 100644 --- a/crates/tinyvoice-module/src/service/capture/startup.rs +++ b/crates/tinyvoice-module/src/service/capture/startup.rs @@ -57,13 +57,13 @@ impl Capture { &self, permission: MicrophonePermission, ) -> CaptureResult { - if permission != MicrophonePermission::Granted { - return Err(CaptureError::PermissionDenied); - } let mut reservations = self.reservations.lock().map_err(|_| state_error())?; if self.closed.load(Ordering::SeqCst) { return Err(CaptureError::Closed); } + if permission != MicrophonePermission::Granted { + return Err(CaptureError::PermissionDenied); + } reservations.retain(|_, reservation| { reservation.pending.is_some() || reservation.created.elapsed() < RESERVATION_TTL }); @@ -85,15 +85,16 @@ impl Capture { request: RecordingStartRequest, stream: bool, ) -> CaptureResult { - if request.permission != MicrophonePermission::Granted { - return Err(CaptureError::PermissionDenied); - } - let handle = request.handle.ok_or(CaptureError::InvalidParameters)?; - let (pending, busy) = { + let permission = request.permission; + let (handle, pending, busy) = { let mut reservations = self.reservations.lock().map_err(|_| state_error())?; if self.closed.load(Ordering::SeqCst) { return Err(CaptureError::Closed); } + if permission != MicrophonePermission::Granted { + return Err(CaptureError::PermissionDenied); + } + let handle = request.handle.ok_or(CaptureError::InvalidParameters)?; let reservation = reservations .get_mut(&handle) .ok_or(CaptureError::UnknownHandle)?; @@ -115,7 +116,7 @@ impl Capture { completed, }); reservation.pending = Some(pending.clone()); - (pending, busy) + (handle, pending, busy) }; let capture = self.clone(); let (reply, received) = tokio::sync::oneshot::channel(); diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs index 715cfe0..b7f3a1e 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs @@ -16,6 +16,7 @@ use x11rb::{ rust_connection::RustConnection, }; const CONTROL_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300); +const MAX_RECORD_FRAMES: usize = 256; pub(super) fn start(request: &HotkeyRequest) -> Result { let combo = @@ -180,7 +181,11 @@ fn process_record_bytes( events: &mpsc::SyncSender, overflow: &AtomicBool, ) { - let (frames, _) = bytes.as_chunks::<32>(); + let (frames, remainder) = bytes.as_chunks::<32>(); + if frames.len() > MAX_RECORD_FRAMES || !remainder.is_empty() { + overflow.store(true, Ordering::SeqCst); + return; + } let mut index = 0; while index < frames.len() { let frame = frames[index]; diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs index 71e4a11..fda98ad 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs @@ -233,6 +233,29 @@ fn a_full_native_event_queue_reports_continuity_loss() { assert_eq!(receiver.try_iter().collect::>(), vec![true]); } +#[test] +fn oversized_record_reply_reports_continuity_loss_without_partial_events() { + let combo = tinyvoice::hotkey::parse_hotkey("space").unwrap(); + let bytes: Vec<_> = (0..=MAX_RECORD_FRAMES) + .flat_map(|timestamp| key_event(2, 65, u32::try_from(timestamp).unwrap_or_default())) + .collect(); + let (sender, receiver) = mpsc::sync_channel(8); + let mut down = HashSet::new(); + let overflow = AtomicBool::new(false); + + process_record_bytes( + &bytes, + &combo, + ActivationMode::Push, + &mut down, + &sender, + &overflow, + ); + + assert!(overflow.load(Ordering::SeqCst)); + assert!(receiver.try_iter().next().is_none()); +} + fn key_event(kind: u8, keycode: u8, timestamp: u32) -> [u8; 32] { let mut bytes = [0_u8; 32]; bytes[0] = kind; diff --git a/crates/tinyvoice-module/src/service/hotkey/native/windows.rs b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs index edc2b5b..013e114 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/windows.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs @@ -21,34 +21,18 @@ pub(super) fn start(request: &HotkeyRequest) -> Result>, } impl NativeOwner for WindowsOwner { @@ -56,9 +40,6 @@ impl NativeOwner for WindowsOwner { self.listener .stop() .map_err(|_| HotkeyError::CleanupFailed)?; - if let Some(bridge) = self.bridge.take() { - bridge.join().map_err(|_| HotkeyError::CleanupFailed)?; - } Ok(()) } } diff --git a/crates/tinyvoice/Cargo.toml b/crates/tinyvoice/Cargo.toml index 3991ee9..d0ac607 100644 --- a/crates/tinyvoice/Cargo.toml +++ b/crates/tinyvoice/Cargo.toml @@ -12,7 +12,8 @@ keywords = ["voice", "vad", "speech", "audio", "agent"] categories = ["multimedia::audio"] [dependencies] -# The shared serialized vocabulary used by this crate and its TinyBus module. +# Shared wire payloads live in a transport-free crate; this adds no TinyBus +# runtime or module SDK dependency to the voice library. tinyvoice-bus = { version = "0.1.2", path = "../tinyvoice-bus" } # Derive macros for the crate-wide error type in `src/error/mod.rs`. thiserror = "2" diff --git a/crates/tinyvoice/src/capture/chunks.rs b/crates/tinyvoice/src/capture/chunks.rs index 494316b..b05dd36 100644 --- a/crates/tinyvoice/src/capture/chunks.rs +++ b/crates/tinyvoice/src/capture/chunks.rs @@ -175,7 +175,13 @@ pub(super) fn wait_for_stream_end( errors: &std::sync::mpsc::Receiver, ) -> Result<(), String> { loop { - if stop.load(Ordering::SeqCst) || tx.is_closed() { + if stop.load(Ordering::SeqCst) { + return match errors.try_recv() { + Ok(error) => Err(error), + Err(_) => Ok(()), + }; + } + if tx.is_closed() { return Ok(()); } match errors.recv_timeout(std::time::Duration::from_millis(50)) { diff --git a/crates/tinyvoice/src/capture/chunks_tests.rs b/crates/tinyvoice/src/capture/chunks_tests.rs index c7d990c..4b9e29b 100644 --- a/crates/tinyvoice/src/capture/chunks_tests.rs +++ b/crates/tinyvoice/src/capture/chunks_tests.rs @@ -203,6 +203,19 @@ fn stream_lifetime_surfaces_device_errors_and_accepts_explicit_stop() { assert!(super::wait_for_stream_end(&tx, &stop, &rx).is_ok()); } +#[test] +fn explicit_stop_preserves_a_queued_device_error() { + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (errors, rx) = std::sync::mpsc::channel(); + errors.send("device disconnected".to_string()).unwrap(); + + assert_eq!( + super::wait_for_stream_end(&tx, &std::sync::atomic::AtomicBool::new(true), &rx) + .unwrap_err(), + "device disconnected" + ); +} + #[tokio::test] async fn oversized_native_buffers_are_dropped_before_queueing() { let (tx, mut rx) = tokio::sync::mpsc::channel(2); diff --git a/docs/specs/module-capture.md b/docs/specs/module-capture.md index 21be590..1048d68 100644 --- a/docs/specs/module-capture.md +++ b/docs/specs/module-capture.md @@ -63,14 +63,16 @@ names, error detail, handles, audio, paths and request arguments. Local fixtures exercise lifecycle, bounds, faults and in-memory bus dispatch without hardware or external services. The actual compiled artifact verifier -checks all 26 declared members and permission denial without opening a device. +checks all 32 declared members and permission denial without opening a device. The native device bridge follows the repository's existing physical-device coverage exception; manager and processing code remain subject to the 90% per-file threshold. The release workflow installs ALSA development headers for Linux module builds. -OpenHuman must pin a released artifact compatible with contract 1.3 and verify -its digest before switching callers. Hotkey lifecycle operations remain follow-up work. +OpenHuman must pin a released artifact compatible with current contract 1.4 +(this capture slice was introduced in 1.3) and verify its digest before +switching callers. Module-owned hotkeys are specified in +`hotkey-module-lifecycle.md`. Continuous capture returns an opaque handle and native sample rate/channel format. Native callbacks forward interleaved f32 samples through an eight-chunk From 1ed9acafa44a53ee239186818f4ef1f2665d0f80 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 01:00:15 +0300 Subject: [PATCH 17/25] fix(windows): register hook from its owning module Co-authored-by: Medulla --- crates/tinyvoice-hotkey-win/src/lib.rs | 34 ++++- .../src/lifecycle_tests.rs | 23 ++++ .../src/service/hotkey/native/windows.rs | 127 +++++++++++------- 3 files changed, 134 insertions(+), 50 deletions(-) diff --git a/crates/tinyvoice-hotkey-win/src/lib.rs b/crates/tinyvoice-hotkey-win/src/lib.rs index fe46003..654609c 100644 --- a/crates/tinyvoice-hotkey-win/src/lib.rs +++ b/crates/tinyvoice-hotkey-win/src/lib.rs @@ -16,8 +16,11 @@ use std::sync::{ }; use std::thread::{self, JoinHandle}; use std::time::Duration; -use windows_sys::Win32::Foundation::{LPARAM, LRESULT, WPARAM}; -use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; +use windows_sys::Win32::Foundation::{HMODULE, LPARAM, LRESULT, WPARAM}; +use windows_sys::Win32::System::LibraryLoader::{ + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + GetModuleHandleExW, +}; use windows_sys::Win32::System::Threading::GetCurrentThreadId; use windows_sys::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, GetMessageW, HC_ACTION, KBDLLHOOKSTRUCT, MSG, PM_NOREMOVE, PeekMessageW, @@ -159,7 +162,10 @@ fn owner_loop( PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_NOREMOVE); } let _ = thread_id_sender.send(thread_id); - let module = unsafe { GetModuleHandleW(std::ptr::null()) }; + let Ok(module) = callback_module() else { + let _ = ready.send(Err(HookError)); + return Err(HookError); + }; let hook = unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_callback), module, 0) }; if hook.is_null() { let _ = ready.send(Err(HookError)); @@ -215,6 +221,28 @@ fn owner_loop( } } +fn callback_module() -> Result { + resolve_callback_module(|flags, address, module| unsafe { + GetModuleHandleExW(flags, address, module) + }) +} + +fn resolve_callback_module( + lookup: impl FnOnce(u32, *const u16, *mut HMODULE) -> i32, +) -> Result { + let mut module = std::ptr::null_mut(); + // FROM_ADDRESS makes this pointer an address inside the module, not a + // string. The hook must be attributed to the DLL containing its callback, + // rather than the process executable that GetModuleHandleW(NULL) returns. + let address = keyboard_callback as *const () as *const u16; + let flags = + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT; + if lookup(flags, address, &mut module) == 0 || module.is_null() { + return Err(HookError); + } + Ok(module) +} + unsafe extern "system" fn keyboard_callback(code: i32, message: WPARAM, data: LPARAM) -> LRESULT { if code == HC_ACTION as i32 { // Windows owns KBDLLHOOKSTRUCT for this callback invocation. We only diff --git a/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs b/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs index 812aa8c..307f630 100644 --- a/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs +++ b/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs @@ -2,9 +2,13 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; +use crate::{keyboard_callback, resolve_callback_module}; use std::sync::atomic::AtomicBool; use std::sync::mpsc; use std::time::Duration; +use windows_sys::Win32::System::LibraryLoader::{ + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, +}; #[test] fn unexpected_message_loop_exit_marks_continuity_lost() { @@ -15,6 +19,25 @@ fn unexpected_message_loop_exit_marks_continuity_lost() { assert!(overflow.load(Ordering::SeqCst)); } +#[test] +fn hook_registration_resolves_the_module_containing_its_callback() { + let callback_address = keyboard_callback as *const () as *const u16; + let expected_module = std::ptr::NonNull::::dangling().as_ptr(); + let module = resolve_callback_module(|flags, address, output| { + assert_eq!(address, callback_address); + assert_eq!( + flags, + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT + ); + // SAFETY: the helper provides a valid out pointer and the fixture only + // writes a non-null sentinel HMODULE value to verify the lookup result. + unsafe { *output = expected_module }; + 1 + }); + + assert_eq!(module, Ok(expected_module)); +} + #[test] fn stop_joins_an_owner_that_already_finished_after_unhook() { let mut worker = Some(std::thread::spawn(|| Ok(()))); diff --git a/crates/tinyvoice-module/src/service/hotkey/native/windows.rs b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs index 013e114..0decea2 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/windows.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs @@ -53,39 +53,40 @@ impl Drop for WindowsOwner { } fn virtual_key(key: Key) -> Option { + modifier_virtual_key(key) + .or_else(|| navigation_virtual_key(key)) + .or_else(|| function_virtual_key(key)) + .or_else(|| printable_virtual_key(key)) + .or_else(|| keypad_virtual_key(key)) +} + +fn modifier_virtual_key(key: Key) -> Option { Some(match key { Key::Alt => 0xA4, Key::AltGr => 0xA5, - Key::Backspace => 0x08, - Key::CapsLock => 0x14, Key::ControlLeft => 0xA2, Key::ControlRight => 0xA3, + Key::MetaLeft => 0x5B, + Key::MetaRight => 0x5C, + Key::ShiftLeft => 0xA0, + Key::ShiftRight => 0xA1, + _ => return None, + }) +} + +fn navigation_virtual_key(key: Key) -> Option { + Some(match key { + Key::Backspace => 0x08, + Key::CapsLock => 0x14, Key::Delete => 0x2E, Key::DownArrow => 0x28, Key::End => 0x23, Key::Escape => 0x1B, - Key::F1 => 0x70, - Key::F2 => 0x71, - Key::F3 => 0x72, - Key::F4 => 0x73, - Key::F5 => 0x74, - Key::F6 => 0x75, - Key::F7 => 0x76, - Key::F8 => 0x77, - Key::F9 => 0x78, - Key::F10 => 0x79, - Key::F11 => 0x7A, - Key::F12 => 0x7B, Key::Home => 0x24, Key::LeftArrow => 0x25, - Key::MetaLeft => 0x5B, - Key::MetaRight => 0x5C, Key::PageDown => 0x22, Key::PageUp => 0x21, - Key::Return | Key::KpReturn => 0x0D, Key::RightArrow => 0x27, - Key::ShiftLeft => 0xA0, - Key::ShiftRight => 0xA1, Key::Space => 0x20, Key::Tab => 0x09, Key::UpArrow => 0x26, @@ -93,6 +94,32 @@ fn virtual_key(key: Key) -> Option { Key::ScrollLock => 0x91, Key::Pause => 0x13, Key::NumLock => 0x90, + Key::Insert => 0x2D, + Key::Return => 0x0D, + _ => return None, + }) +} + +fn function_virtual_key(key: Key) -> Option { + Some(match key { + Key::F1 => 0x70, + Key::F2 => 0x71, + Key::F3 => 0x72, + Key::F4 => 0x73, + Key::F5 => 0x74, + Key::F6 => 0x75, + Key::F7 => 0x76, + Key::F8 => 0x77, + Key::F9 => 0x78, + Key::F10 => 0x79, + Key::F11 => 0x7A, + Key::F12 => 0x7B, + _ => return None, + }) +} + +fn printable_virtual_key(key: Key) -> Option { + Some(match key { Key::BackQuote => 0xC0, Key::Num0 => 0x30, Key::Num1 => 0x31, @@ -106,32 +133,32 @@ fn virtual_key(key: Key) -> Option { Key::Num9 => 0x39, Key::Minus => 0xBD, Key::Equal => 0xBB, - Key::KeyA => b'A' as u32, - Key::KeyB => b'B' as u32, - Key::KeyC => b'C' as u32, - Key::KeyD => b'D' as u32, - Key::KeyE => b'E' as u32, - Key::KeyF => b'F' as u32, - Key::KeyG => b'G' as u32, - Key::KeyH => b'H' as u32, - Key::KeyI => b'I' as u32, - Key::KeyJ => b'J' as u32, - Key::KeyK => b'K' as u32, - Key::KeyL => b'L' as u32, - Key::KeyM => b'M' as u32, - Key::KeyN => b'N' as u32, - Key::KeyO => b'O' as u32, - Key::KeyP => b'P' as u32, - Key::KeyQ => b'Q' as u32, - Key::KeyR => b'R' as u32, - Key::KeyS => b'S' as u32, - Key::KeyT => b'T' as u32, - Key::KeyU => b'U' as u32, - Key::KeyV => b'V' as u32, - Key::KeyW => b'W' as u32, - Key::KeyX => b'X' as u32, - Key::KeyY => b'Y' as u32, - Key::KeyZ => b'Z' as u32, + Key::KeyA => u32::from(b'A'), + Key::KeyB => u32::from(b'B'), + Key::KeyC => u32::from(b'C'), + Key::KeyD => u32::from(b'D'), + Key::KeyE => u32::from(b'E'), + Key::KeyF => u32::from(b'F'), + Key::KeyG => u32::from(b'G'), + Key::KeyH => u32::from(b'H'), + Key::KeyI => u32::from(b'I'), + Key::KeyJ => u32::from(b'J'), + Key::KeyK => u32::from(b'K'), + Key::KeyL => u32::from(b'L'), + Key::KeyM => u32::from(b'M'), + Key::KeyN => u32::from(b'N'), + Key::KeyO => u32::from(b'O'), + Key::KeyP => u32::from(b'P'), + Key::KeyQ => u32::from(b'Q'), + Key::KeyR => u32::from(b'R'), + Key::KeyS => u32::from(b'S'), + Key::KeyT => u32::from(b'T'), + Key::KeyU => u32::from(b'U'), + Key::KeyV => u32::from(b'V'), + Key::KeyW => u32::from(b'W'), + Key::KeyX => u32::from(b'X'), + Key::KeyY => u32::from(b'Y'), + Key::KeyZ => u32::from(b'Z'), Key::LeftBracket => 0xDB, Key::RightBracket => 0xDD, Key::SemiColon => 0xBA, @@ -141,7 +168,13 @@ fn virtual_key(key: Key) -> Option { Key::Comma => 0xBC, Key::Dot => 0xBE, Key::Slash => 0xBF, - Key::Insert => 0x2D, + _ => return None, + }) +} + +fn keypad_virtual_key(key: Key) -> Option { + Some(match key { + Key::KpReturn => 0x0D, Key::KpMinus => 0x6D, Key::KpPlus => 0x6B, Key::KpMultiply => 0x6A, @@ -157,7 +190,7 @@ fn virtual_key(key: Key) -> Option { Key::Kp8 => 0x68, Key::Kp9 => 0x69, Key::KpDelete => 0x6E, - Key::Function | Key::Unknown(_) => return None, + _ => return None, }) } From 20b930c4b49a3256e860412032cc4a800dfe3e35 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 01:06:16 +0300 Subject: [PATCH 18/25] fix(tinyvoice): verify platform hotkey lifecycle gates Co-authored-by: Medulla --- .github/workflows/ci.yml | 19 +++++++++++++++++ AGENTS.md | 11 +++++----- .../src/lifecycle_tests.rs | 10 +++------ .../src/service/capture/mod.rs | 2 ++ .../src/service/hotkey/mod.rs | 3 +++ .../src/service/hotkey/native/linux.rs | 21 +++++++++++++++++++ .../src/service/hotkey/native/linux_tests.rs | 11 ++++++++++ docs/specs/module-capture.md | 9 +++++--- 8 files changed, 71 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bb387e..d139d3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,25 @@ jobs: path: coverage.json if-no-files-found: ignore + windows-hotkeys: + name: Windows hotkey ownership + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: true + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Test module hotkey lifecycle on Windows + run: cargo test --locked --manifest-path crates/tinyvoice-module/Cargo.toml --all-features + + - name: Test native Windows hook owner + run: cargo test --locked --manifest-path crates/tinyvoice-hotkey-win/Cargo.toml + docs: name: Docs runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index ec28954..a50da82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,11 +12,12 @@ which decides where the *next* piece of code goes: > **A crate owns what is identical for every host; the host owns what depends > on its own runtime, config, or threat model.** -`tinyvoice` keeps its pure primitives synchronous and runtime-free. Native -capture and hotkeys execute inside the separately compiled module. -The library retains optional reusable native primitives; hosts use only the -minimal bus contract. Credentials, permission decisions (through the computer -module), product configuration and STT/TTS orchestration stay with the host. +`tinyvoice` keeps its pure primitives synchronous and runtime-free. The module +owns native capture and its stoppable hotkey lifecycle. The library retains an +optional reusable native listener API for standalone hosts; hosts that need +module-managed lifetimes use the minimal bus contract. Credentials, permission +decisions (through the computer module), product configuration and STT/TTS +orchestration stay with the host. ## Project Structure diff --git a/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs b/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs index 307f630..37979cb 100644 --- a/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs +++ b/crates/tinyvoice-hotkey-win/src/lifecycle_tests.rs @@ -5,7 +5,6 @@ use super::*; use crate::{keyboard_callback, resolve_callback_module}; use std::sync::atomic::AtomicBool; use std::sync::mpsc; -use std::time::Duration; use windows_sys::Win32::System::LibraryLoader::{ GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, }; @@ -75,8 +74,7 @@ fn failed_unhook_reply_retains_worker_until_retry_completes_cleanup() { ); release_tx.send(()).unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(1); - while !worker.as_ref().unwrap().is_finished() && std::time::Instant::now() < deadline { + while !worker.as_ref().unwrap().is_finished() { std::thread::yield_now(); } assert_eq!( @@ -107,8 +105,7 @@ fn failed_stop_post_retains_live_owner() { assert!(worker.is_some()); release_tx.send(()).unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(1); - while !worker.as_ref().unwrap().is_finished() && std::time::Instant::now() < deadline { + while !worker.as_ref().unwrap().is_finished() { std::thread::yield_now(); } assert_eq!( @@ -160,8 +157,7 @@ fn reply_timeout_retains_live_owner_until_later_join() { assert!(worker.is_some()); release_tx.send(()).unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(1); - while !worker.as_ref().unwrap().is_finished() && std::time::Instant::now() < deadline { + while !worker.as_ref().unwrap().is_finished() { std::thread::yield_now(); } assert_eq!( diff --git a/crates/tinyvoice-module/src/service/capture/mod.rs b/crates/tinyvoice-module/src/service/capture/mod.rs index fdbb8ac..2631137 100644 --- a/crates/tinyvoice-module/src/service/capture/mod.rs +++ b/crates/tinyvoice-module/src/service/capture/mod.rs @@ -99,6 +99,8 @@ impl Capture { streams: Mutex::new(HashMap::new()), } } + /// Permanently close capture admission, discard outputs, and await owned + /// native cleanup and pending startup completion. pub(super) async fn shutdown(self: &Arc) -> CaptureResult<()> { // Freeze publication under the same lock used by startup workers. let pending = { diff --git a/crates/tinyvoice-module/src/service/hotkey/mod.rs b/crates/tinyvoice-module/src/service/hotkey/mod.rs index 859bed8..79d5bbb 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod.rs @@ -135,6 +135,7 @@ impl Hotkeys { Ok(handle) } + /// Start a reserved listener; failed setup leaves no running lease. pub(super) fn start(&self, request: &HotkeyHandleRequest) -> HotkeyResult { validate_handle(&request.handle)?; let mut starts = self @@ -409,6 +410,8 @@ impl Hotkeys { Ok(status) } + /// Close admission and stop listeners; cleanup errors retain their owners + /// so a later shutdown call can retry them. pub(super) fn shutdown(&self) -> HotkeyResult { let mut starts = self .starts_in_progress diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs index b7f3a1e..295a196 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs @@ -61,6 +61,10 @@ pub(super) fn start(request: &HotkeyRequest) -> Result Result( + first: Option>, + category: impl FnOnce(&T) -> u8, +) -> Result { + // X RECORD reports StartOfData only after the server accepted the enable + // request. Do not publish a running lease on a failed, ended, or unexpected + // first reply. + let Some(Ok(reply)) = first else { + return Err(()); + }; + if category(&reply) == 4 { + Ok(reply) + } else { + Err(()) + } +} + fn create_record_context(control: &RustConnection, context: u32) -> Result<(), HotkeyError> { let empty = record::Range8 { first: 0, last: 0 }; let range = record::Range { diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs index fda98ad..f571e89 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs @@ -22,6 +22,17 @@ fn local_xrecord_listener_cancels_blocked_reader_and_joins() { .expect("X RECORD stop disables context and joins reader"); } +#[test] +fn record_listener_rejects_failed_ended_and_unexpected_start_replies() { + assert!(take_start_reply::(None, |reply| *reply).is_err()); + assert!(take_start_reply(Some(Err(())), |reply: &u8| *reply).is_err()); + assert!(take_start_reply(Some(Ok::(0)), |reply| *reply).is_err()); + assert_eq!( + take_start_reply(Some(Ok::(4)), |reply| *reply), + Ok(4) + ); +} + #[test] fn autorepeat_release_press_pair_does_not_end_or_retoggle_activation() { let combo = tinyvoice::hotkey::parse_hotkey("space").expect("known key"); diff --git a/docs/specs/module-capture.md b/docs/specs/module-capture.md index 1048d68..e6922e3 100644 --- a/docs/specs/module-capture.md +++ b/docs/specs/module-capture.md @@ -1,7 +1,7 @@ # Module-owned native capture contract -TinyVoice contract 1.3 adds eleven members while preserving every existing method's -arity and wire format: +The capture slice introduced in TinyVoice contract 1.3 adds eleven members +while preserving every existing method's arity and wire format: | Member | One argument | Result | | --- | --- | --- | @@ -42,7 +42,10 @@ request future does not release its BusyGuard while cleanup/preparation is pendi Workers retain ownership through completion even if a request future disappears. CaptureShutdown closes this capture instance permanently, marks pending startup and preparation canceled, discards outputs, drains live resources and awaits all -native cleanup and worker completion. Reserve/start/finish then return Closed. +native cleanup and worker completion. Reserve, RecordingStart, CaptureStart, +and RecordingFinish then return Closed. Existing handle operations return +UnknownHandle after shutdown has drained their handles; repeated CaptureShutdown +is successful. The host must invoke CaptureShutdown before the generic TinyBus ABI shutdown: its runtime.shutdown_timeout alone does not await blocked native workers. Sign-out uses per-handle cancel/stop/release so the process module remains usable. Dropping From 887dc4a3b9d249a3dacbb5c7130044c9a2434030 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 01:11:02 +0300 Subject: [PATCH 19/25] fix(windows): reap failed hook cleanup off the drop path Co-authored-by: Medulla --- crates/tinyvoice-hotkey-win/src/lib.rs | 103 +++++++++++++++--- crates/tinyvoice-hotkey-win/src/lib_tests.rs | 90 +++++++++++++++ .../src/service/hotkey/native/windows.rs | 8 -- 3 files changed, 176 insertions(+), 25 deletions(-) create mode 100644 crates/tinyvoice-hotkey-win/src/lib_tests.rs diff --git a/crates/tinyvoice-hotkey-win/src/lib.rs b/crates/tinyvoice-hotkey-win/src/lib.rs index 654609c..b881c74 100644 --- a/crates/tinyvoice-hotkey-win/src/lib.rs +++ b/crates/tinyvoice-hotkey-win/src/lib.rs @@ -5,6 +5,9 @@ //! installing thread's message loop, reads the Windows-owned event pointer only //! during the callback, and forwards bounded facts with `try_send`. Stop is a //! message to that owner thread; success follows `UnhookWindowsHookEx` and join. +//! A per-listener process-owned reaper retains the owner after final Drop when +//! cleanup fails. TinyBus intentionally keeps mapped module images loaded until +//! process exit, so the callback and reaper code remain mapped during cleanup. #![allow(unsafe_code)] @@ -32,6 +35,7 @@ mod lifecycle; const STOP_MESSAGE: u32 = WM_APP + 0x41; const STOP_REPLY_TIMEOUT: Duration = Duration::from_secs(1); +const REAPER_RETRY_DELAY: Duration = Duration::from_millis(50); /// Physical Windows virtual-key chord in the existing TinyVoice key vocabulary. #[derive(Debug, Clone)] @@ -49,15 +53,73 @@ pub struct Chord { pub struct HookError; /// An owned hook thread and its bounded activation fact receiver. +/// +/// Explicit stop returns an error while retaining the owner for retry. Final +/// drop transfers failed cleanup to a process-owned reaper instead of blocking +/// the caller or detaching the Windows hook thread. #[derive(Debug)] pub struct Listener { events: Option>, overflow: Arc, + owner: Option>, + reaper: Reaper, +} + +#[derive(Debug)] +struct Reaper { + sender: mpsc::Sender>, + _worker: JoinHandle<()>, +} + +trait HookOwner: std::fmt::Debug + Send { + fn stop(&mut self) -> Result<(), HookError>; +} + +#[derive(Debug)] +struct WindowsOwner { thread_id: u32, worker: Option>>, replies: mpsc::Receiver>, } +impl HookOwner for WindowsOwner { + fn stop(&mut self) -> Result<(), HookError> { + lifecycle::stop_worker( + &mut self.worker, + || unsafe { PostThreadMessageW(self.thread_id, STOP_MESSAGE, 0, 0) != 0 }, + || { + self.replies + .recv_timeout(STOP_REPLY_TIMEOUT) + .map_err(|_| ()) + }, + ) + } +} + +fn spawn_reaper() -> Result { + let (sender, receiver) = mpsc::channel::>(); + let worker = thread::Builder::new() + .name("tinyvoice-hotkey-reaper".into()) + .spawn(move || { + while let Ok(mut owner) = receiver.recv() { + loop { + let stopped = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| owner.stop())) + .is_ok_and(|result| result.is_ok()); + if stopped { + break; + } + thread::sleep(REAPER_RETRY_DELAY); + } + } + }) + .map_err(|_| HookError)?; + Ok(Reaper { + sender, + _worker: worker, + }) +} + impl Listener { /// Receives the bounded activation facts observed by the hook callback. pub fn take_events(&mut self) -> Option> { @@ -70,25 +132,22 @@ impl Listener { /// Unhooks on the owner thread and joins it before returning success. pub fn stop(&mut self) -> Result<(), HookError> { - lifecycle::stop_worker( - &mut self.worker, - || unsafe { PostThreadMessageW(self.thread_id, STOP_MESSAGE, 0, 0) != 0 }, - || { - self.replies - .recv_timeout(STOP_REPLY_TIMEOUT) - .map_err(|_| ()) - }, - ) + self.owner.as_mut().map_or(Ok(()), |owner| owner.stop()) } } impl Drop for Listener { fn drop(&mut self) { - // If the owner is being destroyed, keep its code mapped until Windows - // confirms unregistration; an unbounded wait is safer than detaching a - // callback into an unloadable module. - while self.worker.is_some() && self.stop().is_err() { - thread::sleep(Duration::from_millis(50)); + if let Some(owner) = self.owner.take() + && let Err(error) = self.reaper.sender.send(owner) + { + // The per-listener reaper is created before hook installation, so + // this is only a defensive fallback for an unexpectedly lost + // reaper thread. Preserve the owner and never silently detach it. + let mut owner = error.0; + while owner.stop().is_err() { + thread::sleep(REAPER_RETRY_DELAY); + } } } } @@ -105,6 +164,9 @@ thread_local! { static CALLBACK: RefCell> = const { RefCel /// Start a dedicated owner thread, wait for hook registration, and return its lease. pub fn start(chord: Chord) -> Result { + // Establish an owned cleanup path before installing a callback that could + // outlive the caller if synchronous cleanup later fails. + let reaper = spawn_reaper()?; let (events_tx, events) = mpsc::sync_channel(256); let (ready_tx, ready_rx) = mpsc::sync_channel(1); let (reply_tx, replies) = mpsc::sync_channel(4); @@ -141,9 +203,12 @@ pub fn start(chord: Chord) -> Result { Ok(Listener { events: Some(events), overflow, - thread_id, - worker: Some(worker), - replies, + owner: Some(Box::new(WindowsOwner { + thread_id, + worker: Some(worker), + replies, + })), + reaper, }) } @@ -221,6 +286,10 @@ fn owner_loop( } } +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; + fn callback_module() -> Result { resolve_callback_module(|flags, address, module| unsafe { GetModuleHandleExW(flags, address, module) diff --git a/crates/tinyvoice-hotkey-win/src/lib_tests.rs b/crates/tinyvoice-hotkey-win/src/lib_tests.rs new file mode 100644 index 0000000..da68149 --- /dev/null +++ b/crates/tinyvoice-hotkey-win/src/lib_tests.rs @@ -0,0 +1,90 @@ +//! Owner cleanup fixtures for the Windows hook boundary. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use std::sync::atomic::AtomicUsize; + +#[derive(Debug, PartialEq, Eq)] +enum CleanupEvent { + Failed, + Stopped, + Dropped, +} + +#[derive(Debug)] +struct RetryOwner { + attempts: usize, + allow_cleanup: Arc, + worker: Option>, + events: mpsc::Sender, + successful_cleanups: Arc, + joins: Arc, + drops: Arc, +} + +impl HookOwner for RetryOwner { + fn stop(&mut self) -> Result<(), HookError> { + self.attempts += 1; + if self.attempts < 3 || !self.allow_cleanup.load(Ordering::SeqCst) { + let _ = self.events.send(CleanupEvent::Failed); + return Err(HookError); + } + if let Some(worker) = self.worker.take() { + worker.join().map_err(|_| HookError)?; + self.joins.fetch_add(1, Ordering::SeqCst); + } + self.successful_cleanups.fetch_add(1, Ordering::SeqCst); + let _ = self.events.send(CleanupEvent::Stopped); + Ok(()) + } +} + +impl Drop for RetryOwner { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + let _ = self.events.send(CleanupEvent::Dropped); + } +} + +#[test] +fn drop_transfers_persistently_failing_owner_to_retry_worker() { + let reaper = spawn_reaper().unwrap(); + let (events_tx, events_rx) = mpsc::channel(); + let allow_cleanup = Arc::new(AtomicBool::new(false)); + let joins = Arc::new(AtomicUsize::new(0)); + let successful_cleanups = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let (release_worker_tx, release_worker_rx) = mpsc::channel(); + let worker = thread::spawn(move || release_worker_rx.recv().unwrap()); + let listener = Listener { + events: None, + overflow: Arc::new(AtomicBool::new(false)), + owner: Some(Box::new(RetryOwner { + attempts: 0, + allow_cleanup: allow_cleanup.clone(), + worker: Some(worker), + events: events_tx, + successful_cleanups: successful_cleanups.clone(), + joins: joins.clone(), + drops: drops.clone(), + })), + reaper, + }; + + // Drop returns even though the owner keeps failing cleanup. Ownership is + // now held by the worker until the fixture permits successful cleanup. + drop(listener); + assert_eq!(events_rx.recv().unwrap(), CleanupEvent::Failed); + assert_eq!(events_rx.recv().unwrap(), CleanupEvent::Failed); + assert_eq!(successful_cleanups.load(Ordering::SeqCst), 0); + assert_eq!(joins.load(Ordering::SeqCst), 0); + assert_eq!(drops.load(Ordering::SeqCst), 0); + + release_worker_tx.send(()).unwrap(); + allow_cleanup.store(true, Ordering::SeqCst); + assert_eq!(events_rx.recv().unwrap(), CleanupEvent::Stopped); + assert_eq!(events_rx.recv().unwrap(), CleanupEvent::Dropped); + assert_eq!(successful_cleanups.load(Ordering::SeqCst), 1); + assert_eq!(joins.load(Ordering::SeqCst), 1); + assert_eq!(drops.load(Ordering::SeqCst), 1); +} diff --git a/crates/tinyvoice-module/src/service/hotkey/native/windows.rs b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs index 0decea2..b7db249 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/windows.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/windows.rs @@ -44,14 +44,6 @@ impl NativeOwner for WindowsOwner { } } -impl Drop for WindowsOwner { - fn drop(&mut self) { - while self.stop().is_err() { - std::thread::sleep(std::time::Duration::from_millis(50)); - } - } -} - fn virtual_key(key: Key) -> Option { modifier_virtual_key(key) .or_else(|| navigation_virtual_key(key)) From 77bd00bfc32bededb03192280f7340970a879cc4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 03:57:03 +0300 Subject: [PATCH 20/25] fix: preserve hotkey feed continuity and shutdown cleanup Co-authored-by: Medulla --- .../src/service/hotkey/mod.rs | 23 ++- .../src/service/hotkey/mod_tests.rs | 138 ++++++++++++++++++ 2 files changed, 155 insertions(+), 6 deletions(-) diff --git a/crates/tinyvoice-module/src/service/hotkey/mod.rs b/crates/tinyvoice-module/src/service/hotkey/mod.rs index 79d5bbb..70c721f 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod.rs @@ -304,19 +304,22 @@ impl Hotkeys { lease.last_feed = Some((signature.0, signature.1, None)); return Ok(status(lease)); } + let mut gap = false; for fact in &request.facts { if fact.sequence <= lease.source_sequence { - return Err(HotkeyError::SequenceGap); + continue; } - if fact.sequence != lease.source_sequence.saturating_add(1) { + if !gap && fact.sequence != lease.source_sequence.saturating_add(1) { reset(lease); - lease.source_sequence = fact.sequence; - lease.last_feed = Some((signature.0, signature.1, Some(HotkeyError::SequenceGap))); - return Err(HotkeyError::SequenceGap); + gap = true; } lease.source_sequence = fact.sequence; apply_fact(lease, matches!(fact.fact, tinyvoice_bus::HostKeyFact::Down)); } + if gap { + lease.last_feed = Some((signature.0, signature.1, Some(HotkeyError::SequenceGap))); + return Err(HotkeyError::SequenceGap); + } lease.last_feed = Some((signature.0, signature.1, None)); Ok(status(lease)) } @@ -433,13 +436,21 @@ impl Hotkeys { .map_err(|_| HotkeyError::Closed)?; } let mut leases = self.leases.lock().map_err(|_| HotkeyError::Closed)?; + let mut cleanup_failed = false; for lease in leases.values_mut() { if let Some(listener) = lease.native.as_mut() { - listener.stop()?; + if listener.stop().is_err() { + cleanup_failed = true; + continue; + } lease.native = None; } lease.state = HotkeyState::Stopped; } + if cleanup_failed { + leases.retain(|_, lease| lease.native.is_some()); + return Err(HotkeyError::CleanupFailed); + } leases.clear(); Ok(HotkeyStatus { state: HotkeyState::Stopped, diff --git a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs index cb99e0e..47a9337 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs @@ -1,3 +1,5 @@ +//! Tests for hotkey lease state and cleanup behavior. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; @@ -374,6 +376,72 @@ fn sequence_gap_resets_push_state_until_a_fresh_release() { assert!(feed(&hotkeys, &handle, generation, &[(6, HostKeyFact::Down)]).active); } +#[test] +fn a_sequence_gap_applies_the_rest_of_the_batch_and_resynchronizes() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); + let gap = HotkeyFeedRequest { + handle: handle.clone(), + generation, + facts: vec![ + SequencedHostFact { + sequence: 3, + fact: HostKeyFact::Down, + }, + SequencedHostFact { + sequence: 4, + fact: HostKeyFact::Up, + }, + SequencedHostFact { + sequence: 5, + fact: HostKeyFact::Down, + }, + ], + overflow: false, + }; + + assert_eq!(hotkeys.feed(&gap), Err(HotkeyError::SequenceGap)); + let after_gap = hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }) + .unwrap(); + assert!(after_gap.reset); + assert!(after_gap.active, "the batch's fresh Up and Down are applied"); + assert_eq!(after_gap.events.len(), 1); + assert_eq!(after_gap.events[0].event, HotkeyEvent::Pressed); + + assert!( + !feed(&hotkeys, &handle, generation, &[(6, HostKeyFact::Up)]).active, + "the next batch continues from the last fact in the gapped batch" + ); +} + +#[test] +fn a_partially_overlapping_batch_applies_its_unseen_tail() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); + + let overlap = HotkeyFeedRequest { + handle, + generation, + facts: vec![ + SequencedHostFact { + sequence: 1, + fact: HostKeyFact::Down, + }, + SequencedHostFact { + sequence: 2, + fact: HostKeyFact::Up, + }, + ], + overflow: false, + }; + let status = hotkeys.feed(&overlap).expect("new tail should be applied"); + assert!(!status.active); +} + #[test] fn retrying_a_rejected_sequence_gap_replays_the_error() { let (hotkeys, handle, generation) = started(ActivationMode::Push); @@ -843,3 +911,73 @@ fn failed_native_cleanup_keeps_retryable_ownership_and_never_acknowledges_stop() }) ); } + +#[test] +fn shutdown_stops_every_listener_and_retains_only_failed_cleanup_for_retry() { + let stop_calls = Arc::new(AtomicUsize::new(0)); + let hotkeys = Hotkeys::with_backend(Arc::new(ShutdownBackend(stop_calls.clone()))); + let handles: Vec<_> = (0..2) + .map(|_| { + hotkeys + .reserve(HotkeyReserveRequest { + request: HotkeyRequest { + key: "ctrl+space".into(), + mode: ActivationMode::Push, + source: HotkeySource::Native, + }, + }) + .unwrap() + }) + .collect(); + for handle in &handles { + hotkeys + .start(&HotkeyHandleRequest { + handle: handle.clone(), + }) + .unwrap(); + } + + assert_eq!(hotkeys.shutdown(), Err(HotkeyError::CleanupFailed)); + assert_eq!(stop_calls.load(Ordering::SeqCst), 2); + + assert_eq!(hotkeys.shutdown().unwrap().state, HotkeyState::Stopped); + assert_eq!(stop_calls.load(Ordering::SeqCst), 3); +} + +#[derive(Debug)] +struct ShutdownBackend(Arc); + +impl native::Backend for ShutdownBackend { + fn start(&self, _request: &HotkeyRequest) -> Result { + let (sender, events) = std::sync::mpsc::sync_channel(1); + drop(sender); + Ok(native::NativeListener::new( + events, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + Box::new(ShutdownOwner { + stop_calls: self.0.clone(), + stopped: false, + }), + )) + } +} + +#[derive(Debug)] +struct ShutdownOwner { + stop_calls: Arc, + stopped: bool, +} + +impl native::NativeOwner for ShutdownOwner { + fn stop(&mut self) -> Result<(), HotkeyError> { + if self.stopped { + return Ok(()); + } + if self.stop_calls.fetch_add(1, Ordering::SeqCst) == 0 { + return Err(HotkeyError::CleanupFailed); + } + self.stopped = true; + Ok(()) + } +} From d64ed33d0535091e6f8d7667f200fd72ade5922e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 03:57:05 +0300 Subject: [PATCH 21/25] fix: clean up lost XRecord contexts and map navigation keys Co-authored-by: Medulla --- .../src/service/hotkey/native/linux.rs | 72 ++++++++++++++----- .../src/service/hotkey/native/linux_tests.rs | 65 +++++++++++++++++ 2 files changed, 120 insertions(+), 17 deletions(-) diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs index 295a196..67b7d5b 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs @@ -87,7 +87,7 @@ pub(super) fn start(request: &HotkeyRequest) -> Result Result Result<(), H #[derive(Debug)] pub(super) struct XRecordOwner { - control: x11rb::rust_connection::RustConnection, + control: Box, context: Option, disable_sent: bool, wake: rustix::fd::OwnedFd, worker: Option>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecordCleanupError { + ConnectionLost, + Protocol, +} + +trait RecordControl: std::fmt::Debug + Send { + fn disable_context(&self, context: u32) -> Result<(), RecordCleanupError>; + fn free_context(&self, context: u32) -> Result<(), RecordCleanupError>; +} + +impl RecordControl for RustConnection { + fn disable_context(&self, context: u32) -> Result<(), RecordCleanupError> { + self.record_disable_context(context) + .map_err(|_| RecordCleanupError::ConnectionLost)? + .check() + .map_err(|error| classify_reply_error(&error)) + } + + fn free_context(&self, context: u32) -> Result<(), RecordCleanupError> { + self.record_free_context(context) + .map_err(|_| RecordCleanupError::ConnectionLost)? + .check() + .map_err(|error| classify_reply_error(&error)) + } +} + +fn classify_reply_error(error: &x11rb::errors::ReplyError) -> RecordCleanupError { + match error { + x11rb::errors::ReplyError::ConnectionError(_) => RecordCleanupError::ConnectionLost, + x11rb::errors::ReplyError::X11Error(_) => RecordCleanupError::Protocol, + } +} + impl NativeOwner for XRecordOwner { fn stop(&mut self) -> Result<(), HotkeyError> { let disable_result = if let Some(context) = self.context.filter(|_| !self.disable_sent) { - match self.control.record_disable_context(context) { - Ok(cookie) => { - self.disable_sent = true; - cookie.check().map_err(|_| HotkeyError::CleanupFailed) - } - Err(_) => Err(HotkeyError::CleanupFailed), - } + self.disable_sent = true; + self.control.disable_context(context) } else { Ok(()) }; @@ -181,14 +210,19 @@ impl NativeOwner for XRecordOwner { if let Some(worker) = self.worker.take() { worker.join().map_err(|_| HotkeyError::CleanupFailed)?; } - disable_result?; + match disable_result { + Ok(()) => {} + Err(RecordCleanupError::ConnectionLost) => { + self.context = None; + return Ok(()); + } + Err(RecordCleanupError::Protocol) => return Err(HotkeyError::CleanupFailed), + } if let Some(context) = self.context { - self.control - .record_free_context(context) - .map_err(|_| HotkeyError::CleanupFailed)? - .check() - .map_err(|_| HotkeyError::CleanupFailed)?; - self.context = None; + match self.control.free_context(context) { + Ok(()) | Err(RecordCleanupError::ConnectionLost) => self.context = None, + Err(RecordCleanupError::Protocol) => return Err(HotkeyError::CleanupFailed), + } } Ok(()) } @@ -298,11 +332,15 @@ fn navigation_key(code: u32) -> Option { 115 => Key::End, 9 => Key::Escape, 110 => Key::Home, + 77 => Key::NumLock, + 127 => Key::Pause, + 107 => Key::PrintScreen, 113 => Key::LeftArrow, 117 => Key::PageDown, 112 => Key::PageUp, 36 => Key::Return, 114 => Key::RightArrow, + 78 => Key::ScrollLock, 65 => Key::Space, 23 => Key::Tab, 111 => Key::UpArrow, diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs index f571e89..53430b4 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs @@ -1,3 +1,5 @@ +//! Tests for X11 key mapping and listener cleanup. + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; @@ -22,6 +24,65 @@ fn local_xrecord_listener_cancels_blocked_reader_and_joins() { .expect("X RECORD stop disables context and joins reader"); } +#[test] +fn xrecord_stop_releases_context_after_control_connection_loss() { + let mut owner = owner_with(FakeRecordControl { + disable: Err(RecordCleanupError::ConnectionLost), + free: Ok(()), + }); + + assert_eq!(owner.stop(), Ok(())); + assert_eq!(owner.context, None); +} + +#[test] +fn xrecord_stop_keeps_context_on_protocol_error() { + let mut owner = owner_with(FakeRecordControl { + disable: Err(RecordCleanupError::Protocol), + free: Ok(()), + }); + + assert_eq!(owner.stop(), Err(HotkeyError::CleanupFailed)); + assert_eq!(owner.context, Some(1)); +} + +#[test] +fn xrecord_stop_releases_context_if_free_finds_connection_lost() { + let mut owner = owner_with(FakeRecordControl { + disable: Ok(()), + free: Err(RecordCleanupError::ConnectionLost), + }); + + assert_eq!(owner.stop(), Ok(())); + assert_eq!(owner.context, None); +} + +#[derive(Debug)] +struct FakeRecordControl { + disable: Result<(), RecordCleanupError>, + free: Result<(), RecordCleanupError>, +} + +impl RecordControl for FakeRecordControl { + fn disable_context(&self, _context: u32) -> Result<(), RecordCleanupError> { + self.disable + } + + fn free_context(&self, _context: u32) -> Result<(), RecordCleanupError> { + self.free + } +} + +fn owner_with(control: FakeRecordControl) -> XRecordOwner { + XRecordOwner { + control: Box::new(control), + context: Some(1), + disable_sent: false, + wake: std::fs::File::open("/dev/null").unwrap().into(), + worker: None, + } +} + #[test] fn record_listener_rejects_failed_ended_and_unexpected_start_replies() { assert!(take_start_reply::(None, |reply| *reply).is_err()); @@ -71,6 +132,10 @@ fn physical_x_keycodes_keep_the_existing_key_vocabulary() { (105, Key::ControlRight), (119, Key::Delete), (116, Key::DownArrow), + (127, Key::Pause), + (107, Key::PrintScreen), + (77, Key::NumLock), + (78, Key::ScrollLock), (115, Key::End), (9, Key::Escape), (67, Key::F1), From 5b98a8b98b0d4f418e11024db216f2e236fa0f1d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 03:57:08 +0300 Subject: [PATCH 22/25] docs: list TinyVoice hotkey bus methods Co-authored-by: Medulla --- MODULE.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/MODULE.md b/MODULE.md index 0841808..a908b4d 100644 --- a/MODULE.md +++ b/MODULE.md @@ -4,7 +4,7 @@ This package contains the native `tinyvoice` module for TinyBus module ABI v1. Install only the archive matching the host operating system and architecture. The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at -`/ai/tinyhumans/tinyvoice/Voice`, and provides 26 methods under contract 1.3. +`/ai/tinyhumans/tinyvoice/Voice`, and provides 32 methods under contract 1.4. ## Methods @@ -36,6 +36,12 @@ The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at | `CaptureStart` | permission and reserved handle | opaque stream handle and native format | | `CapturePoll` | handle, max_chunks in one request | ordered raw chunks and closed flag | | `CaptureStop` | handle | structured result after native thread shutdown | +| `HotkeyReserve` | bounded `HotkeyRequest` | opaque `HotkeyHandle` ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) | +| `HotkeyStart` | reserved handle | status and host-feed generation ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) | +| `HotkeyRead` | handle and optional acknowledged batch | bounded replayable event batch ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) | +| `HotkeyFeed` | handle, generation, sequence, key facts, and overflow flag | feed status ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) | +| `HotkeyStop` | handle | status after listener cleanup ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) | +| `HotkeyShutdown` | none | terminal cleanup status ([lifecycle](docs/specs/hotkey-module-lifecycle.md)) | Notes on the contract: From 5c5bfca0bdaae27b90136e0c7d5f6dd9a3714781 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 04:01:10 +0300 Subject: [PATCH 23/25] fix: reset hotkey state at every sequence discontinuity Co-authored-by: Medulla --- .../src/service/hotkey/mod.rs | 2 +- .../src/service/hotkey/mod_tests.rs | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/tinyvoice-module/src/service/hotkey/mod.rs b/crates/tinyvoice-module/src/service/hotkey/mod.rs index 70c721f..8bcf351 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod.rs @@ -309,7 +309,7 @@ impl Hotkeys { if fact.sequence <= lease.source_sequence { continue; } - if !gap && fact.sequence != lease.source_sequence.saturating_add(1) { + if fact.sequence != lease.source_sequence.saturating_add(1) { reset(lease); gap = true; } diff --git a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs index 47a9337..f277b4e 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs @@ -418,6 +418,51 @@ fn a_sequence_gap_applies_the_rest_of_the_batch_and_resynchronizes() { ); } +#[test] +fn every_sequence_gap_requires_a_fresh_release_before_reactivation() { + let (hotkeys, handle, generation) = started(ActivationMode::Push); + assert!(feed(&hotkeys, &handle, generation, &[(1, HostKeyFact::Down)]).active); + + let gap = HotkeyFeedRequest { + handle: handle.clone(), + generation, + facts: vec![ + SequencedHostFact { + sequence: 3, + fact: HostKeyFact::Up, + }, + SequencedHostFact { + sequence: 4, + fact: HostKeyFact::Down, + }, + SequencedHostFact { + sequence: 6, + fact: HostKeyFact::Down, + }, + SequencedHostFact { + sequence: 7, + fact: HostKeyFact::Down, + }, + ], + overflow: false, + }; + + assert_eq!(hotkeys.feed(&gap), Err(HotkeyError::SequenceGap)); + assert!( + !hotkeys + .read(&HotkeyReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + }) + .unwrap() + .active, + "the second gap must reset and disarm the lease again" + ); + assert!(!feed(&hotkeys, &handle, generation, &[(8, HostKeyFact::Down)]).active); + assert!(!feed(&hotkeys, &handle, generation, &[(9, HostKeyFact::Up)]).active); + assert!(feed(&hotkeys, &handle, generation, &[(10, HostKeyFact::Down)]).active); +} + #[test] fn a_partially_overlapping_batch_applies_its_unseen_tail() { let (hotkeys, handle, generation) = started(ActivationMode::Push); From 40fad039c617b92e67b8e80c27fdddd6baf47cf4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 04:01:12 +0300 Subject: [PATCH 24/25] fix: retain uncertain XRecord cleanup for retry Co-authored-by: Medulla --- .../src/service/hotkey/native/linux.rs | 33 ++++++++-- .../src/service/hotkey/native/linux_tests.rs | 60 +++++++++++++++---- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs index 67b7d5b..367a2ef 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux.rs @@ -168,6 +168,7 @@ pub(super) struct XRecordOwner { enum RecordCleanupError { ConnectionLost, Protocol, + Uncertain, } trait RecordControl: std::fmt::Debug + Send { @@ -178,14 +179,14 @@ trait RecordControl: std::fmt::Debug + Send { impl RecordControl for RustConnection { fn disable_context(&self, context: u32) -> Result<(), RecordCleanupError> { self.record_disable_context(context) - .map_err(|_| RecordCleanupError::ConnectionLost)? + .map_err(|error| classify_connection_error(&error))? .check() .map_err(|error| classify_reply_error(&error)) } fn free_context(&self, context: u32) -> Result<(), RecordCleanupError> { self.record_free_context(context) - .map_err(|_| RecordCleanupError::ConnectionLost)? + .map_err(|error| classify_connection_error(&error))? .check() .map_err(|error| classify_reply_error(&error)) } @@ -193,11 +194,29 @@ impl RecordControl for RustConnection { fn classify_reply_error(error: &x11rb::errors::ReplyError) -> RecordCleanupError { match error { - x11rb::errors::ReplyError::ConnectionError(_) => RecordCleanupError::ConnectionLost, + x11rb::errors::ReplyError::ConnectionError(error) => classify_connection_error(error), x11rb::errors::ReplyError::X11Error(_) => RecordCleanupError::Protocol, } } +fn classify_connection_error(error: &x11rb::errors::ConnectionError) -> RecordCleanupError { + match error { + x11rb::errors::ConnectionError::IoError(error) + if matches!( + error.kind(), + std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::NotConnected + | std::io::ErrorKind::UnexpectedEof + ) => + { + RecordCleanupError::ConnectionLost + } + _ => RecordCleanupError::Uncertain, + } +} + impl NativeOwner for XRecordOwner { fn stop(&mut self) -> Result<(), HotkeyError> { let disable_result = if let Some(context) = self.context.filter(|_| !self.disable_sent) { @@ -216,12 +235,16 @@ impl NativeOwner for XRecordOwner { self.context = None; return Ok(()); } - Err(RecordCleanupError::Protocol) => return Err(HotkeyError::CleanupFailed), + Err(RecordCleanupError::Protocol | RecordCleanupError::Uncertain) => { + return Err(HotkeyError::CleanupFailed); + } } if let Some(context) = self.context { match self.control.free_context(context) { Ok(()) | Err(RecordCleanupError::ConnectionLost) => self.context = None, - Err(RecordCleanupError::Protocol) => return Err(HotkeyError::CleanupFailed), + Err(RecordCleanupError::Protocol | RecordCleanupError::Uncertain) => { + return Err(HotkeyError::CleanupFailed); + } } } Ok(()) diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs index 53430b4..5798a24 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs @@ -27,8 +27,8 @@ fn local_xrecord_listener_cancels_blocked_reader_and_joins() { #[test] fn xrecord_stop_releases_context_after_control_connection_loss() { let mut owner = owner_with(FakeRecordControl { - disable: Err(RecordCleanupError::ConnectionLost), - free: Ok(()), + disable: Arc::new(std::sync::Mutex::new(Err(RecordCleanupError::ConnectionLost))), + free: Arc::new(std::sync::Mutex::new(Ok(()))), }); assert_eq!(owner.stop(), Ok(())); @@ -38,8 +38,8 @@ fn xrecord_stop_releases_context_after_control_connection_loss() { #[test] fn xrecord_stop_keeps_context_on_protocol_error() { let mut owner = owner_with(FakeRecordControl { - disable: Err(RecordCleanupError::Protocol), - free: Ok(()), + disable: Arc::new(std::sync::Mutex::new(Err(RecordCleanupError::Protocol))), + free: Arc::new(std::sync::Mutex::new(Ok(()))), }); assert_eq!(owner.stop(), Err(HotkeyError::CleanupFailed)); @@ -49,27 +49,65 @@ fn xrecord_stop_keeps_context_on_protocol_error() { #[test] fn xrecord_stop_releases_context_if_free_finds_connection_lost() { let mut owner = owner_with(FakeRecordControl { - disable: Ok(()), - free: Err(RecordCleanupError::ConnectionLost), + disable: Arc::new(std::sync::Mutex::new(Ok(()))), + free: Arc::new(std::sync::Mutex::new(Err(RecordCleanupError::ConnectionLost))), }); assert_eq!(owner.stop(), Ok(())); assert_eq!(owner.context, None); } -#[derive(Debug)] +#[test] +fn uncertain_x11_connection_errors_do_not_mean_the_context_was_released() { + let timeout = x11rb::errors::ReplyError::ConnectionError( + x11rb::errors::ConnectionError::IoError(std::io::Error::from( + std::io::ErrorKind::TimedOut, + )), + ); + let unknown = x11rb::errors::ReplyError::ConnectionError( + x11rb::errors::ConnectionError::UnknownError, + ); + + assert_eq!(classify_reply_error(&timeout), RecordCleanupError::Uncertain); + assert_eq!(classify_reply_error(&unknown), RecordCleanupError::Uncertain); + + let closed = x11rb::errors::ReplyError::ConnectionError( + x11rb::errors::ConnectionError::IoError(std::io::Error::from( + std::io::ErrorKind::BrokenPipe, + )), + ); + assert_eq!(classify_reply_error(&closed), RecordCleanupError::ConnectionLost); +} + +#[test] +fn xrecord_stop_retains_context_after_timeout_and_retries_cleanup() { + let control = FakeRecordControl { + disable: Arc::new(std::sync::Mutex::new(Err(RecordCleanupError::Uncertain))), + free: Arc::new(std::sync::Mutex::new(Ok(()))), + }; + let mut owner = owner_with(control.clone()); + + assert_eq!(owner.stop(), Err(HotkeyError::CleanupFailed)); + assert_eq!(owner.context, Some(1)); + + *control.disable.lock().unwrap() = Ok(()); + assert_eq!(owner.stop(), Ok(())); + assert_eq!(owner.context, None); +} + +#[derive(Clone, Debug)] struct FakeRecordControl { - disable: Result<(), RecordCleanupError>, - free: Result<(), RecordCleanupError>, + disable: Arc>>, + free: Arc>>, } impl RecordControl for FakeRecordControl { fn disable_context(&self, _context: u32) -> Result<(), RecordCleanupError> { - self.disable + *self.disable.lock().unwrap() } fn free_context(&self, _context: u32) -> Result<(), RecordCleanupError> { - self.free + *self.free.lock().unwrap() } } From 02f6a274a1ef77b4bb5f5c116bddefeb79a48c86 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 04:04:29 +0300 Subject: [PATCH 25/25] style: format hotkey regression tests Co-authored-by: Medulla --- .../src/service/hotkey/mod_tests.rs | 5 ++- .../src/service/hotkey/native/linux_tests.rs | 39 ++++++++++++------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs index f277b4e..2411339 100644 --- a/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/mod_tests.rs @@ -408,7 +408,10 @@ fn a_sequence_gap_applies_the_rest_of_the_batch_and_resynchronizes() { }) .unwrap(); assert!(after_gap.reset); - assert!(after_gap.active, "the batch's fresh Up and Down are applied"); + assert!( + after_gap.active, + "the batch's fresh Up and Down are applied" + ); assert_eq!(after_gap.events.len(), 1); assert_eq!(after_gap.events[0].event, HotkeyEvent::Pressed); diff --git a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs index 5798a24..c92536b 100644 --- a/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs +++ b/crates/tinyvoice-module/src/service/hotkey/native/linux_tests.rs @@ -27,7 +27,9 @@ fn local_xrecord_listener_cancels_blocked_reader_and_joins() { #[test] fn xrecord_stop_releases_context_after_control_connection_loss() { let mut owner = owner_with(FakeRecordControl { - disable: Arc::new(std::sync::Mutex::new(Err(RecordCleanupError::ConnectionLost))), + disable: Arc::new(std::sync::Mutex::new(Err( + RecordCleanupError::ConnectionLost, + ))), free: Arc::new(std::sync::Mutex::new(Ok(()))), }); @@ -50,7 +52,9 @@ fn xrecord_stop_keeps_context_on_protocol_error() { fn xrecord_stop_releases_context_if_free_finds_connection_lost() { let mut owner = owner_with(FakeRecordControl { disable: Arc::new(std::sync::Mutex::new(Ok(()))), - free: Arc::new(std::sync::Mutex::new(Err(RecordCleanupError::ConnectionLost))), + free: Arc::new(std::sync::Mutex::new(Err( + RecordCleanupError::ConnectionLost, + ))), }); assert_eq!(owner.stop(), Ok(())); @@ -60,23 +64,28 @@ fn xrecord_stop_releases_context_if_free_finds_connection_lost() { #[test] fn uncertain_x11_connection_errors_do_not_mean_the_context_was_released() { let timeout = x11rb::errors::ReplyError::ConnectionError( - x11rb::errors::ConnectionError::IoError(std::io::Error::from( - std::io::ErrorKind::TimedOut, - )), - ); - let unknown = x11rb::errors::ReplyError::ConnectionError( - x11rb::errors::ConnectionError::UnknownError, + x11rb::errors::ConnectionError::IoError(std::io::Error::from(std::io::ErrorKind::TimedOut)), ); + let unknown = + x11rb::errors::ReplyError::ConnectionError(x11rb::errors::ConnectionError::UnknownError); - assert_eq!(classify_reply_error(&timeout), RecordCleanupError::Uncertain); - assert_eq!(classify_reply_error(&unknown), RecordCleanupError::Uncertain); + assert_eq!( + classify_reply_error(&timeout), + RecordCleanupError::Uncertain + ); + assert_eq!( + classify_reply_error(&unknown), + RecordCleanupError::Uncertain + ); - let closed = x11rb::errors::ReplyError::ConnectionError( - x11rb::errors::ConnectionError::IoError(std::io::Error::from( - std::io::ErrorKind::BrokenPipe, - )), + let closed = + x11rb::errors::ReplyError::ConnectionError(x11rb::errors::ConnectionError::IoError( + std::io::Error::from(std::io::ErrorKind::BrokenPipe), + )); + assert_eq!( + classify_reply_error(&closed), + RecordCleanupError::ConnectionLost ); - assert_eq!(classify_reply_error(&closed), RecordCleanupError::ConnectionLost); } #[test]