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 | } |