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