diff --git a/Cargo.lock b/Cargo.lock index d3bd18b..edd28c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,6 +1156,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -6689,6 +6698,7 @@ dependencies = [ name = "wisp-audiokit" version = "0.0.0" dependencies = [ + "crossbeam-channel", "thiserror 2.0.18", "wisp-audiokit-sys", "wisp-core", diff --git a/Cargo.toml b/Cargo.toml index 2447334..8ef1c04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ resolver = "3" [workspace.dependencies] chrono = { version = "0.4.44", default-features = false, features = ["clock", "std"] } +crossbeam-channel = "0.5.15" gpui = "0.2.2" rusqlite = { version = "0.34.0", features = ["bundled", "chrono"] } serde = { version = "1.0.228", features = ["derive"] } diff --git a/apps/wisp-desktop/src/session_runner.rs b/apps/wisp-desktop/src/session_runner.rs index dacb738..a115a85 100644 --- a/apps/wisp-desktop/src/session_runner.rs +++ b/apps/wisp-desktop/src/session_runner.rs @@ -12,6 +12,12 @@ use std::time::Duration; use wisp_audiokit::{Event, Session, SessionError}; +/// How often the running session checks for UI commands (Stop / Shutdown) +/// while waiting for the next audio event. Sets the worst-case latency for +/// a Stop press to be honoured. Events themselves are delivered +/// immediately — this only bounds the *idle* wake-up cadence. +const CMD_POLL_INTERVAL: Duration = Duration::from_millis(20); + /// Commands the UI sends to the worker. pub enum Command { Start { output_dir: PathBuf, locale: String }, @@ -118,9 +124,10 @@ fn run_session( } let _ = update_tx.send(Update::Started); - // Pump events until the UI asks to stop. We poll the cmd channel - // between event reads so a Stop request doesn't have to wait for the - // next audio event. + // Pump events until the UI asks to stop. Between events we wake at + // most every `CMD_POLL_INTERVAL` to check the command channel so a + // Stop request doesn't have to wait for the next audio event; when + // events are arriving we forward them immediately without polling. loop { match cmd_rx.try_recv() { Ok(Command::Stop) => break, @@ -131,10 +138,8 @@ fn run_session( }, Ok(Command::Start { .. }) | Err(TryRecvError::Empty) => {}, } - if let Some(event) = session.try_recv() { + if let Some(event) = session.recv_timeout(CMD_POLL_INTERVAL) { let _ = update_tx.send(Update::Event(event)); - } else { - std::thread::sleep(Duration::from_millis(20)); } } diff --git a/crates/wisp-audiokit-sys/src/lib.rs b/crates/wisp-audiokit-sys/src/lib.rs index fc629c8..6f8f21f 100644 --- a/crates/wisp-audiokit-sys/src/lib.rs +++ b/crates/wisp-audiokit-sys/src/lib.rs @@ -4,12 +4,16 @@ //! crate's `build.rs` into a static library (`libWispAudioKit.a`). The C ABI //! surface is hand-mirrored from `native/WispAudioKit/include/wisp_audiokit.h`. //! -//! On non-macOS targets every binding is a stub returning a null pointer or -//! a non-zero error code so the workspace stays buildable. +//! `WispAudioKit` is macOS-only, so the `extern "C"` block below is gated to +//! `target_os = "macos"`. On other targets the crate exposes only the type +//! aliases and constants — there are no function symbols to link against, +//! and consumers must (and do) cfg-gate any code that touches them. #![allow(unsafe_code, non_camel_case_types)] -use std::os::raw::{c_char, c_int, c_void}; +#[cfg(target_os = "macos")] +use std::os::raw::c_int; +use std::os::raw::{c_char, c_void}; /// Opaque handle for a `WispSession`. Construct via [`wisp_session_new`]. #[repr(C)] @@ -71,74 +75,3 @@ unsafe extern "C" { /// null. Invalidated by the next mutating call. pub fn wisp_session_last_error_message(session: *mut WispSession) -> *const c_char; } - -// ---- Non-macOS stubs ---------------------------------------------------- - -#[cfg(not(target_os = "macos"))] -mod stubs { - //! Non-macOS stubs. `WispAudioKit` is macOS-only, so on Linux / Windows - //! every entry point is a no-op returning null / `-1`. The functions are - //! marked `unsafe` only to keep their signatures interchangeable with - //! the real `extern "C"` declarations on macOS — they are trivially - //! safe to call. - - use super::{WispLogCallback, WispResultCallback, WispSession, c_char, c_int, c_void}; - - /// Stub: always returns null on non-macOS. - /// - /// # Safety - /// Trivially safe; no pointers are dereferenced. - #[must_use] - pub unsafe fn wisp_audiokit_version() -> *const c_char { - core::ptr::null() - } - - /// Stub: always returns null on non-macOS. - /// - /// # Safety - /// Trivially safe; no pointers are dereferenced. - pub unsafe fn wisp_session_new( - _output_dir: *const c_char, - _locale: *const c_char, - _on_result: Option, - _on_log: Option, - _user_data: *mut c_void, - ) -> *mut WispSession { - core::ptr::null_mut() - } - - /// Stub: always returns `-1` on non-macOS. - /// - /// # Safety - /// Trivially safe; no pointers are dereferenced. - pub unsafe fn wisp_session_start(_session: *mut WispSession) -> c_int { - -1 - } - - /// Stub: no-op on non-macOS. - /// - /// # Safety - /// Trivially safe; no pointers are dereferenced. - pub unsafe fn wisp_session_stop(_session: *mut WispSession) {} - - /// Stub: no-op on non-macOS. - /// - /// # Safety - /// Trivially safe; no pointers are dereferenced. - pub unsafe fn wisp_session_free(_session: *mut WispSession) {} - - /// Stub: always returns null on non-macOS. - /// - /// # Safety - /// Trivially safe; no pointers are dereferenced. - #[must_use] - pub unsafe fn wisp_session_last_error_message(_session: *mut WispSession) -> *const c_char { - core::ptr::null() - } -} - -#[cfg(not(target_os = "macos"))] -pub use stubs::{ - wisp_audiokit_version, wisp_session_free, wisp_session_last_error_message, wisp_session_new, - wisp_session_start, wisp_session_stop, -}; diff --git a/crates/wisp-audiokit/Cargo.toml b/crates/wisp-audiokit/Cargo.toml index fc243ec..95ea5b8 100644 --- a/crates/wisp-audiokit/Cargo.toml +++ b/crates/wisp-audiokit/Cargo.toml @@ -21,4 +21,5 @@ unsafe_code = "allow" [dependencies] wisp-audiokit-sys = { path = "../wisp-audiokit-sys" } wisp-core = { path = "../wisp-core" } +crossbeam-channel = { workspace = true } thiserror = { workspace = true } diff --git a/crates/wisp-audiokit/src/lib.rs b/crates/wisp-audiokit/src/lib.rs index b890167..18101a3 100644 --- a/crates/wisp-audiokit/src/lib.rs +++ b/crates/wisp-audiokit/src/lib.rs @@ -12,8 +12,9 @@ mod imp { use std::ffi::{CStr, CString}; use std::path::Path; use std::ptr::NonNull; - use std::sync::mpsc; + use std::time::Duration; + use crossbeam_channel as channel; use wisp_audiokit_sys as sys; use wisp_core::SourceLabel; @@ -66,7 +67,7 @@ mod imp { /// running session is always cleaned up. pub struct Session { handle: NonNull, - receiver: mpsc::Receiver, + receiver: channel::Receiver, // Kept alive so the callbacks' user_data pointer stays valid for // as long as the Swift side might call them. _ctx: Box, @@ -74,12 +75,18 @@ mod imp { // SAFETY: Session owns the C handle and the receiver. The handle is // an opaque pointer we never deref ourselves; the C side serializes - // access internally. Receiver is `Send` but `!Sync`, which matches the - // semantics we expose. + // access internally, so it is sound to move the handle across threads. + // (`Session` stays `!Sync` overall because the `NonNull` field is + // `!Sync` — only `Send` needs the manual impl.) unsafe impl Send for Session {} + // Swift may invoke `on_result_thunk` / `on_log_thunk` from different + // threads. The thunks form `&CallbackContext` from a raw `user_data` + // pointer, so `CallbackContext` must be `Sync`. `crossbeam_channel`'s + // `Sender` is `Sync` (unlike `std::sync::mpsc::Sender`), which lets + // those callbacks fire concurrently without UB. struct CallbackContext { - sender: mpsc::Sender, + sender: channel::Sender, } impl Session { @@ -108,7 +115,7 @@ mod imp { let locale_c = CString::new(locale).map_err(|_| SessionError::InvalidLocale(locale.to_owned()))?; - let (sender, receiver) = mpsc::channel(); + let (sender, receiver) = channel::unbounded(); let ctx = Box::new(CallbackContext { sender }); let user_data = std::ptr::from_ref::(ctx.as_ref()) as *mut _; @@ -176,6 +183,17 @@ mod imp { pub fn recv(&self) -> Option { self.receiver.recv().ok() } + + /// Block until the next event arrives or `timeout` elapses. + /// Returns `None` on timeout or when the session has been + /// dropped / closed. + #[must_use] + pub fn recv_timeout( + &self, + timeout: Duration, + ) -> Option { + self.receiver.recv_timeout(timeout).ok() + } } impl Drop for Session { @@ -251,6 +269,7 @@ mod imp { #[cfg(not(target_os = "macos"))] mod imp { use std::path::Path; + use std::time::Duration; use wisp_core::SourceLabel; @@ -312,6 +331,15 @@ mod imp { pub fn recv(&self) -> Option { None } + + /// Always returns `None`. + #[must_use] + pub fn recv_timeout( + &self, + _timeout: Duration, + ) -> Option { + None + } } } diff --git a/crates/wisp-storage/src/migrations.rs b/crates/wisp-storage/src/migrations.rs index 2e9bc00..988f369 100644 --- a/crates/wisp-storage/src/migrations.rs +++ b/crates/wisp-storage/src/migrations.rs @@ -53,6 +53,14 @@ const MIGRATIONS: &[&str] = &[ ", ]; +// Statically guard the `usize -> u32` conversion in `run` below: the +// migration index is bounded by `MIGRATIONS.len()`, so as long as that +// fits in a u32 the cast is infallible. +const _: () = assert!( + MIGRATIONS.len() <= u32::MAX as usize, + "MIGRATIONS must fit in a u32 (PRAGMA user_version is u32)", +); + /// Apply any pending migrations, advancing `PRAGMA user_version` as each /// step succeeds. Idempotent: running on an already-current database is a /// no-op. @@ -64,20 +72,16 @@ pub(crate) fn run(conn: &Connection) -> Result<()> { let current: u32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; for (i, sql) in MIGRATIONS.iter().enumerate().skip(current as usize) { - // i+1 <= MIGRATIONS.len(), a compile-time constant of small size, - // so this conversion can't overflow u32. let Ok(target) = u32::try_from(i + 1) else { - continue; + unreachable!("MIGRATIONS.len() statically asserted to fit in u32"); }; - let tx_result = (|| -> std::result::Result<(), rusqlite::Error> { - conn.execute_batch("BEGIN;")?; - conn.execute_batch(sql)?; - conn.pragma_update(None, "user_version", target)?; - conn.execute_batch("COMMIT;")?; - Ok(()) + let result = (|| -> std::result::Result<(), rusqlite::Error> { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(sql)?; + tx.pragma_update(None, "user_version", target)?; + tx.commit() })(); - if let Err(source) = tx_result { - let _ = conn.execute_batch("ROLLBACK;"); + if let Err(source) = result { return Err(StorageError::Migration { target, source }); } }