Skip to content

Commit 22fb388

Browse files
refactor: address rust-code-reviewer findings (pass 2) (#20)
* refactor: address rust-code-reviewer pass-2 findings - wisp-audiokit: swap std::sync::mpsc for crossbeam-channel so the Sender shared via raw user_data is Sync. The Swift side may invoke on_result / on_log thunks from different threads concurrently; with std mpsc that formed overlapping &Sender references (UB). Also expose Session::recv_timeout. - wisp-storage migrations: use Connection::unchecked_transaction so a panic mid-step doesn't leak an open transaction, and turn the u32 overflow case into a statically-asserted unreachable so we can't silently skip a migration step. - wisp-audiokit-sys: drop the non-macOS stub module entirely; the wrapper crate already cfg-gates every call site to macOS, so the stubs were dead code that forced callers to write unnecessary unsafe {}. - session_runner: replace try_recv + sleep(20ms) with Session::recv_timeout(20ms) so events are forwarded immediately instead of waiting up to a tick. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: correct Session SAFETY comment after crossbeam swap `crossbeam_channel::Receiver<T>` is `Sync` (unlike `mpsc::Receiver`), so the old "Receiver is `Send` but `!Sync`" justification no longer matched the truth — `Session` is `!Sync` because of its `NonNull<…>` field, not its receiver. Rewrite the SAFETY comment accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: cargo fmt Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7b656b2 commit 22fb388

7 files changed

Lines changed: 79 additions & 97 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ resolver = "3"
1010

1111
[workspace.dependencies]
1212
chrono = { version = "0.4.44", default-features = false, features = ["clock", "std"] }
13+
crossbeam-channel = "0.5.15"
1314
gpui = "0.2.2"
1415
rusqlite = { version = "0.34.0", features = ["bundled", "chrono"] }
1516
serde = { version = "1.0.228", features = ["derive"] }

apps/wisp-desktop/src/session_runner.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ use std::time::Duration;
1212

1313
use wisp_audiokit::{Event, Session, SessionError};
1414

15+
/// How often the running session checks for UI commands (Stop / Shutdown)
16+
/// while waiting for the next audio event. Sets the worst-case latency for
17+
/// a Stop press to be honoured. Events themselves are delivered
18+
/// immediately — this only bounds the *idle* wake-up cadence.
19+
const CMD_POLL_INTERVAL: Duration = Duration::from_millis(20);
20+
1521
/// Commands the UI sends to the worker.
1622
pub enum Command {
1723
Start { output_dir: PathBuf, locale: String },
@@ -118,9 +124,10 @@ fn run_session(
118124
}
119125
let _ = update_tx.send(Update::Started);
120126

121-
// Pump events until the UI asks to stop. We poll the cmd channel
122-
// between event reads so a Stop request doesn't have to wait for the
123-
// next audio event.
127+
// Pump events until the UI asks to stop. Between events we wake at
128+
// most every `CMD_POLL_INTERVAL` to check the command channel so a
129+
// Stop request doesn't have to wait for the next audio event; when
130+
// events are arriving we forward them immediately without polling.
124131
loop {
125132
match cmd_rx.try_recv() {
126133
Ok(Command::Stop) => break,
@@ -131,10 +138,8 @@ fn run_session(
131138
},
132139
Ok(Command::Start { .. }) | Err(TryRecvError::Empty) => {},
133140
}
134-
if let Some(event) = session.try_recv() {
141+
if let Some(event) = session.recv_timeout(CMD_POLL_INTERVAL) {
135142
let _ = update_tx.send(Update::Event(event));
136-
} else {
137-
std::thread::sleep(Duration::from_millis(20));
138143
}
139144
}
140145

crates/wisp-audiokit-sys/src/lib.rs

Lines changed: 7 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
//! crate's `build.rs` into a static library (`libWispAudioKit.a`). The C ABI
55
//! surface is hand-mirrored from `native/WispAudioKit/include/wisp_audiokit.h`.
66
//!
7-
//! On non-macOS targets every binding is a stub returning a null pointer or
8-
//! a non-zero error code so the workspace stays buildable.
7+
//! `WispAudioKit` is macOS-only, so the `extern "C"` block below is gated to
8+
//! `target_os = "macos"`. On other targets the crate exposes only the type
9+
//! aliases and constants — there are no function symbols to link against,
10+
//! and consumers must (and do) cfg-gate any code that touches them.
911
1012
#![allow(unsafe_code, non_camel_case_types)]
1113

12-
use std::os::raw::{c_char, c_int, c_void};
14+
#[cfg(target_os = "macos")]
15+
use std::os::raw::c_int;
16+
use std::os::raw::{c_char, c_void};
1317

1418
/// Opaque handle for a `WispSession`. Construct via [`wisp_session_new`].
1519
#[repr(C)]
@@ -71,74 +75,3 @@ unsafe extern "C" {
7175
/// null. Invalidated by the next mutating call.
7276
pub fn wisp_session_last_error_message(session: *mut WispSession) -> *const c_char;
7377
}
74-
75-
// ---- Non-macOS stubs ----------------------------------------------------
76-
77-
#[cfg(not(target_os = "macos"))]
78-
mod stubs {
79-
//! Non-macOS stubs. `WispAudioKit` is macOS-only, so on Linux / Windows
80-
//! every entry point is a no-op returning null / `-1`. The functions are
81-
//! marked `unsafe` only to keep their signatures interchangeable with
82-
//! the real `extern "C"` declarations on macOS — they are trivially
83-
//! safe to call.
84-
85-
use super::{WispLogCallback, WispResultCallback, WispSession, c_char, c_int, c_void};
86-
87-
/// Stub: always returns null on non-macOS.
88-
///
89-
/// # Safety
90-
/// Trivially safe; no pointers are dereferenced.
91-
#[must_use]
92-
pub unsafe fn wisp_audiokit_version() -> *const c_char {
93-
core::ptr::null()
94-
}
95-
96-
/// Stub: always returns null on non-macOS.
97-
///
98-
/// # Safety
99-
/// Trivially safe; no pointers are dereferenced.
100-
pub unsafe fn wisp_session_new(
101-
_output_dir: *const c_char,
102-
_locale: *const c_char,
103-
_on_result: Option<WispResultCallback>,
104-
_on_log: Option<WispLogCallback>,
105-
_user_data: *mut c_void,
106-
) -> *mut WispSession {
107-
core::ptr::null_mut()
108-
}
109-
110-
/// Stub: always returns `-1` on non-macOS.
111-
///
112-
/// # Safety
113-
/// Trivially safe; no pointers are dereferenced.
114-
pub unsafe fn wisp_session_start(_session: *mut WispSession) -> c_int {
115-
-1
116-
}
117-
118-
/// Stub: no-op on non-macOS.
119-
///
120-
/// # Safety
121-
/// Trivially safe; no pointers are dereferenced.
122-
pub unsafe fn wisp_session_stop(_session: *mut WispSession) {}
123-
124-
/// Stub: no-op on non-macOS.
125-
///
126-
/// # Safety
127-
/// Trivially safe; no pointers are dereferenced.
128-
pub unsafe fn wisp_session_free(_session: *mut WispSession) {}
129-
130-
/// Stub: always returns null on non-macOS.
131-
///
132-
/// # Safety
133-
/// Trivially safe; no pointers are dereferenced.
134-
#[must_use]
135-
pub unsafe fn wisp_session_last_error_message(_session: *mut WispSession) -> *const c_char {
136-
core::ptr::null()
137-
}
138-
}
139-
140-
#[cfg(not(target_os = "macos"))]
141-
pub use stubs::{
142-
wisp_audiokit_version, wisp_session_free, wisp_session_last_error_message, wisp_session_new,
143-
wisp_session_start, wisp_session_stop,
144-
};

crates/wisp-audiokit/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ unsafe_code = "allow"
2121
[dependencies]
2222
wisp-audiokit-sys = { path = "../wisp-audiokit-sys" }
2323
wisp-core = { path = "../wisp-core" }
24+
crossbeam-channel = { workspace = true }
2425
thiserror = { workspace = true }

crates/wisp-audiokit/src/lib.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ mod imp {
1212
use std::ffi::{CStr, CString};
1313
use std::path::Path;
1414
use std::ptr::NonNull;
15-
use std::sync::mpsc;
15+
use std::time::Duration;
1616

17+
use crossbeam_channel as channel;
1718
use wisp_audiokit_sys as sys;
1819
use wisp_core::SourceLabel;
1920

@@ -66,20 +67,26 @@ mod imp {
6667
/// running session is always cleaned up.
6768
pub struct Session {
6869
handle: NonNull<sys::WispSession>,
69-
receiver: mpsc::Receiver<Event>,
70+
receiver: channel::Receiver<Event>,
7071
// Kept alive so the callbacks' user_data pointer stays valid for
7172
// as long as the Swift side might call them.
7273
_ctx: Box<CallbackContext>,
7374
}
7475

7576
// SAFETY: Session owns the C handle and the receiver. The handle is
7677
// an opaque pointer we never deref ourselves; the C side serializes
77-
// access internally. Receiver is `Send` but `!Sync`, which matches the
78-
// semantics we expose.
78+
// access internally, so it is sound to move the handle across threads.
79+
// (`Session` stays `!Sync` overall because the `NonNull` field is
80+
// `!Sync` — only `Send` needs the manual impl.)
7981
unsafe impl Send for Session {}
8082

83+
// Swift may invoke `on_result_thunk` / `on_log_thunk` from different
84+
// threads. The thunks form `&CallbackContext` from a raw `user_data`
85+
// pointer, so `CallbackContext` must be `Sync`. `crossbeam_channel`'s
86+
// `Sender` is `Sync` (unlike `std::sync::mpsc::Sender`), which lets
87+
// those callbacks fire concurrently without UB.
8188
struct CallbackContext {
82-
sender: mpsc::Sender<Event>,
89+
sender: channel::Sender<Event>,
8390
}
8491

8592
impl Session {
@@ -108,7 +115,7 @@ mod imp {
108115
let locale_c =
109116
CString::new(locale).map_err(|_| SessionError::InvalidLocale(locale.to_owned()))?;
110117

111-
let (sender, receiver) = mpsc::channel();
118+
let (sender, receiver) = channel::unbounded();
112119
let ctx = Box::new(CallbackContext { sender });
113120
let user_data = std::ptr::from_ref::<CallbackContext>(ctx.as_ref()) as *mut _;
114121

@@ -176,6 +183,17 @@ mod imp {
176183
pub fn recv(&self) -> Option<Event> {
177184
self.receiver.recv().ok()
178185
}
186+
187+
/// Block until the next event arrives or `timeout` elapses.
188+
/// Returns `None` on timeout or when the session has been
189+
/// dropped / closed.
190+
#[must_use]
191+
pub fn recv_timeout(
192+
&self,
193+
timeout: Duration,
194+
) -> Option<Event> {
195+
self.receiver.recv_timeout(timeout).ok()
196+
}
179197
}
180198

181199
impl Drop for Session {
@@ -251,6 +269,7 @@ mod imp {
251269
#[cfg(not(target_os = "macos"))]
252270
mod imp {
253271
use std::path::Path;
272+
use std::time::Duration;
254273

255274
use wisp_core::SourceLabel;
256275

@@ -312,6 +331,15 @@ mod imp {
312331
pub fn recv(&self) -> Option<Event> {
313332
None
314333
}
334+
335+
/// Always returns `None`.
336+
#[must_use]
337+
pub fn recv_timeout(
338+
&self,
339+
_timeout: Duration,
340+
) -> Option<Event> {
341+
None
342+
}
315343
}
316344
}
317345

crates/wisp-storage/src/migrations.rs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ const MIGRATIONS: &[&str] = &[
5353
",
5454
];
5555

56+
// Statically guard the `usize -> u32` conversion in `run` below: the
57+
// migration index is bounded by `MIGRATIONS.len()`, so as long as that
58+
// fits in a u32 the cast is infallible.
59+
const _: () = assert!(
60+
MIGRATIONS.len() <= u32::MAX as usize,
61+
"MIGRATIONS must fit in a u32 (PRAGMA user_version is u32)",
62+
);
63+
5664
/// Apply any pending migrations, advancing `PRAGMA user_version` as each
5765
/// step succeeds. Idempotent: running on an already-current database is a
5866
/// no-op.
@@ -64,20 +72,16 @@ pub(crate) fn run(conn: &Connection) -> Result<()> {
6472
let current: u32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
6573

6674
for (i, sql) in MIGRATIONS.iter().enumerate().skip(current as usize) {
67-
// i+1 <= MIGRATIONS.len(), a compile-time constant of small size,
68-
// so this conversion can't overflow u32.
6975
let Ok(target) = u32::try_from(i + 1) else {
70-
continue;
76+
unreachable!("MIGRATIONS.len() statically asserted to fit in u32");
7177
};
72-
let tx_result = (|| -> std::result::Result<(), rusqlite::Error> {
73-
conn.execute_batch("BEGIN;")?;
74-
conn.execute_batch(sql)?;
75-
conn.pragma_update(None, "user_version", target)?;
76-
conn.execute_batch("COMMIT;")?;
77-
Ok(())
78+
let result = (|| -> std::result::Result<(), rusqlite::Error> {
79+
let tx = conn.unchecked_transaction()?;
80+
tx.execute_batch(sql)?;
81+
tx.pragma_update(None, "user_version", target)?;
82+
tx.commit()
7883
})();
79-
if let Err(source) = tx_result {
80-
let _ = conn.execute_batch("ROLLBACK;");
84+
if let Err(source) = result {
8185
return Err(StorageError::Migration { target, source });
8286
}
8387
}

0 commit comments

Comments
 (0)