Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-tts-daemon/src/daemon/startup.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Daemon construction + startup orchestration: assembling subsystem handles,
3
//! loading + CLI-overriding the config, and kicking off the background load of
4
//! the configured startup model. Split out of `types.rs` so that file holds
5
//! just the daemon struct, its type aliases, and runtime accessors.
6
7
use crate::config::DaemonConfig;
8
use crate::daemon::events::EventBus;
9
use crate::daemon::types::{LoadedModel, SharedLoadedModel, SuperTTSDaemon, normalize_device};
10
use crate::download_progress::DownloadStateManager;
11
use crate::resource_management::ResourceManager;
12
use crate::services::dbus::DBusManager;
13
use crate::tts_models::backends;
14
use anyhow::Result;
15
use log::{info, warn};
16
use std::sync::{Arc, RwLock};
17
use tokio::sync::broadcast;
18
19
/// Pre-assembled subsystem handles created during daemon startup.
20
struct DaemonComponents {
21
    shutdown_tx: broadcast::Sender<()>,
22
    model: SharedLoadedModel,
23
    download_manager: Arc<DownloadStateManager>,
24
    resource_manager: Arc<ResourceManager>,
25
    dbus_manager: Option<Arc<DBusManager>>,
26
}
27
28
impl DaemonComponents {
29
    /// Instantiate all subsystem handles that do not depend on configuration
30
    /// values (those come from `SuperTTSDaemon::load_and_persist_config`).
31
70
    async fn init() -> Self {
32
70
        let (shutdown_tx, _) = broadcast::channel(1);
33
34
        // Model slot starts empty; filled once the startup model loads.
35
70
        let model: SharedLoadedModel = Arc::new(tokio::sync::RwLock::new(None));
36
37
70
        let download_manager = Arc::new(DownloadStateManager::new());
38
39
70
        let resource_manager = if cfg!(debug_assertions) {
40
70
            Arc::new(ResourceManager::development())
41
        } else {
42
0
            Arc::new(ResourceManager::production())
43
        };
44
45
        // D-Bus is optional; absence is non-fatal.
46
70
        let dbus_manager = match DBusManager::new().await {
47
62
            Ok(mgr) => Some(Arc::new(mgr)),
48
8
            Err(e) => {
49
8
                warn!("D-Bus initialization failed (this is normal on some systems): {e}");
50
8
                None
51
            }
52
        };
53
54
70
        Self {
55
70
            shutdown_tx,
56
70
            model,
57
70
            download_manager,
58
70
            resource_manager,
59
70
            dbus_manager,
60
70
        }
61
70
    }
62
}
63
64
impl SuperTTSDaemon {
65
    /// Create a new `SuperTTSDaemon` instance
66
    ///
67
    /// # Errors
68
    ///
69
    /// Returns an error if model loading fails.
70
70
    pub async fn new() -> Result<Self> {
71
70
        info!("Initializing Super TTS Daemon...");
72
73
70
        let config = Self::load_and_persist_config();
74
75
        // Extract config fields needed for the struct before config is moved in.
76
70
        let preferred_device = config.device.preferred_device.clone();
77
70
        let actual_device = preferred_device.clone(); // Will be updated when model loads
78
70
        let active_backend = config.synthesis.active_backend.clone();
79
70
        let audio_theme = config.audio.theme;
80
70
        let volume = config.audio.volume;
81
82
70
        let components = DaemonComponents::init().await;
83
        // Built here rather than inline so the speech engine can publish onto the
84
        // same bus `/events` subscribers read from.
85
70
        let events = Arc::new(EventBus::new());
86
70
        let voices = Arc::new(crate::voices::VoiceLibrary::new(
87
70
            crate::voices::VoiceLibrary::default_dir(),
88
        ));
89
90
70
        let daemon = SuperTTSDaemon {
91
70
            model: components.model,
92
70
            shutdown_tx: components.shutdown_tx,
93
70
            dbus_manager: components.dbus_manager,
94
70
            events: Arc::clone(&events),
95
70
            audio_theme: Arc::new(RwLock::new(audio_theme)),
96
70
            volume: Arc::new(RwLock::new(volume)),
97
70
            download_manager: components.download_manager,
98
70
            preferred_device: Arc::new(tokio::sync::RwLock::new(preferred_device)),
99
70
            actual_device: Arc::new(tokio::sync::RwLock::new(actual_device)),
100
70
            config: Arc::new(tokio::sync::RwLock::new(config)),
101
70
            resource_manager: components.resource_manager,
102
70
            backends: Arc::new(tokio::sync::RwLock::new(Vec::new())),
103
70
            active_backend: Arc::new(tokio::sync::RwLock::new(active_backend)),
104
70
            notifier: Arc::new(tokio::sync::Mutex::new(
105
70
                crate::output::notification::Notifier::dbus(),
106
70
            )),
107
70
            self_update: Arc::new(crate::self_update::checker()),
108
70
            speech: Arc::new(
109
70
                crate::daemon::speech::SpeechEngine::new()
110
70
                    .with_events(Arc::clone(&events))
111
70
                    .with_voices(Arc::clone(&voices)),
112
70
            ),
113
70
            voices,
114
70
            loading: Arc::new(super_engine_daemon::load_gate::LoadGate::default()),
115
70
        };
116
117
70
        daemon.post_init().await;
118
119
70
        Ok(daemon)
120
70
    }
121
122
    /// Load the daemon config from disk, apply any CLI overrides, and persist
123
    /// back to disk if anything changed. Returns the ready-to-use config.
124
70
    fn load_and_persist_config() -> DaemonConfig {
125
70
        let config = DaemonConfig::load();
126
70
        info!("Loaded daemon configuration from disk");
127
70
        config
128
70
    }
129
130
    /// Perform post-construction startup: discover backends, apply any
131
    /// temporary session-level device override, then kick off background
132
    /// loading of the startup model (if one is configured).
133
70
    async fn post_init(&self) {
134
        // Discover installed backends.
135
70
        self.refresh_backends().await;
136
137
        // Load the configured startup model — if any — in the **background** so
138
        // the HTTP listener can come up immediately. A model load may download
139
        // gigabytes and bind a GPU; blocking startup on it would leave clients
140
        // unable to connect (and unable to pick a lighter model) until it
141
        // finishes. Clients watch the `daemon_status_changed` SSE topic for the
142
        // `ready` transition. With no configured preference the daemon stays
143
        // idle until the user selects a model — it never auto-pulls a model.
144
70
        if let Some((
name0
,
source0
)) = self.pick_startup_model().await {
145
0
            let bg = self.clone();
146
0
            tokio::spawn(async move {
147
0
                if let Err(e) =
148
0
                    Self::load_initial_model_and_broadcast(&bg, name.clone(), source).await
149
                {
150
0
                    warn!("Failed to load startup model {name}: {e}; daemon is idle");
151
0
                    bg.download_manager.clear_download(());
152
0
                }
153
0
            });
154
        } else {
155
70
            info!("No startup model configured; daemon is idle until one is selected");
156
        }
157
158
        // Periodic self-update check: first ~60s after start (don't compete
159
        // with model load), then daily. POST /v1/update/check bypasses the
160
        // check_enabled gate; this loop honors it.
161
        {
162
70
            let bg = self.clone();
163
70
            let mut shutdown = self.shutdown_tx.subscribe();
164
70
            tokio::spawn(async move {
165
70
                tokio::select! {
166
70
                    () = tokio::time::sleep(std::time::Duration::from_mins(1)) => 
{}0
167
70
                    _ = shutdown.recv() => return,
168
                }
169
0
                let mut interval = tokio::time::interval(std::time::Duration::from_hours(24));
170
0
                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
171
                loop {
172
                    // interval fires immediately on the first tick, which is
173
                    // exactly the "check on startup" behavior we want.
174
0
                    tokio::select! {
175
0
                        _ = interval.tick() => {}
176
0
                        _ = shutdown.recv() => return,
177
                    }
178
0
                    if bg.config.read().await.update.check_enabled {
179
0
                        let _ = bg.run_self_update_check_and_notify().await;
180
0
                    }
181
                }
182
70
            });
183
        }
184
70
    }
185
186
    /// Record the backend serving `source` as the active one when nothing has
187
    /// selected one yet, returning whether it changed anything.
188
    ///
189
    /// The startup path resolves its model from the legacy
190
    /// `preferred_model`/`preferred_source` config, which
191
    /// carries no `active_backend` — a config written before that field
192
    /// existed loads it as `None`. Without this the daemon speaks
193
    /// happily while `GET /active_backend` stays null, so the settings app
194
    /// shows its "no backend loaded" empty state and `GET /models` (scoped to
195
    /// the active backend) returns nothing.
196
    ///
197
    /// Guarded on `is_none()`, so it only ever fills a gap: an explicit
198
    /// selection through `set_active_backend`/`set_model` always wins.
199
    ///
200
    /// Persisting is deliberately left to the caller. Keeping this free of
201
    /// disk I/O is what lets it be unit-tested without writing over the user's
202
    /// real config.
203
4
    async fn adopt_active_backend_for(&self, source: &str) -> bool {
204
4
        if self.active_backend.read().await.is_some() {
205
1
            return false;
206
3
        }
207
3
        let dir_name = {
208
3
            let backends = self.backends.read().await;
209
3
            backends
210
3
                .iter()
211
3
                .find(|b| b.source == source)
212
3
                .and_then(backends::dir_name)
213
        };
214
3
        let Some(
dir2
) = dir_name else {
215
1
            return false;
216
        };
217
2
        *self.active_backend.write().await = Some(dir.clone());
218
2
        self.config.write().await.synthesis.active_backend = Some(dir);
219
2
        true
220
4
    }
221
222
0
    async fn load_initial_model_and_broadcast(
223
0
        daemon: &SuperTTSDaemon,
224
0
        name: String,
225
0
        source: String,
226
0
    ) -> Result<()> {
227
0
        daemon.broadcast_model_loading_status(&name);
228
229
        // The persisted model comes back on the device it was persisted with —
230
        // its own if it has one, else the global default. Reading the default
231
        // alone would quietly undo a per-model choice on every restart.
232
0
        let device_pref = daemon.config.read().await.effective_device(&source, &name);
233
0
        let (instance, definition) = daemon
234
0
            .instantiate_backend(&name, &source, &device_pref)
235
0
            .await?;
236
237
0
        info!("model {name} loaded successfully");
238
239
0
        if daemon.adopt_active_backend_for(&source).await
240
0
            && let Err(e) = daemon.persist_config().await
241
        {
242
0
            warn!("Failed to persist active_backend after startup load: {e}");
243
0
        }
244
245
        // Capture the device label before moving `instance` into the shared
246
        // `LoadedModel` — `ready` event consumers (e.g. the settings app's
247
        // current_device tracking) only update when `actual_device` is present.
248
0
        let actual_device = normalize_device(&instance.device());
249
0
        *daemon.actual_device.write().await = actual_device.clone();
250
0
        *daemon.model.write().await = Some(LoadedModel::new(definition, instance));
251
252
        // Announce the active model the same way a user-initiated switch does
253
        // (`model_switched` + `ready`). The startup load formerly emitted only
254
        // `ready`, so a settings app reconnecting after a daemon restart never
255
        // learned which model became active and kept showing "no model loaded".
256
0
        daemon.broadcast_model_active(&name, &source, &actual_device);
257
0
        Ok(())
258
0
    }
259
}
260
261
#[cfg(test)]
262
#[path = "startup_tests.rs"]
263
mod tests;