Skip to main content

sayd_core/
handle.rs

1//! Runs the engine on its own thread and gives everyone else a handle to it.
2//!
3//! The engine is deliberately single-threaded: it owns the queue, the
4//! synthesizer and the sink outright, and `tick()` does one unit of work and
5//! returns. So it moves wholesale onto one thread, and callers send commands
6//! in and read published snapshots out. Nothing outside this module ever
7//! holds a reference to the `Engine` itself.
8//!
9//! Snapshots are published into a mutex after every tick. Readers take that
10//! lock for the length of a clone and never while the engine is working, so
11//! a D-Bus poller cannot stall synthesis.
12
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
15use std::sync::{Arc, Mutex};
16use std::thread::JoinHandle;
17use std::time::Duration;
18
19use crate::audio::AudioSink;
20use crate::config::Config;
21use crate::engine::{Command, Engine, SayOpts, Snapshot};
22use crate::synth::Synthesizer;
23
24/// How long the engine thread waits for a command before ticking anyway.
25///
26/// `tick` must be called even when no command arrives: it is what advances
27/// synthesis, drains the sink, and runs the idle-unload check.
28const TICK_INTERVAL: Duration = Duration::from_millis(10);
29
30/// A submission plus the channel its answer goes back on.
31type SubmitJob = (String, SayOpts, Sender<Result<Option<u64>, String>>);
32
33enum Msg {
34    Cmd(Command),
35    Submit(Box<SubmitJob>),
36    ReplaceSink(Box<dyn AudioSink>),
37    Shutdown,
38}
39
40#[derive(Clone)]
41pub struct EngineHandle {
42    tx: Sender<Msg>,
43    latest: Arc<Mutex<Snapshot>>,
44    thread: Arc<Mutex<Option<JoinHandle<()>>>>,
45    /// Set once the engine thread's run loop has exited, by any means:
46    /// a normal `break` out of the loop, or the loop body unwinding on a
47    /// panic (e.g. from a `Synthesizer` implementation). Lets the daemon's
48    /// main loop notice a `Command::Shutdown` that arrived over the channel
49    /// (e.g. from a D-Bus `Quit` call), or a crashed engine thread, without
50    /// having to call `shutdown` itself.
51    shut_down: Arc<AtomicBool>,
52}
53
54impl EngineHandle {
55    pub fn spawn(
56        cfg: Config,
57        synth: Box<dyn Synthesizer>,
58        sink: Box<dyn AudioSink>,
59    ) -> EngineHandle {
60        let (tx, rx) = mpsc::channel::<Msg>();
61        let engine = Engine::new(cfg, synth, sink);
62        let latest = Arc::new(Mutex::new(engine.snapshot()));
63        let published = latest.clone();
64        let shut_down = Arc::new(AtomicBool::new(false));
65        let shut_down_writer = shut_down.clone();
66
67        let thread = std::thread::Builder::new()
68            .name("sayd-engine".into())
69            .spawn(move || run(engine, rx, published, shut_down_writer))
70            .ok();
71
72        EngineHandle {
73            tx,
74            latest,
75            thread: Arc::new(Mutex::new(thread)),
76            shut_down,
77        }
78    }
79
80    /// Fire and forget. A dead engine thread silently drops the command —
81    /// the daemon notices through the snapshot, not here.
82    pub fn send(&self, cmd: Command) {
83        let _ = self.tx.send(Msg::Cmd(cmd));
84    }
85
86    /// Submit text and wait for the engine's answer.
87    pub fn submit(&self, text: String, opts: SayOpts) -> Result<Option<u64>, String> {
88        let (reply_tx, reply_rx) = mpsc::channel();
89        self.tx
90            .send(Msg::Submit(Box::new((text, opts, reply_tx))))
91            .map_err(|_| "engine thread is not running".to_string())?;
92        reply_rx
93            .recv()
94            .map_err(|_| "engine thread stopped before answering".to_string())?
95    }
96
97    /// The most recently published snapshot.
98    pub fn snapshot(&self) -> Snapshot {
99        match self.latest.lock() {
100            Ok(g) => g.clone(),
101            Err(poisoned) => poisoned.into_inner().clone(),
102        }
103    }
104
105    /// Hand the engine a fresh audio device after a failure. Fire and
106    /// forget, like `send`: the daemon learns the outcome through the next
107    /// published snapshot.
108    pub fn replace_sink(&self, sink: Box<dyn AudioSink>) {
109        let _ = self.tx.send(Msg::ReplaceSink(sink));
110    }
111
112    /// Whether the engine thread has exited, for any reason: `shutdown` was
113    /// called, a `Command::Shutdown` arrived over the channel (e.g. from a
114    /// D-Bus `Quit` call), or the thread panicked. Lets the daemon's main
115    /// loop notice a dead engine without having to poll `shutdown` itself.
116    pub fn has_shut_down(&self) -> bool {
117        self.shut_down.load(Ordering::Acquire)
118    }
119
120    /// Ask the engine thread to stop, and wait for it. Safe to call more
121    /// than once, including concurrently from two clones: only the first
122    /// caller to take the `JoinHandle` actually joins it, and every other
123    /// caller's `Shutdown` send is a harmless no-op once the thread has
124    /// already exited (the channel is simply dropped along with it).
125    pub fn shutdown(&self) {
126        let _ = self.tx.send(Msg::Shutdown);
127        let handle = match self.thread.lock() {
128            Ok(mut g) => g.take(),
129            Err(poisoned) => poisoned.into_inner().take(),
130        };
131        if let Some(h) = handle {
132            let _ = h.join();
133        }
134    }
135}
136
137/// Marks `shut_down` true when dropped, on any exit from `run`'s loop --
138/// a normal `break` as well as a panic unwinding out of the loop body.
139/// `engine.tick()` calls into `Synthesizer::phonemize`/`synth` and
140/// `AudioSink` methods, none of which are `catch_unwind`-wrapped (and
141/// `phonemize` has no `Result` to fail through anyway), so a panic there
142/// unwinds straight past a plain `shut_down.store(true, ..)` placed after
143/// the loop. Tying the store to `Drop` instead means it runs no matter how
144/// the stack unwinds.
145struct ShutDownOnDrop(Arc<AtomicBool>);
146
147impl Drop for ShutDownOnDrop {
148    fn drop(&mut self) {
149        self.0.store(true, Ordering::Release);
150    }
151}
152
153fn run(
154    mut engine: Engine,
155    rx: Receiver<Msg>,
156    published: Arc<Mutex<Snapshot>>,
157    shut_down: Arc<AtomicBool>,
158) {
159    let _guard = ShutDownOnDrop(shut_down);
160
161    loop {
162        match rx.recv_timeout(TICK_INTERVAL) {
163            Ok(Msg::Cmd(c)) => engine.handle(c),
164            Ok(Msg::Submit(job)) => {
165                let (text, opts, reply) = *job;
166                let r = engine.submit(text, opts);
167                let _ = reply.send(r);
168            }
169            Ok(Msg::ReplaceSink(sink)) => engine.replace_sink(sink),
170            // An explicit `Msg::Shutdown` breaks immediately, skipping the
171            // final `tick()` and publish below; a `Command::Shutdown` sent
172            // through `send()` instead falls through to `tick()` and is
173            // only caught by `is_shutdown()` afterwards. Harmless either
174            // way -- the engine and sink are dropped either way -- but the
175            // two routes are not identical.
176            Ok(Msg::Shutdown) => break,
177            Err(RecvTimeoutError::Timeout) => {}
178            Err(RecvTimeoutError::Disconnected) => break,
179        }
180
181        engine.tick();
182
183        match published.lock() {
184            Ok(mut g) => *g = engine.snapshot(),
185            Err(poisoned) => *poisoned.into_inner() = engine.snapshot(),
186        }
187
188        if engine.is_shutdown() {
189            break;
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::audio::VecSink;
198    use crate::config::Config;
199    use crate::engine::{Command, SayOpts, State};
200    use crate::synth::StubSynthesizer;
201    use std::time::{Duration, Instant};
202
203    fn handle() -> EngineHandle {
204        EngineHandle::spawn(
205            Config::default(),
206            Box::new(StubSynthesizer::new()),
207            Box::new(VecSink::new(24_000 * 10)),
208        )
209    }
210
211    /// Poll until `f` holds or the deadline passes.
212    fn wait_for(h: &EngineHandle, label: &str, f: impl Fn(&crate::engine::Snapshot) -> bool) {
213        let deadline = Instant::now() + Duration::from_secs(5);
214        while Instant::now() < deadline {
215            if f(&h.snapshot()) {
216                return;
217            }
218            std::thread::sleep(Duration::from_millis(5));
219        }
220        panic!("timed out waiting for {label}; snapshot = {:?}", h.snapshot());
221    }
222
223    #[test]
224    fn spawns_idle_and_reports_a_snapshot() {
225        let h = handle();
226        assert_eq!(h.snapshot().state, State::Idle);
227        h.shutdown();
228    }
229
230    #[test]
231    fn submit_returns_the_engines_answer() {
232        let h = handle();
233        let id = h.submit("hello there.".into(), SayOpts::default()).expect("accepted");
234        assert!(id.is_some());
235        h.shutdown();
236    }
237
238    #[test]
239    fn submit_propagates_a_rejection() {
240        let h = EngineHandle::spawn(
241            Config { max_chars: 5, ..Config::default() },
242            Box::new(StubSynthesizer::new()),
243            Box::new(VecSink::new(24_000)),
244        );
245        assert!(h.submit("much too long".into(), SayOpts::default()).is_err());
246        h.shutdown();
247    }
248
249    #[test]
250    fn the_engine_ticks_on_its_own_thread() {
251        let h = handle();
252        h.submit("hello there. this is a test.".into(), SayOpts::default()).expect("accepted");
253        wait_for(&h, "speaking", |s| s.state == State::Speaking);
254        h.shutdown();
255    }
256
257    #[test]
258    fn commands_reach_the_engine() {
259        let h = handle();
260        h.submit("hello there. this is a test.".into(), SayOpts::default()).expect("accepted");
261        wait_for(&h, "speaking", |s| s.state == State::Speaking);
262        h.send(Command::Stop);
263        wait_for(&h, "idle after stop", |s| s.state == State::Idle && s.queue_len == 0);
264        h.shutdown();
265    }
266
267    #[test]
268    fn the_handle_is_clonable_and_shared_across_threads() {
269        let h = handle();
270        let h2 = h.clone();
271        let t = std::thread::spawn(move || {
272            h2.submit("from another thread.".into(), SayOpts::default())
273        });
274        let r = t.join().expect("thread panicked");
275        assert!(r.expect("accepted").is_some());
276        h.shutdown();
277    }
278
279    #[test]
280    fn shutdown_joins_the_thread_and_does_not_hang() {
281        let h = handle();
282        let start = Instant::now();
283        h.shutdown();
284        assert!(start.elapsed() < Duration::from_secs(5), "shutdown hung");
285    }
286
287    #[test]
288    fn snapshot_after_shutdown_does_not_panic() {
289        let h = handle();
290        let h2 = h.clone();
291        h.shutdown();
292        // The engine thread is gone; the last published snapshot is still readable.
293        let _ = h2.snapshot();
294    }
295
296    #[test]
297    fn shutdown_twice_on_the_same_handle_does_not_hang_or_panic() {
298        let h = handle();
299        h.shutdown();
300        let start = Instant::now();
301        h.shutdown();
302        assert!(start.elapsed() < Duration::from_secs(5), "second shutdown hung");
303    }
304
305    #[test]
306    fn concurrent_shutdown_from_two_clones_does_not_hang_or_panic() {
307        let h = handle();
308        let h2 = h.clone();
309        let t1 = std::thread::spawn(move || h.shutdown());
310        let t2 = std::thread::spawn(move || h2.shutdown());
311        t1.join().expect("shutdown panicked on handle 1");
312        t2.join().expect("shutdown panicked on handle 2");
313    }
314
315    fn assert_send_sync<T: Send + Sync>() {}
316
317    #[test]
318    fn handle_is_send_and_sync() {
319        assert_send_sync::<EngineHandle>();
320    }
321
322    #[test]
323    fn replace_sink_forwards_to_the_engine() {
324        // A failed sink should be swappable for a fresh one without
325        // restarting the engine thread.
326        let h = EngineHandle::spawn(
327            Config { max_chars: 5, ..Config::default() },
328            Box::new(StubSynthesizer::new()),
329            Box::new(VecSink::new(24_000)),
330        );
331        // Push the engine into Error via a rejection.
332        assert!(h.submit("much too long".into(), SayOpts::default()).is_err());
333        wait_for(&h, "error", |s| s.state == State::Error);
334
335        h.replace_sink(Box::new(VecSink::new(24_000 * 10)));
336        wait_for(&h, "idle after replace_sink", |s| s.state == State::Idle);
337
338        // max_chars is still 5, so keep this within the limit.
339        let id = h.submit("hi.".into(), SayOpts::default()).expect("accepted");
340        assert!(id.is_some());
341        h.shutdown();
342    }
343
344    #[test]
345    fn has_shut_down_is_false_until_shutdown_completes() {
346        let h = handle();
347        assert!(!h.has_shut_down());
348        h.shutdown();
349        assert!(h.has_shut_down());
350    }
351
352    #[test]
353    fn has_shut_down_becomes_true_after_a_shutdown_command_over_the_channel() {
354        let h = handle();
355        assert!(!h.has_shut_down());
356        h.send(Command::Shutdown);
357
358        let deadline = Instant::now() + Duration::from_secs(5);
359        while Instant::now() < deadline {
360            if h.has_shut_down() {
361                return;
362            }
363            std::thread::sleep(Duration::from_millis(5));
364        }
365        panic!("timed out waiting for has_shut_down() to become true");
366    }
367
368    #[test]
369    fn has_shut_down_becomes_true_when_the_engine_thread_panics() {
370        // A `Synthesizer` whose `phonemize` panics: `phonemize` runs inside
371        // `engine.tick()` on the engine thread, has no `Result` to fail
372        // through, and nothing wraps the loop body in `catch_unwind`. The
373        // panic unwinds the engine thread. `has_shut_down()` must still
374        // become true -- that is what lets a daemon loop notice a crashed
375        // engine rather than waiting on it forever.
376        struct PhonemizePanics;
377        impl crate::synth::Synthesizer for PhonemizePanics {
378            fn phonemize(&mut self, _text: &str, _voice: &str) -> String {
379                panic!("PhonemizePanics: synthesizer exploded on purpose");
380            }
381            fn fits(&mut self, _phonemes: &str) -> bool {
382                true
383            }
384            fn synth(
385                &mut self,
386                _phonemes: &str,
387                _voice: &str,
388                _speed: f32,
389            ) -> Result<Vec<f32>, String> {
390                Ok(Vec::new())
391            }
392            fn unload(&mut self) {}
393            fn is_loaded(&self) -> bool {
394                true
395            }
396        }
397
398        // The panic below is expected and its message is uninteresting; a
399        // std test binary still prints it to stderr via the default hook on
400        // every run. Swap in a no-op hook for the duration of this test so
401        // that output stays quiet, and restore the previous hook
402        // immediately after so a genuine panic elsewhere in the suite still
403        // prints normally.
404        let previous_hook = std::panic::take_hook();
405        std::panic::set_hook(Box::new(|_| {}));
406
407        let h = EngineHandle::spawn(
408            Config::default(),
409            Box::new(PhonemizePanics),
410            Box::new(VecSink::new(24_000 * 10)),
411        );
412        // Queuing succeeds -- `submit` only enqueues the text; `phonemize`
413        // is not called until a later `tick()` picks the utterance up off
414        // the queue, on the engine thread.
415        let _ = h.submit("hello there.".into(), SayOpts::default());
416
417        let deadline = Instant::now() + Duration::from_secs(5);
418        let result = loop {
419            if h.has_shut_down() {
420                break Ok(());
421            }
422            if Instant::now() >= deadline {
423                break Err(());
424            }
425            std::thread::sleep(Duration::from_millis(5));
426        };
427
428        std::panic::set_hook(previous_hook);
429        assert!(
430            result.is_ok(),
431            "timed out waiting for has_shut_down() to become true after a panic"
432        );
433    }
434
435    #[test]
436    fn submit_after_shutdown_returns_an_error_instead_of_hanging() {
437        // Pins the explicitly-named attack scenario: a `submit` issued after
438        // the engine thread is gone must come back with an error promptly,
439        // not block forever. Run on its own thread with a timeout so a
440        // regression fails loudly instead of hanging the test suite.
441        let h = handle();
442        h.shutdown();
443
444        let h2 = h.clone();
445        let (done_tx, done_rx) = mpsc::channel();
446        std::thread::spawn(move || {
447            let _ = done_tx.send(h2.submit("hello there.".into(), SayOpts::default()));
448        });
449
450        match done_rx.recv_timeout(Duration::from_secs(5)) {
451            Ok(r) => assert!(
452                r.is_err(),
453                "submit after shutdown should be rejected, got {r:?}"
454            ),
455            Err(_) => panic!("submit after shutdown hung instead of returning promptly"),
456        }
457    }
458}