Coverage Report

Created: 2026-07-21 00:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-daemon/src/stt_models/subprocess/mod.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! Host for STT 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), spawns the backend binary in a
7
//! hardened `systemd-run --user` transient unit, drives the `/v1` contract
8
//! over a pathname Unix socket, and presents the result through the daemon's
9
//! [`Transcribe`] trait. The backend itself is fully self-contained and shares
10
//! no code with the daemon.
11
12
use std::path::{Path, PathBuf};
13
use std::sync::Arc;
14
use std::time::Duration;
15
16
use anyhow::{Context, Result, anyhow, bail};
17
use async_trait::async_trait;
18
use bytes::Bytes;
19
use http_body_util::{BodyExt, Full};
20
use hyper_util::rt::TokioIo;
21
use log::{info, warn};
22
use tokio::net::UnixStream;
23
24
use super_stt_shared::utils::audio::{ResampleQuality, resample};
25
26
use crate::stt_models::backends::manifest::Manifest;
27
use crate::stt_models::transcribe::{ModelInfo, ModelInfoData, ModelState, Transcribe};
28
29
mod systemd;
30
pub use systemd::cleanup_orphan_units;
31
32
const SAMPLE_RATE: u32 = 16000;
33
34
/// A running, sandboxed subprocess backend usable as a [`Transcribe`] model.
35
pub struct SubprocessBackend {
36
    socket: PathBuf,
37
    unit: String,
38
    model_id: String,
39
    info: ModelInfoData,
40
    /// Device label reported by the backend's `/v1/status` (e.g. `"cuda"`).
41
    device: String,
42
}
43
44
impl SubprocessBackend {
45
    /// Provision the selected model, spawn the sandboxed backend, and load it.
46
    ///
47
    /// `backend_dir` holds `backend.toml` and the `entrypoint` binary; model
48
    /// files are downloaded into `<backend_dir>/<dest>`. `device_pref` is
49
    /// `"cpu"`, `"cuda"`, or empty (auto).
50
    ///
51
    /// # Errors
52
    /// Returns an error if provisioning, spawning, or loading fails.
53
0
    pub async fn spawn(
54
0
        backend_dir: &Path,
55
0
        model_name: &str,
56
0
        device_pref: &str,
57
0
        tracker: Option<&Arc<crate::download_progress::DownloadProgressTracker>>,
58
0
    ) -> Result<Self> {
59
0
        let manifest = Manifest::load(backend_dir)?;
60
61
0
        let model = manifest
62
0
            .models
63
0
            .iter()
64
0
            .find(|m| m.name == model_name)
65
0
            .ok_or_else(|| anyhow!("model {model_name} not declared in backend.toml"))?;
66
67
        // Provision ONLY the selected model's files (lazy per model). The
68
        // tracker (when present) reports per-file and per-byte progress through
69
        // `DownloadStateManager` so the settings app's progress bar updates in
70
        // real time. Each file carries its own URL and destination; `parse`
71
        // already validated every `destination` as a safe relative path, so the
72
        // join below cannot escape the backend dir.
73
0
        let items: Vec<_> = model
74
0
            .files
75
0
            .iter()
76
0
            .map(|spec| crate::stt_models::download::DownloadItem {
77
0
                url: spec.url.clone(),
78
0
                destination: backend_dir.join(&spec.destination),
79
0
                sha256: spec.sha256.clone(),
80
0
            })
81
0
            .collect();
82
0
        info!(
83
            "provisioning {model_name}: {} files into {}",
84
0
            items.len(),
85
0
            backend_dir.display()
86
        );
87
0
        crate::stt_models::download::download_files(&items, tracker, 0)
88
0
            .await
89
0
            .with_context(|| format!("provisioning {model_name}"))?;
90
91
        // All files are on disk. Spawning the sandboxed unit and loading
92
        // weights onto the device is the slow tail (tens of seconds for a
93
        // multi-GB model on GPU) but isn't byte-tracked — flip the tracker
94
        // to "loading_model" so the settings app swaps the full download
95
        // bar for a "Loading model into memory…" indicator instead of
96
        // freezing on a full bar.
97
0
        if let Some(t) = tracker {
98
0
            t.mark_loading();
99
0
            t.broadcast_progress();
100
0
        }
101
102
        // Socket under the runtime dir (pathname socket — survives PrivateNetwork).
103
        // Route through the shared validated helper so it gets the same
104
        // traversal/prefix/length guards as the daemon's own sockets, instead
105
        // of a raw `$XDG_RUNTIME_DIR` join (Tier 2 #7).
106
0
        let socket = super_stt_shared::validation::secure_runtime_path(&format!(
107
0
            "backends/{}.sock",
108
0
            sanitize(model_name)
109
0
        ));
110
0
        let socket_dir = socket.parent().map_or_else(
111
0
            || PathBuf::from("/tmp/stt/backends"),
112
            std::path::Path::to_path_buf,
113
        );
114
0
        std::fs::create_dir_all(&socket_dir)?;
115
0
        let _ = std::fs::remove_file(&socket);
116
117
0
        let binary = backend_dir.join(&manifest.backend.entrypoint);
118
0
        anyhow::ensure!(
119
0
            binary.exists(),
120
            "backend binary not found: {}",
121
0
            binary.display()
122
        );
123
124
0
        let unit = format!(
125
            "super-stt-backend-{}-{}",
126
0
            sanitize(model_name),
127
0
            std::process::id()
128
        );
129
130
0
        systemd::spawn_systemd_unit(&unit, &binary, backend_dir, &socket_dir, &socket).await?;
131
132
0
        let interval = model
133
0
            .processing_interval_ms
134
0
            .map_or_else(|| Duration::from_secs(2), Duration::from_millis);
135
0
        let info = ModelInfoData::new(
136
0
            model_name,
137
0
            model.provider.clone(),
138
0
            manifest.backend.source.clone(),
139
0
            model.multilingual,
140
0
            model.is_online(),
141
0
            interval,
142
        );
143
144
0
        let mut backend = Self {
145
0
            socket,
146
0
            unit,
147
0
            model_id: model_name.to_string(),
148
0
            info,
149
0
            device: "unknown".to_string(),
150
0
        };
151
152
0
        let provider_str = model.provider.to_string();
153
0
        backend.wait_for_ping(Duration::from_secs(30)).await?;
154
0
        backend.load(model_name, &provider_str, device_pref).await?;
155
0
        Ok(backend)
156
0
    }
157
158
    /// Poll `/v1/ping` until the backend is serving or the deadline passes.
159
0
    async fn wait_for_ping(&self, timeout: Duration) -> Result<()> {
160
0
        let deadline = std::time::Instant::now() + timeout;
161
        loop {
162
0
            if let Ok((200, _)) = self.request("GET", "/v1/ping", &[], Vec::new()).await {
163
0
                return Ok(());
164
0
            }
165
0
            if std::time::Instant::now() >= deadline {
166
0
                bail!(
167
                    "backend did not start within {timeout:?}.\n{}",
168
0
                    self.unit_logs()
169
                );
170
0
            }
171
0
            tokio::time::sleep(Duration::from_millis(200)).await;
172
        }
173
0
    }
174
175
    /// `POST /v1/load` then poll `/v1/status` until `ready` (or `error`),
176
    /// capturing the device label the backend reports.
177
0
    async fn load(&mut self, name: &str, provider: &str, device_pref: &str) -> Result<()> {
178
0
        let mut load = serde_json::json!({ "name": name, "provider": provider });
179
0
        if !device_pref.is_empty() {
180
0
            load["device"] = serde_json::json!(device_pref);
181
0
        }
182
0
        let body = serde_json::to_vec(&load)?;
183
0
        let (status, resp) = self
184
0
            .request("POST", "/v1/load", &json_headers(), body)
185
0
            .await?;
186
0
        anyhow::ensure!(
187
0
            status == 202 || status == 200,
188
            "/v1/load returned {status}: {}",
189
0
            String::from_utf8_lossy(&resp)
190
        );
191
192
        // Loading the model onto the GPU can take a while.
193
0
        let deadline = std::time::Instant::now() + Duration::from_mins(10);
194
        loop {
195
0
            let (_, resp) = self.request("GET", "/v1/status", &[], Vec::new()).await?;
196
0
            let json: serde_json::Value = serde_json::from_slice(&resp)?;
197
0
            match json.get("state").and_then(|v| v.as_str()) {
198
0
                Some("ready") => {
199
0
                    let device = json.get("device").and_then(|v| v.as_str()).unwrap_or("?");
200
0
                    info!("backend ready (device={device})");
201
0
                    self.device = device.to_string();
202
0
                    return Ok(());
203
                }
204
0
                Some("error") => bail!(
205
                    "backend load failed: {}",
206
0
                    json.get("reason")
207
0
                        .and_then(|v| v.as_str())
208
0
                        .unwrap_or("unknown")
209
                ),
210
0
                _ => {}
211
            }
212
0
            if std::time::Instant::now() >= deadline {
213
0
                bail!("backend load timed out");
214
0
            }
215
0
            tokio::time::sleep(Duration::from_millis(500)).await;
216
        }
217
0
    }
218
219
    /// One HTTP request over the backend's Unix socket.
220
0
    async fn request(
221
0
        &self,
222
0
        method: &str,
223
0
        path: &str,
224
0
        headers: &[(String, String)],
225
0
        body: Vec<u8>,
226
0
    ) -> Result<(u16, Vec<u8>)> {
227
0
        let stream = UnixStream::connect(&self.socket)
228
0
            .await
229
0
            .with_context(|| format!("connect {}", self.socket.display()))?;
230
0
        let io = TokioIo::new(stream);
231
0
        let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
232
0
        tokio::spawn(async move {
233
0
            let _ = conn.await;
234
0
        });
235
236
0
        let mut builder = hyper::Request::builder()
237
0
            .method(method)
238
0
            .uri(path)
239
0
            .header("host", "backend.local");
240
0
        for (k, v) in headers {
241
0
            builder = builder.header(k.as_str(), v.as_str());
242
0
        }
243
0
        let req = builder.body(Full::new(Bytes::from(body)))?;
244
245
0
        let resp = sender.send_request(req).await?;
246
0
        let status = resp.status().as_u16();
247
0
        let bytes = resp.into_body().collect().await?.to_bytes().to_vec();
248
0
        Ok((status, bytes))
249
0
    }
250
251
    /// Capture recent unit logs for diagnostics.
252
0
    fn unit_logs(&self) -> String {
253
0
        std::process::Command::new("journalctl")
254
0
            .args(["--user", "-u", &self.unit, "--no-pager", "-n", "30"])
255
0
            .output()
256
0
            .ok()
257
0
            .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
258
0
            .unwrap_or_default()
259
0
    }
260
}
261
262
impl Drop for SubprocessBackend {
263
0
    fn drop(&mut self) {
264
        // Best-effort: stop the transient unit (SIGTERM) and remove the socket.
265
        //
266
        // `Drop` is synchronous, so we call `std::process::Command` directly;
267
        // it blocks the runtime worker thread while `systemctl --user stop`
268
        // waits for the unit to exit (usually under a second). Surfaces the
269
        // result so a failure doesn't silently leave the subprocess running
270
        // — the previous `let _ = …` swallowed every error, which made the
271
        // "backend not stopping" failure mode invisible.
272
0
        match std::process::Command::new("systemctl")
273
0
            .args(["--user", "stop", &self.unit])
274
0
            .status()
275
        {
276
0
            Ok(status) if status.success() => {
277
0
                info!("stopped backend unit {}", self.unit);
278
            }
279
0
            Ok(status) => {
280
0
                warn!(
281
                    "systemctl --user stop {} exited with {}; subprocess may still be running",
282
                    self.unit, status,
283
                );
284
            }
285
0
            Err(e) => {
286
0
                warn!("failed to invoke systemctl to stop {}: {e}", self.unit);
287
            }
288
        }
289
0
        let _ = std::fs::remove_file(&self.socket);
290
0
    }
291
}
292
293
impl ModelInfo for SubprocessBackend {
294
0
    fn info(&self) -> &ModelInfoData {
295
0
        &self.info
296
0
    }
297
}
298
299
impl ModelState for SubprocessBackend {
300
    /// Device label the backend reported at load time (e.g. `"cuda"`).
301
0
    fn device(&self) -> String {
302
0
        self.device.clone()
303
0
    }
304
}
305
306
#[async_trait]
307
impl Transcribe for SubprocessBackend {
308
    /// Stop the `systemd-run --user` transient unit asynchronously and
309
    /// remove the socket file. Called by the daemon before the
310
    /// [`LoadedModel`](crate::daemon::types::LoadedModel) is dropped — gives
311
    /// us a real `.await` instead of blocking the runtime in `Drop`. After
312
    /// this returns, the synchronous `Drop` impl is effectively a no-op
313
    /// (the unit is already stopped) and stays for crash paths and tests.
314
0
    async fn shutdown(&mut self) -> Result<()> {
315
        let status = tokio::process::Command::new("systemctl")
316
            .args(["--user", "stop", &self.unit])
317
            .status()
318
            .await
319
0
            .with_context(|| format!("invoke systemctl to stop {}", self.unit))?;
320
        if status.success() {
321
            info!("stopped backend unit {}", self.unit);
322
        } else {
323
            warn!(
324
                "systemctl --user stop {} exited with {status}; subprocess may still be running",
325
                self.unit,
326
            );
327
        }
328
        let _ = std::fs::remove_file(&self.socket);
329
        Ok(())
330
0
    }
331
332
    async fn transcribe_audio(
333
        &mut self,
334
        audio: &[f32],
335
        sample_rate: u32,
336
        language: Option<&str>,
337
0
    ) -> Result<String> {
338
        // The daemon owns resampling; backends receive 16 kHz.
339
        let audio16 = resample(audio, sample_rate, SAMPLE_RATE, ResampleQuality::Fast)?;
340
        let body = crate::stt_models::v1::build_transcribe_body(&audio16, SAMPLE_RATE, language)?;
341
        let mut headers = json_headers();
342
        headers.push(("x-stt-model".to_string(), self.model_id.clone()));
343
        let (status, resp) = self
344
            .request("POST", "/v1/transcribe", &headers, body)
345
            .await?;
346
        crate::stt_models::v1::parse_transcribe_response(status, &resp)
347
0
    }
348
}
349
350
0
fn json_headers() -> Vec<(String, String)> {
351
0
    vec![("content-type".to_string(), "application/json".to_string())]
352
0
}
353
354
0
fn sanitize(s: &str) -> String {
355
0
    s.chars()
356
0
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
357
0
        .collect()
358
0
}