Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-tts-daemon/src/tts_models/subprocess.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Host for TTS backends shipped as sandboxed native subprocesses
3
//! (experimental — gated behind the `subprocess-backends` feature).
4
//!
5
//! [`SubprocessBackend`] provisions a backend's model files (downloading from
6
//! `HuggingFace` into the per-backend directory), has
7
//! [`super_engine_daemon::subprocess`] spawn the backend binary into a sandbox
8
//! and load the model, and presents the result through the daemon's
9
//! [`Synthesize`] trait. The sandbox, the socket and the `/v1` handshake are
10
//! the engine's; what is here is what a synthesis backend is asked. The
11
//! backend itself is fully self-contained and shares no code with the daemon.
12
13
use std::path::Path;
14
use std::sync::Arc;
15
use std::time::Duration;
16
17
use anyhow::{Context, Result, anyhow};
18
use async_trait::async_trait;
19
use http_body_util::BodyExt;
20
use log::info;
21
use super_engine_daemon::subprocess::{self as engine, Start, json_headers};
22
use super_tts_shared::models::protocol::LoadProgress;
23
24
use crate::tts_models::backends::manifest::{FileSpec, Manifest};
25
use crate::tts_models::download::DownloadItem;
26
use crate::tts_models::synthesize::{ModelInfo, ModelInfoData, ModelState, Synthesize};
27
28
/// A running, sandboxed subprocess backend usable as a [`Synthesize`] model.
29
pub struct SubprocessBackend {
30
    backend: engine::SubprocessBackend,
31
    model_id: String,
32
    info: ModelInfoData,
33
}
34
35
impl SubprocessBackend {
36
    /// Provision the selected model, spawn the sandboxed backend, and load it.
37
    ///
38
    /// `backend_dir` holds `backend.toml` and the `entrypoint` binary; model
39
    /// files are downloaded into `<backend_dir>/<dest>`. `device_pref` is the
40
    /// resolved accelerator (`"cpu"`, `"cuda"`, `"rocm"`, `"metal"`,
41
    /// `"vulkan"`), or empty when none resolved, which leaves the backend to
42
    /// select for itself. `host` is what the machine's GPUs actually are, and
43
    /// decides which variant of a per-architecture model file is fetched —
44
    /// deliberately not `device_pref`, so choosing CPU on a CUDA machine does
45
    /// not discard the CUDA files already on disk. `context_headers` are the
46
    /// already-formed `x-tts-secret-*` / `x-tts-option-*` pairs to inject on
47
    /// every request.
48
    ///
49
    /// A model that declares `prepare_files` has those downloaded first. Its
50
    /// backend is then started and sent `POST /v1/prepare`, so it builds its
51
    /// GPU kernels while the rest of the files download, and the load after
52
    /// the download is an ordinary one.
53
    ///
54
    /// # Errors
55
    /// Returns an error if provisioning, spawning, or loading fails.
56
0
    pub async fn spawn(
57
0
        backend_dir: &Path,
58
0
        model_name: &str,
59
0
        device_pref: &str,
60
0
        host: &crate::registry::host_detect::Host,
61
0
        tracker: Option<&Arc<crate::download_progress::DownloadProgressTracker>>,
62
0
        context_headers: Vec<(String, String)>,
63
0
    ) -> Result<Self> {
64
0
        let manifest = Manifest::load(backend_dir)?;
65
66
0
        let model = manifest
67
0
            .models
68
0
            .iter()
69
0
            .find(|m| m.name == model_name)
70
0
            .ok_or_else(|| anyhow!("model {model_name} not declared in backend.toml"))?;
71
72
        // Provision ONLY the selected model's files (lazy per model). The
73
        // tracker (when present) reports per-file and per-byte progress through
74
        // `DownloadStateManager` so the settings app's progress bar updates in
75
        // real time. Each file carries its own URL and destination; `parse`
76
        // already validated every `destination` as a safe relative path, so the
77
        // join below cannot escape the backend dir.
78
        //
79
        // Entries sharing a destination are per-architecture variants of one
80
        // file, so the list is resolved against the host first: exactly one
81
        // variant per destination is fetched, and a manifest that declares no
82
        // selector — every v1 manifest — comes back as the files it wrote.
83
        // `instantiate_subprocess` resolves the same list to size the progress
84
        // card, and both go through this one function so the count and the
85
        // downloads cannot disagree.
86
        //
87
        // The files a prepare reads go first, in the order the manifest lists
88
        // them. `prepare_files` names destinations, and the selection keeps
89
        // one variant per destination, so each is matched whichever variant
90
        // this host takes.
91
0
        let selected = crate::registry::compat::select_files(host, model)
92
0
            .map_err(|reason| anyhow!("provisioning {model_name}: {reason}"))?;
93
0
        let (prepare_files, rest_files): (Vec<_>, Vec<_>) = selected
94
0
            .into_iter()
95
0
            .partition(|spec| model.prepare_files.contains(&spec.destination));
96
0
        let item = |spec: &FileSpec| DownloadItem {
97
0
            url: spec.url.clone(),
98
0
            destination: backend_dir.join(&spec.destination),
99
0
            sha256: spec.sha256.clone(),
100
0
        };
101
0
        let prepare_items: Vec<_> = prepare_files.into_iter().map(item).collect();
102
0
        let rest_items: Vec<_> = rest_files.into_iter().map(item).collect();
103
0
        info!(
104
            "provisioning {model_name}: {} files into {}",
105
0
            prepare_items.len() + rest_items.len(),
106
0
            backend_dir.display()
107
        );
108
109
        // Once every file is on disk, spawning the sandboxed unit and loading
110
        // weights onto the device is the slow tail (tens of seconds for a
111
        // multi-GB model on GPU) but isn't byte-tracked — flip the tracker
112
        // to "loading_model" so the settings app swaps the full download
113
        // bar for a "Loading model into memory…" indicator instead of
114
        // freezing on a full bar.
115
0
        let mark_loading = || {
116
0
            if let Some(t) = tracker {
117
0
                t.mark_loading();
118
0
                t.broadcast_progress();
119
0
            }
120
0
        };
121
122
0
        let start = Start {
123
0
            backend_dir,
124
0
            entrypoint: &manifest.backend.entrypoint,
125
0
            model: model_name,
126
0
            provider: model.provider.as_deref(),
127
0
            devices: &model.supported_devices,
128
0
            device_pref,
129
0
            context_headers,
130
0
        };
131
0
        let mut backend = if model.prepare_files.is_empty() {
132
0
            provision(model_name, &rest_items, tracker, 0).await?;
133
0
            mark_loading();
134
0
            engine::SubprocessBackend::start(&super_tts_shared::SUPER_TTS, start).await?
135
        } else {
136
0
            provision(model_name, &prepare_items, tracker, 0).await?;
137
            // Dropping the backend stops it, so a download that fails or is
138
            // cancelled below takes the prepare down with it.
139
0
            let backend =
140
0
                engine::SubprocessBackend::start(&super_tts_shared::SUPER_TTS, start).await?;
141
0
            backend.prepare().await;
142
0
            provision(model_name, &rest_items, tracker, prepare_items.len()).await?;
143
0
            mark_loading();
144
0
            backend
145
        };
146
147
        // What the backend reports of its own load goes to the tracker, so the
148
        // card can say what the load is doing rather than sit on a full bar.
149
0
        let forward = tracker.map(|t| {
150
0
            move |load: LoadProgress| {
151
0
                t.set_load_progress(load);
152
0
                t.broadcast_progress();
153
0
            }
154
0
        });
155
0
        backend
156
0
            .load(
157
0
                forward
158
0
                    .as_ref()
159
0
                    .map(|f| f as &(dyn Fn(LoadProgress) + Send + Sync)),
160
            )
161
0
            .await?;
162
163
0
        let interval = model
164
0
            .processing_interval_ms
165
0
            .map_or_else(|| Duration::from_secs(2), Duration::from_millis);
166
0
        let info = ModelInfoData::new(
167
0
            model_name,
168
0
            manifest.backend.source.clone(),
169
0
            model.multilingual,
170
0
            model.is_online(),
171
0
            interval,
172
        );
173
174
0
        Ok(Self {
175
0
            backend,
176
0
            model_id: model_name.to_string(),
177
0
            info,
178
0
        })
179
0
    }
180
181
    /// The headers a model request carries: the JSON body, and which model
182
    /// it is for.
183
0
    fn model_headers(&self) -> Vec<(String, String)> {
184
0
        let mut headers = json_headers();
185
0
        headers.push(("x-tts-model".to_string(), self.model_id.clone()));
186
0
        headers
187
0
    }
188
189
    /// `POST /v1/synthesize` — drive one synthesis and feed `sink` as frames
190
    /// decode.
191
    ///
192
    /// The body is read incrementally rather than collected: a subprocess
193
    /// backend writes PCM as it produces it, so the daemon can begin playing
194
    /// well before synthesis ends.
195
    ///
196
    /// # Errors
197
    /// Returns an error if the socket dial or request fails, the response is
198
    /// not `200`, the headers do not describe usable audio, or the frame stream
199
    /// is malformed.
200
0
    pub async fn synthesize(
201
0
        &self,
202
0
        req: &crate::tts_models::v1::SynthesizeRequest<'_>,
203
0
        sink: &mut (dyn crate::tts_models::v1::SynthesisSink + Send),
204
0
    ) -> Result<()> {
205
0
        let body = crate::tts_models::v1::build_synthesize_body(req)?;
206
        // The connection is held until the body has been read: the response
207
        // is not complete when its head arrives.
208
0
        let (resp, _connection) = self
209
0
            .backend
210
0
            .send("POST", "/v1/synthesize", &self.model_headers(), body)
211
0
            .await?;
212
0
        let status = resp.status().as_u16();
213
0
        let (parts, body) = resp.into_parts();
214
0
        if status == 200 {
215
0
            crate::tts_models::v1::pump_synthesis(&parts.headers, body, sink).await
216
        } else {
217
0
            let collected = body.collect().await?.to_bytes();
218
0
            Err(crate::tts_models::v1::synthesize_error(status, &collected))
219
        }
220
0
    }
221
222
    /// `POST /v1/voices` — hand the backend one cloned voice's reference
223
    /// audio.
224
    ///
225
    /// # Errors
226
    /// Returns an error if the socket dial fails or the backend refuses the
227
    /// clip.
228
0
    pub async fn register_voice(
229
0
        &self,
230
0
        req: &crate::tts_models::v1::RegisterVoiceRequest<'_>,
231
0
    ) -> Result<()> {
232
0
        let body = crate::tts_models::v1::build_register_voice_body(req)?;
233
0
        let (status, resp) = self
234
0
            .backend
235
0
            .request("POST", "/v1/voices", &self.model_headers(), body)
236
0
            .await?;
237
0
        if (200..300).contains(&status) {
238
0
            return Ok(());
239
0
        }
240
0
        Err(crate::tts_models::v1::voice_error(status, &resp))
241
0
    }
242
243
    /// `DELETE /v1/voices/{voice}` — drop a registered cloned voice.
244
    ///
245
    /// The id is percent-encoded into the path, so the `voice:` prefix travels
246
    /// as one path segment whatever a backend's router does with a raw colon.
247
    ///
248
    /// # Errors
249
    /// Returns an error if the socket dial fails or the backend refuses. A
250
    /// `404` is success: the goal is that the backend not hold the voice.
251
0
    pub async fn unregister_voice(&self, voice: &str) -> Result<()> {
252
0
        let path = format!("/v1/voices/{}", urlencoding::encode(voice));
253
0
        let (status, resp) = self
254
0
            .backend
255
0
            .request("DELETE", &path, &[], Vec::new())
256
0
            .await?;
257
0
        if status == 404 || (200..300).contains(&status) {
258
0
            return Ok(());
259
0
        }
260
0
        Err(crate::tts_models::v1::voice_error(status, &resp))
261
0
    }
262
}
263
264
/// Download `items` for `model_name`, numbering them from `first` on the
265
/// tracker so a load provisioned in two parts counts its files once.
266
0
async fn provision(
267
0
    model_name: &str,
268
0
    items: &[DownloadItem],
269
0
    tracker: Option<&Arc<crate::download_progress::DownloadProgressTracker>>,
270
0
    first: usize,
271
0
) -> Result<()> {
272
0
    crate::tts_models::download::download_files(items, tracker, first)
273
0
        .await
274
0
        .with_context(|| format!("provisioning {model_name}"))
275
0
}
276
277
impl ModelInfo for SubprocessBackend {
278
0
    fn info(&self) -> &ModelInfoData {
279
0
        &self.info
280
0
    }
281
}
282
283
impl ModelState for SubprocessBackend {
284
    /// Device label the backend reported at load time (e.g. `"cuda"`).
285
0
    fn device(&self) -> String {
286
0
        self.backend.device().to_string()
287
0
    }
288
}
289
290
#[async_trait]
291
impl Synthesize for SubprocessBackend {
292
    /// Swap the injected secret/option pairs. The next `/v1` request carries
293
    /// them; one already in flight keeps the set it was built with.
294
    ///
295
    /// `user_allowed_hosts` is ignored, and there is nothing here to ignore it
296
    /// with: the sandbox gives a subprocess backend no network, so it has no
297
    /// egress to authorize and `base_url` means nothing to it.
298
0
    fn reconfigure(&self, context: crate::tts_models::synthesize::BackendContext) {
299
0
        self.backend.set_context_headers(context.headers);
300
0
    }
301
302
    /// Forward to the inherent streaming implementation.
303
    async fn synthesize(
304
        &self,
305
        request: &crate::tts_models::v1::SynthesizeRequest<'_>,
306
        sink: &mut (dyn crate::tts_models::v1::SynthesisSink + Send),
307
0
    ) -> Result<()> {
308
        Self::synthesize(self, request, sink).await
309
0
    }
310
311
    async fn register_voice(
312
        &self,
313
        request: &crate::tts_models::v1::RegisterVoiceRequest<'_>,
314
0
    ) -> Result<()> {
315
        Self::register_voice(self, request).await
316
0
    }
317
318
0
    async fn unregister_voice(&self, voice: &str) -> Result<()> {
319
        Self::unregister_voice(self, voice).await
320
0
    }
321
322
    /// Stop the sandboxed backend asynchronously and remove the socket file.
323
    /// Called by the daemon before the
324
    /// [`LoadedModel`](crate::daemon::types::LoadedModel) is dropped — gives
325
    /// us a real `.await` instead of blocking the runtime in `Drop`. After
326
    /// this returns, the synchronous `Drop` path is a no-op and stays for
327
    /// crash paths and tests.
328
0
    async fn shutdown(&mut self) -> Result<()> {
329
        self.backend.shutdown().await;
330
        Ok(())
331
0
    }
332
}
333
334
/// Stop backends left behind by a previous daemon run. See
335
/// [`super_engine_daemon::subprocess::cleanup_orphans`].
336
70
pub async fn cleanup_orphan_units() {
337
70
    engine::cleanup_orphans(&super_tts_shared::SUPER_TTS).await;
338
70
}