Coverage Report

Created: 2026-09-05 23:00

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
    /// The `x-stt-secret-*` / `x-stt-option-*` pairs injected on every `/v1`
43
    /// request, per the contract's request-header section. Formed once at
44
    /// spawn from the user's settings, like the WASM transport's.
45
    context_headers: Vec<(String, String)>,
46
}
47
48
impl SubprocessBackend {
49
    /// Provision the selected model, spawn the sandboxed backend, and load it.
50
    ///
51
    /// `backend_dir` holds `backend.toml` and the `entrypoint` binary; model
52
    /// files are downloaded into `<backend_dir>/<dest>`. `device_pref` is the
53
    /// resolved accelerator (`"cpu"`, `"cuda"`, `"rocm"`, `"metal"`,
54
    /// `"vulkan"`), or empty when none resolved, which leaves the backend to
55
    /// select for itself. `context_headers` are the already-formed
56
    /// `x-stt-secret-*` / `x-stt-option-*` pairs to inject on every request.
57
    ///
58
    /// # Errors
59
    /// Returns an error if provisioning, spawning, or loading fails.
60
0
    pub async fn spawn(
61
0
        backend_dir: &Path,
62
0
        model_name: &str,
63
0
        device_pref: &str,
64
0
        tracker: Option<&Arc<crate::download_progress::DownloadProgressTracker>>,
65
0
        context_headers: Vec<(String, String)>,
66
0
    ) -> Result<Self> {
67
0
        let manifest = Manifest::load(backend_dir)?;
68
69
0
        let model = manifest
70
0
            .models
71
0
            .iter()
72
0
            .find(|m| m.name == model_name)
73
0
            .ok_or_else(|| anyhow!("model {model_name} not declared in backend.toml"))?;
74
75
        // Provision ONLY the selected model's files (lazy per model). The
76
        // tracker (when present) reports per-file and per-byte progress through
77
        // `DownloadStateManager` so the settings app's progress bar updates in
78
        // real time. Each file carries its own URL and destination; `parse`
79
        // already validated every `destination` as a safe relative path, so the
80
        // join below cannot escape the backend dir.
81
0
        let items: Vec<_> = model
82
0
            .files
83
0
            .iter()
84
0
            .map(|spec| crate::stt_models::download::DownloadItem {
85
0
                url: spec.url.clone(),
86
0
                destination: backend_dir.join(&spec.destination),
87
0
                sha256: spec.sha256.clone(),
88
0
            })
89
0
            .collect();
90
0
        info!(
91
            "provisioning {model_name}: {} files into {}",
92
0
            items.len(),
93
0
            backend_dir.display()
94
        );
95
0
        crate::stt_models::download::download_files(&items, tracker, 0)
96
0
            .await
97
0
            .with_context(|| format!("provisioning {model_name}"))?;
98
99
        // All files are on disk. Spawning the sandboxed unit and loading
100
        // weights onto the device is the slow tail (tens of seconds for a
101
        // multi-GB model on GPU) but isn't byte-tracked — flip the tracker
102
        // to "loading_model" so the settings app swaps the full download
103
        // bar for a "Loading model into memory…" indicator instead of
104
        // freezing on a full bar.
105
0
        if let Some(t) = tracker {
106
0
            t.mark_loading();
107
0
            t.broadcast_progress();
108
0
        }
109
110
        // Socket under the runtime dir (pathname socket — survives PrivateNetwork).
111
        // Route through the shared validated helper so it gets the same
112
        // traversal/prefix/length guards as the daemon's own sockets, instead
113
        // of a raw `$XDG_RUNTIME_DIR` join (Tier 2 #7).
114
        //
115
        // Keyed by backend directory *and* model, not by model alone: the
116
        // daemon runs two backend instances at once (the transcription model
117
        // and the post-processor), and two backends may legitimately serve the
118
        // same model name. Keyed by model alone, the second spawn's
119
        // `remove_file` below would unlink the live instance's socket and
120
        // either teardown would take out the other's.
121
0
        let instance = instance_key(backend_dir, model_name);
122
0
        let socket =
123
0
            super_stt_shared::validation::secure_runtime_path(&format!("backends/{instance}.sock"));
124
0
        let socket_dir = socket.parent().map_or_else(
125
0
            || PathBuf::from("/tmp/stt/backend/list"),
126
            std::path::Path::to_path_buf,
127
        );
128
0
        std::fs::create_dir_all(&socket_dir)?;
129
0
        let _ = std::fs::remove_file(&socket);
130
131
0
        let binary = backend_dir.join(&manifest.backend.entrypoint);
132
0
        anyhow::ensure!(
133
0
            binary.exists(),
134
            "backend binary not found: {}",
135
0
            binary.display()
136
        );
137
138
        // Same key as the socket, for the same reason — `systemd-run --unit=`
139
        // fails outright when the name is already taken. The
140
        // `super-stt-backend-` prefix is load-bearing: `cleanup_orphan_units`
141
        // sweeps by it at daemon startup.
142
0
        let unit = format!("super-stt-backend-{instance}-{}", std::process::id());
143
144
0
        systemd::spawn_systemd_unit(
145
0
            &unit,
146
0
            &binary,
147
0
            backend_dir,
148
0
            &socket_dir,
149
0
            &socket,
150
0
            &model.supported_devices,
151
0
        )
152
0
        .await?;
153
154
0
        let interval = model
155
0
            .processing_interval_ms
156
0
            .map_or_else(|| Duration::from_secs(2), Duration::from_millis);
157
0
        let info = ModelInfoData::new(
158
0
            model_name,
159
0
            manifest.backend.source.clone(),
160
0
            model.multilingual,
161
0
            model.is_online(),
162
0
            interval,
163
        );
164
165
0
        let mut backend = Self {
166
0
            socket,
167
0
            unit,
168
0
            model_id: model_name.to_string(),
169
0
            info,
170
0
            device: "unknown".to_string(),
171
0
            context_headers,
172
0
        };
173
174
0
        backend.wait_for_ping(Duration::from_secs(30)).await?;
175
0
        backend
176
0
            .load(model_name, model.provider.as_deref(), device_pref)
177
0
            .await?;
178
0
        Ok(backend)
179
0
    }
180
181
    /// Poll `/v1/ping` until the backend is serving or the deadline passes.
182
0
    async fn wait_for_ping(&self, timeout: Duration) -> Result<()> {
183
0
        let deadline = std::time::Instant::now() + timeout;
184
        loop {
185
0
            if let Ok((200, _)) = self.request("GET", "/v1/ping", &[], Vec::new()).await {
186
0
                return Ok(());
187
0
            }
188
0
            if std::time::Instant::now() >= deadline {
189
0
                bail!(
190
                    "backend did not start within {timeout:?}.\n{}",
191
0
                    self.unit_logs()
192
                );
193
0
            }
194
0
            tokio::time::sleep(Duration::from_millis(200)).await;
195
        }
196
0
    }
197
198
    /// `POST /v1/load` then poll `/v1/status` until `ready` (or `error`),
199
    /// capturing the device label the backend reports.
200
0
    async fn load(&mut self, name: &str, provider: Option<&str>, device_pref: &str) -> Result<()> {
201
0
        let body = serde_json::to_vec(&load_body(name, provider, device_pref))?;
202
0
        let (status, resp) = self
203
0
            .request("POST", "/v1/load", &json_headers(), body)
204
0
            .await?;
205
0
        anyhow::ensure!(
206
0
            status == 202 || status == 200,
207
            "/v1/load returned {status}: {}",
208
0
            String::from_utf8_lossy(&resp)
209
        );
210
211
        // Loading the model onto the GPU can take a while.
212
0
        let deadline = std::time::Instant::now() + Duration::from_mins(10);
213
        loop {
214
0
            let (_, resp) = self.request("GET", "/v1/status", &[], Vec::new()).await?;
215
0
            let json: serde_json::Value = serde_json::from_slice(&resp)?;
216
0
            match json.get("state").and_then(|v| v.as_str()) {
217
0
                Some("ready") => {
218
0
                    let device = json.get("device").and_then(|v| v.as_str()).unwrap_or("?");
219
0
                    info!("backend ready (device={device})");
220
0
                    self.device = device.to_string();
221
0
                    return Ok(());
222
                }
223
0
                Some("error") => bail!(
224
                    "backend load failed: {}",
225
0
                    json.get("reason")
226
0
                        .and_then(|v| v.as_str())
227
0
                        .unwrap_or("unknown")
228
                ),
229
0
                _ => {}
230
            }
231
0
            if std::time::Instant::now() >= deadline {
232
0
                bail!("backend load timed out");
233
0
            }
234
0
            tokio::time::sleep(Duration::from_millis(500)).await;
235
        }
236
0
    }
237
238
    /// One HTTP request over the backend's Unix socket, carrying `headers`
239
    /// plus the secret/option context every `/v1` request gets.
240
0
    async fn request(
241
0
        &self,
242
0
        method: &str,
243
0
        path: &str,
244
0
        headers: &[(String, String)],
245
0
        body: Vec<u8>,
246
0
    ) -> Result<(u16, Vec<u8>)> {
247
0
        let stream = UnixStream::connect(&self.socket)
248
0
            .await
249
0
            .with_context(|| format!("connect {}", self.socket.display()))?;
250
0
        let io = TokioIo::new(stream);
251
0
        let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
252
0
        tokio::spawn(async move {
253
0
            let _ = conn.await;
254
0
        });
255
256
0
        let mut builder = hyper::Request::builder()
257
0
            .method(method)
258
0
            .uri(path)
259
0
            .header("host", "backend.local");
260
0
        for (k, v) in headers.iter().chain(&self.context_headers) {
261
0
            builder = builder.header(k.as_str(), v.as_str());
262
0
        }
263
0
        let req = builder.body(Full::new(Bytes::from(body)))?;
264
265
0
        let resp = sender.send_request(req).await?;
266
0
        let status = resp.status().as_u16();
267
0
        let bytes = resp.into_body().collect().await?.to_bytes().to_vec();
268
0
        Ok((status, bytes))
269
0
    }
270
271
    /// Capture recent unit logs for diagnostics.
272
0
    fn unit_logs(&self) -> String {
273
0
        std::process::Command::new("journalctl")
274
0
            .args(["--user", "-u", &self.unit, "--no-pager", "-n", "30"])
275
0
            .output()
276
0
            .ok()
277
0
            .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
278
0
            .unwrap_or_default()
279
0
    }
280
}
281
282
impl Drop for SubprocessBackend {
283
0
    fn drop(&mut self) {
284
        // Best-effort: stop the transient unit (SIGTERM) and remove the socket.
285
        //
286
        // `Drop` is synchronous, so we call `std::process::Command` directly;
287
        // it blocks the runtime worker thread while `systemctl --user stop`
288
        // waits for the unit to exit (usually under a second). Surfaces the
289
        // result so a failure doesn't silently leave the subprocess running
290
        // — the previous `let _ = …` swallowed every error, which made the
291
        // "backend not stopping" failure mode invisible.
292
0
        match std::process::Command::new("systemctl")
293
0
            .args(["--user", "stop", &self.unit])
294
0
            .status()
295
        {
296
0
            Ok(status) if status.success() => {
297
0
                info!("stopped backend unit {}", self.unit);
298
            }
299
0
            Ok(status) => {
300
0
                warn!(
301
                    "systemctl --user stop {} exited with {}; subprocess may still be running",
302
                    self.unit, status,
303
                );
304
            }
305
0
            Err(e) => {
306
0
                warn!("failed to invoke systemctl to stop {}: {e}", self.unit);
307
            }
308
        }
309
0
        let _ = std::fs::remove_file(&self.socket);
310
0
    }
311
}
312
313
impl ModelInfo for SubprocessBackend {
314
0
    fn info(&self) -> &ModelInfoData {
315
0
        &self.info
316
0
    }
317
}
318
319
impl ModelState for SubprocessBackend {
320
    /// Device label the backend reported at load time (e.g. `"cuda"`).
321
0
    fn device(&self) -> String {
322
0
        self.device.clone()
323
0
    }
324
}
325
326
#[async_trait]
327
impl Transcribe for SubprocessBackend {
328
    /// Stop the `systemd-run --user` transient unit asynchronously and
329
    /// remove the socket file. Called by the daemon before the
330
    /// [`LoadedModel`](crate::daemon::types::LoadedModel) is dropped — gives
331
    /// us a real `.await` instead of blocking the runtime in `Drop`. After
332
    /// this returns, the synchronous `Drop` impl is effectively a no-op
333
    /// (the unit is already stopped) and stays for crash paths and tests.
334
0
    async fn shutdown(&mut self) -> Result<()> {
335
        let status = tokio::process::Command::new("systemctl")
336
            .args(["--user", "stop", &self.unit])
337
            .status()
338
            .await
339
0
            .with_context(|| format!("invoke systemctl to stop {}", self.unit))?;
340
        if status.success() {
341
            info!("stopped backend unit {}", self.unit);
342
        } else {
343
            warn!(
344
                "systemctl --user stop {} exited with {status}; subprocess may still be running",
345
                self.unit,
346
            );
347
        }
348
        let _ = std::fs::remove_file(&self.socket);
349
        Ok(())
350
0
    }
351
352
    async fn transcribe_audio(
353
        &mut self,
354
        audio: &[f32],
355
        sample_rate: u32,
356
        language: Option<&str>,
357
0
    ) -> Result<String> {
358
        // The daemon owns resampling; backends receive 16 kHz.
359
        let audio16 = resample(audio, sample_rate, SAMPLE_RATE, ResampleQuality::Fast)?;
360
        let body = crate::stt_models::v1::build_transcribe_body(&audio16, SAMPLE_RATE, language)?;
361
        let mut headers = json_headers();
362
        headers.push(("x-stt-model".to_string(), self.model_id.clone()));
363
        let (status, resp) = self
364
            .request("POST", "/v1/transcribe", &headers, body)
365
            .await?;
366
        crate::stt_models::v1::parse_transcribe_response(status, &resp)
367
0
    }
368
369
0
    async fn process_text(&mut self, text: &str, language: Option<&str>) -> Result<String> {
370
        let body = crate::stt_models::v1::build_process_body(text, language)?;
371
        let mut headers = json_headers();
372
        headers.push(("x-stt-model".to_string(), self.model_id.clone()));
373
        let (status, resp) = self.request("POST", "/v1/process", &headers, body).await?;
374
        crate::stt_models::v1::parse_process_response(status, &resp)
375
0
    }
376
}
377
378
0
fn json_headers() -> Vec<(String, String)> {
379
0
    vec![("content-type".to_string(), "application/json".to_string())]
380
0
}
381
382
/// Build the `POST /v1/load` body. `name` is always present; `device` only
383
/// when the daemon resolved an accelerator to name, and `provider` only when
384
/// the model's manifest declares one.
385
///
386
/// `provider` is a compatibility echo (see [`ModelEntry::provider`]): backends
387
/// released against the earlier `(name, provider)` identity answer
388
/// `400 invalid_model` for a load body that omits it, so whatever the manifest
389
/// declares is forwarded verbatim.
390
///
391
/// [`ModelEntry::provider`]: crate::stt_models::backends::manifest::ModelEntry::provider
392
18
fn load_body(name: &str, provider: Option<&str>, device_pref: &str) -> serde_json::Value {
393
18
    let mut load = serde_json::json!({ "name": name });
394
18
    if let Some(
provider4
) = provider {
395
4
        load["provider"] = serde_json::json!(provider);
396
14
    }
397
18
    if !device_pref.is_empty() {
398
14
        load["device"] = serde_json::json!(device_pref);
399
14
    
}4
400
18
    load
401
18
}
402
403
/// Longest instance key that still leaves room for the socket path.
404
///
405
/// A pathname Unix socket must fit in `sun_path` — 108 bytes on Linux,
406
/// including the terminator. The prefix is
407
/// `$XDG_RUNTIME_DIR/stt/backends/` (about 30 bytes for the usual
408
/// `/run/user/<uid>`) and the suffix is `.sock`, so 64 leaves comfortable
409
/// headroom. Keys are almost always far shorter; this bounds the tail case,
410
/// since a backend `id` — which names the install directory — may be up to
411
/// 255 bytes on its own.
412
const MAX_INSTANCE_KEY: usize = 64;
413
414
/// The name that identifies one running backend instance — its socket file and
415
/// its systemd unit. Derived from the backend's install directory and the model
416
/// it serves, so the daemon's two concurrent instances (transcription model and
417
/// post-processor) never collide, including when two backends serve the same
418
/// model name.
419
///
420
/// A key over [`MAX_INSTANCE_KEY`] is truncated with a hash of the full value
421
/// appended, so an over-long backend id yields a short name that is still
422
/// unique and still the same on every spawn — rather than a socket path the
423
/// kernel refuses to bind.
424
12
fn instance_key(backend_dir: &Path, model_name: &str) -> String {
425
12
    let dir = backend_dir
426
12
        .file_name()
427
12
        .map_or_else(String::new, |n| sanitize(&n.to_string_lossy()));
428
12
    let key = format!("{dir}-{}", sanitize(model_name));
429
12
    if key.len() <= MAX_INSTANCE_KEY {
430
6
        return key;
431
6
    }
432
6
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
433
6
    std::hash::Hash::hash(&key, &mut hasher);
434
6
    let digest = format!("{:016x}", std::hash::Hasher::finish(&hasher));
435
    // `MAX_INSTANCE_KEY` total: the truncated head, a separator, and the digest.
436
6
    let head = &key[..MAX_INSTANCE_KEY - digest.len() - 1];
437
6
    format!("{head}-{digest}")
438
12
}
439
440
24
fn sanitize(s: &str) -> String {
441
24
    s.chars()
442
1.77k
        .
map24
(|c| if c.is_ascii_alphanumeric() {
c1.75k
} else {
'-'24
})
443
24
        .collect()
444
24
}
445
446
#[cfg(test)]
447
#[path = "mod_tests.rs"]
448
mod tests;