Add local spoken passages with Google retrieval and isolated runtime setup
This commit is contained in:
1 parent
bc9459785d
commit
dab703413e
55 files changed
+3633
-70
No files matched your search
@@ -0,0 +1,150 @@
|
||||
//! Verify every isolated runtime code artifact before starting a worker.
|
||||
use super::{file_hash, Device, UPSTREAM};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
schema: u32,
|
||||
upstream_commit: String,
|
||||
device: String,
|
||||
distribution: bool,
|
||||
source_hashes: BTreeMap<String, String>,
|
||||
files: Vec<Entry>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Entry {
|
||||
path: String,
|
||||
bytes: u64,
|
||||
sha256: String,
|
||||
}
|
||||
pub(crate) fn verify(worker: &Path, device: Device) -> Result<String> {
|
||||
let directory = worker
|
||||
.parent()
|
||||
.context("Missing speech runtime directory")?;
|
||||
let path = directory.join("runtime-manifest.json");
|
||||
ensure!(
|
||||
fs::symlink_metadata(&path)
|
||||
.context("Speech runtime inventory missing")?
|
||||
.file_type()
|
||||
.is_file(),
|
||||
"Runtime inventory must be a regular file"
|
||||
);
|
||||
ensure!(
|
||||
fs::metadata(&path)?.len() <= 512 * 1024,
|
||||
"Runtime inventory exceeds bound"
|
||||
);
|
||||
let bytes = fs::read(path)?;
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(&bytes).context("Invalid speech runtime inventory")?;
|
||||
ensure!(
|
||||
manifest.schema == 1 && manifest.upstream_commit == UPSTREAM,
|
||||
"Stale speech runtime inventory"
|
||||
);
|
||||
ensure!(
|
||||
matches!(manifest.device.as_str(), "auto" | "cpu")
|
||||
&& manifest.distribution == (manifest.device == "auto"),
|
||||
"Inconsistent speech distribution inventory"
|
||||
);
|
||||
ensure!(
|
||||
device == Device::Cpu || manifest.distribution,
|
||||
"CPU-only speech runtime is an internal tool"
|
||||
);
|
||||
for name in ["CMakeLists.txt", "worker.cpp", "build.py"] {
|
||||
ensure!(
|
||||
manifest.source_hashes.get(name).is_some_and(|s| hash(s)),
|
||||
"Missing speech build-input provenance"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
!manifest.files.is_empty() && manifest.files.len() <= 128,
|
||||
"Unbounded speech runtime inventory"
|
||||
);
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut total = 0u64;
|
||||
for entry in manifest.files {
|
||||
ensure!(
|
||||
!entry.path.is_empty()
|
||||
&& entry.path != "."
|
||||
&& entry.path != ".."
|
||||
&& !entry.path.contains(['/', '\\', ':'])
|
||||
&& seen.insert(entry.path.clone()),
|
||||
"Invalid speech inventory filename"
|
||||
);
|
||||
ensure!(
|
||||
entry.bytes > 0 && entry.bytes <= 256 * 1024 * 1024 && hash(&entry.sha256),
|
||||
"Invalid speech inventory artifact"
|
||||
);
|
||||
total = total
|
||||
.checked_add(entry.bytes)
|
||||
.context("Speech runtime size overflow")?;
|
||||
ensure!(
|
||||
total <= 1024 * 1024 * 1024,
|
||||
"Speech runtime exceeds size budget"
|
||||
);
|
||||
let path = directory.join(&entry.path);
|
||||
ensure!(
|
||||
fs::symlink_metadata(&path)?.file_type().is_file()
|
||||
&& fs::metadata(&path)?.len() == entry.bytes
|
||||
&& file_hash(&path)? == entry.sha256,
|
||||
"Speech runtime artifact size/SHA changed"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
worker
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.is_some_and(|name| seen.contains(name)),
|
||||
"Worker omitted from speech runtime inventory"
|
||||
);
|
||||
Ok(format!("{:x}", Sha256::digest(bytes)))
|
||||
}
|
||||
fn hash(value: &str) -> bool {
|
||||
value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
#[test]
|
||||
fn altered_dependency_or_missing_inventory_fails_before_process_launch() {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"searchi-speech-inventory-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&root).unwrap();
|
||||
let worker = root.join("worker");
|
||||
let library = root.join("ggml-cpu.dll");
|
||||
fs::write(&worker, b"structural worker fixture").unwrap();
|
||||
fs::write(&library, b"structural library fixture").unwrap();
|
||||
assert!(verify(&worker, Device::Cpu).is_err());
|
||||
let artifact = |path: &Path| json!({"path":path.file_name().unwrap().to_str().unwrap(),"bytes":fs::metadata(path).unwrap().len(),"sha256":file_hash(path).unwrap()});
|
||||
let sources: BTreeMap<_, _> = ["CMakeLists.txt", "worker.cpp", "build.py"]
|
||||
.into_iter()
|
||||
.map(|s| (s, "a".repeat(64)))
|
||||
.collect();
|
||||
let manifest = json!({"schema":1,"upstream_commit":UPSTREAM,"device":"cpu","distribution":false,"source_hashes":sources,"files":[artifact(&worker),artifact(&library)]});
|
||||
fs::write(
|
||||
root.join("runtime-manifest.json"),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let identity = verify(&worker, Device::Cpu).unwrap();
|
||||
assert_eq!(identity.len(), 64);
|
||||
assert!(verify(&worker, Device::Auto).is_err());
|
||||
fs::write(library, b"same length changed bits").unwrap();
|
||||
assert!(verify(&worker, Device::Cpu).is_err());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Local, isolated speech recognition. Google embeddings remain a separate lane.
|
||||
//! Calls consume at most thirty seconds of mono 16 kHz PCM. The service admits
|
||||
//! each call to its background inference budget; this client creates no scheduler.
|
||||
mod inventory;
|
||||
mod process;
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
pub const SAMPLE_RATE: u32 = 16000;
|
||||
pub const MAX_SAMPLES: usize = 480000;
|
||||
pub const POLICY: &str = "live-window-vad-dtw-v1;silero-v6.2.0;base;30s;vad-reset;threshold0.5;minspeech250ms;minsilence100ms;maxspeechFLT_MAX;pad30ms;vad-overlap0.1s;source-sample-intersections;beam5;bestof5;temp0;tempinc0.2;entropy2.4;logprob-1;nospeech0.6;suppressblank;no-suppress-nst;auto-language;no-context;dtw-base;no-flash;contiguous-intervals;first-last-token-points;exact-utf8";
|
||||
pub const UPSTREAM: &str = "d1be6fde11ac6e0407606b4e42fe72d34add8037";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SpeechPassage {
|
||||
pub start_ms: u32,
|
||||
pub end_ms: u32,
|
||||
pub text: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SpeechMetrics {
|
||||
pub vad_ms: f64,
|
||||
pub asr_ms: f64,
|
||||
pub load_ms: f64,
|
||||
pub transport_ms: f64,
|
||||
#[serde(default)]
|
||||
pub fallback_ms: f64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SpeechTranscription {
|
||||
pub passages: Vec<SpeechPassage>,
|
||||
pub metrics: SpeechMetrics,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Device {
|
||||
Auto,
|
||||
Cpu,
|
||||
}
|
||||
|
||||
/// One owned persistent process. Base recognition context loads only on speech.
|
||||
pub struct SpeechWorker {
|
||||
process: process::WorkerProcess,
|
||||
fingerprint: String,
|
||||
worker_path: PathBuf,
|
||||
base_path: PathBuf,
|
||||
vad_path: PathBuf,
|
||||
device: Device,
|
||||
}
|
||||
|
||||
fn verified_file(path: &Path, bytes: u64, expected: &str) -> Result<String> {
|
||||
ensure!(
|
||||
fs::metadata(path)?.is_file() && fs::metadata(path)?.len() == bytes,
|
||||
"Speech model size differs"
|
||||
);
|
||||
let actual = file_hash(path)?;
|
||||
ensure!(actual == expected, "Speech model SHA-256 differs");
|
||||
Ok(actual)
|
||||
}
|
||||
fn file_hash(path: &Path) -> Result<String> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = [0; 65536];
|
||||
loop {
|
||||
let n = file.read(&mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hash.update(&buffer[..n]);
|
||||
}
|
||||
Ok(format!("{:x}", hash.finalize()))
|
||||
}
|
||||
|
||||
impl SpeechWorker {
|
||||
/// Verify exact pinned model bytes before launching the isolated worker.
|
||||
/// Normal distributions use Auto; Windows VM tests must select Cpu.
|
||||
pub fn load(worker: &Path, base: &Path, vad: &Path, device: Device) -> Result<Self> {
|
||||
let device = if std::env::var_os("SEARCHI_FORCE_CPU").is_some_and(|v| v == "1") {
|
||||
Device::Cpu
|
||||
} else {
|
||||
device
|
||||
};
|
||||
let inventory_hash = inventory::verify(worker, device)?;
|
||||
let base_hash = verified_file(
|
||||
base,
|
||||
147951465,
|
||||
"60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe",
|
||||
)?;
|
||||
let vad_hash = verified_file(
|
||||
vad,
|
||||
885098,
|
||||
"2aa269b785eeb53a82983a20501ddf7c1d9c48e33ab63a41391ac6c9f7fb6987",
|
||||
)?;
|
||||
let meta = fs::metadata(worker)?;
|
||||
ensure!(
|
||||
meta.is_file() && meta.len() > 0 && meta.len() <= 64 * 1024 * 1024,
|
||||
"Invalid speech worker size"
|
||||
);
|
||||
let worker_hash = file_hash(worker)?;
|
||||
let fingerprint = format!(
|
||||
"{POLICY};runtime={UPSTREAM};inventory={inventory_hash};worker={worker_hash};base={base_hash};vad={vad_hash}"
|
||||
);
|
||||
let process = process::WorkerProcess::launch(worker, base, vad, device)?;
|
||||
Ok(Self {
|
||||
process,
|
||||
fingerprint,
|
||||
worker_path: worker.to_owned(),
|
||||
base_path: base.to_owned(),
|
||||
vad_path: vad.to_owned(),
|
||||
device,
|
||||
})
|
||||
}
|
||||
pub fn fingerprint(&self) -> &str {
|
||||
&self.fingerprint
|
||||
}
|
||||
pub fn transcribe(&mut self, pcm: &[f32], sample_rate: u32) -> Result<SpeechTranscription> {
|
||||
ensure!(
|
||||
sample_rate == SAMPLE_RATE && !pcm.is_empty() && pcm.len() <= MAX_SAMPLES,
|
||||
"Speech requires bounded mono 16 kHz PCM"
|
||||
);
|
||||
ensure!(pcm.iter().all(|v| v.is_finite()), "Nonfinite speech input");
|
||||
let fallback_started = Instant::now();
|
||||
match self.transcribe_once(pcm) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(error) if self.device == Device::Auto && process::recoverable(&error) => {
|
||||
// Retry only initialization/compute/transport failures. Reverify
|
||||
// assets and all runtime code before a sticky CPU replacement.
|
||||
let replacement = Self::load(
|
||||
&self.worker_path,
|
||||
&self.base_path,
|
||||
&self.vad_path,
|
||||
Device::Cpu,
|
||||
)?;
|
||||
ensure!(
|
||||
replacement.fingerprint == self.fingerprint,
|
||||
"Speech assets changed before CPU fallback"
|
||||
);
|
||||
*self = replacement;
|
||||
let fallback_ms = fallback_started.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut result = self.transcribe_once(pcm)?;
|
||||
result.metrics.fallback_ms = fallback_ms;
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
fn transcribe_once(&mut self, pcm: &[f32]) -> Result<SpeechTranscription> {
|
||||
let started = Instant::now();
|
||||
let detection = self.process.request(1, pcm)?;
|
||||
let mut metrics = SpeechMetrics::default();
|
||||
add_metrics(&mut metrics, &detection)?;
|
||||
let intervals = detection["intervals_samples"]
|
||||
.as_array()
|
||||
.context("Missing live VAD intervals")?;
|
||||
ensure!(intervals.len() <= 128, "Oversized live VAD intervals");
|
||||
let mut previous = 0;
|
||||
let mut passages = Vec::new();
|
||||
for interval in intervals {
|
||||
let pair = interval.as_array().context("Invalid VAD interval")?;
|
||||
ensure!(pair.len() == 2, "Invalid VAD interval pair");
|
||||
let start = usize::try_from(pair[0].as_u64().context("Invalid VAD start")?)?;
|
||||
let end = usize::try_from(pair[1].as_u64().context("Invalid VAD end")?)?;
|
||||
ensure!(
|
||||
start >= previous && start < end && end <= pcm.len(),
|
||||
"Invalid VAD source bounds"
|
||||
);
|
||||
previous = end;
|
||||
let recognition = self.process.request(2, &pcm[start..end])?;
|
||||
add_metrics(&mut metrics, &recognition)?;
|
||||
passages.extend(parse_passages(&recognition, start, end - start)?);
|
||||
ensure!(
|
||||
passages.len() <= 64
|
||||
&& passages.iter().map(|p| p.text.len()).sum::<usize>() <= 64 * 1024,
|
||||
"Recognized speech output exceeds window budget"
|
||||
);
|
||||
}
|
||||
let native = metrics.vad_ms + metrics.asr_ms + metrics.load_ms;
|
||||
metrics.transport_ms = (started.elapsed().as_secs_f64() * 1000.0 - native).max(0.0);
|
||||
Ok(SpeechTranscription { passages, metrics })
|
||||
}
|
||||
}
|
||||
|
||||
fn add_metrics(metrics: &mut SpeechMetrics, response: &serde_json::Value) -> Result<()> {
|
||||
for (field, target) in [
|
||||
("vad_ms", &mut metrics.vad_ms),
|
||||
("asr_ms", &mut metrics.asr_ms),
|
||||
("load_ms", &mut metrics.load_ms),
|
||||
] {
|
||||
let v = response[field].as_f64().context("Missing speech timing")?;
|
||||
ensure!(v.is_finite() && v >= 0.0, "Invalid speech timing");
|
||||
*target += v;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_passages(
|
||||
response: &serde_json::Value,
|
||||
offset_samples: usize,
|
||||
samples: usize,
|
||||
) -> Result<Vec<SpeechPassage>> {
|
||||
let rows = response["sentences"]
|
||||
.as_array()
|
||||
.context("Missing recognized sentences")?;
|
||||
ensure!(rows.len() <= 64, "Oversized recognized sentences");
|
||||
let mut previous = 0;
|
||||
let mut result = Vec::new();
|
||||
let mut text_bytes = 0;
|
||||
for row in rows {
|
||||
let start = row["start_cs"].as_u64().context("Invalid DTW start")?;
|
||||
let end = row["end_cs"].as_u64().context("Invalid DTW end")?;
|
||||
ensure!(
|
||||
start >= previous
|
||||
&& start <= end
|
||||
&& end.checked_mul(160).is_some_and(|v| v <= samples as u64),
|
||||
"Invalid DTW source point span"
|
||||
);
|
||||
previous = end;
|
||||
let text = row["text"]
|
||||
.as_str()
|
||||
.context("Invalid recognized UTF-8 text")?;
|
||||
ensure!(
|
||||
!text.trim().is_empty() && text.len() <= 4096,
|
||||
"Invalid recognized text size"
|
||||
);
|
||||
text_bytes += text.len();
|
||||
ensure!(
|
||||
text_bytes <= 64 * 1024,
|
||||
"Recognized text exceeds window budget"
|
||||
);
|
||||
let origin = offset_samples as u64 / 16;
|
||||
result.push(SpeechPassage {
|
||||
start_ms: u32::try_from(origin + start * 10)?,
|
||||
end_ms: u32::try_from(origin + end * 10)?,
|
||||
text: text.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn actual_points_may_have_zero_span_and_origin_is_applied_once() {
|
||||
let response = json!({"sentences":[{"start_cs":2,"end_cs":2,"text":" exact "}]});
|
||||
assert_eq!(
|
||||
parse_passages(&response, 16000, 16000).unwrap(),
|
||||
vec![SpeechPassage {
|
||||
start_ms: 1020,
|
||||
end_ms: 1020,
|
||||
text: " exact ".into()
|
||||
}]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn reversed_missing_outside_nonmonotonic_points_and_oversized_text_rejected() {
|
||||
for row in [
|
||||
json!({"start_cs":2,"end_cs":1,"text":"x"}),
|
||||
json!({"start_cs":0,"end_cs":101,"text":"x"}),
|
||||
json!({"start_cs":1.5,"end_cs":2,"text":"x"}),
|
||||
json!({"start_cs":0,"end_cs":2,"text":"x".repeat(4097)}),
|
||||
] {
|
||||
assert!(parse_passages(&json!({"sentences":[row]}), 0, 16000).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Bounded binary PCM transport to a single owned native process.
|
||||
use super::{Device, MAX_SAMPLES, POLICY, UPSTREAM};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
path::Path,
|
||||
process::{Child, ChildStdin, ChildStdout, Command, Stdio},
|
||||
sync::mpsc,
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
const MAX_OUTPUT: usize = 4 * 1024 * 1024;
|
||||
const DEADLINE: Duration = Duration::from_secs(600);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum NativeFailure {
|
||||
Initialization,
|
||||
Compute,
|
||||
MalformedText,
|
||||
InvalidPoints,
|
||||
Rejected,
|
||||
}
|
||||
impl NativeFailure {
|
||||
pub fn recoverable(&self) -> bool {
|
||||
matches!(self, Self::Initialization | Self::Compute)
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for NativeFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Initialization => "Speech native initialization failed",
|
||||
Self::Compute => "Speech native computation failed",
|
||||
Self::MalformedText => "Speech recognized invalid UTF-8",
|
||||
Self::InvalidPoints => "Speech recognized invalid DTW points",
|
||||
Self::Rejected => "Speech native operation rejected",
|
||||
})
|
||||
}
|
||||
}
|
||||
impl std::error::Error for NativeFailure {}
|
||||
#[derive(Debug)]
|
||||
struct ProtocolFailure;
|
||||
impl std::fmt::Display for ProtocolFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Invalid speech worker response protocol")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ProtocolFailure {}
|
||||
#[derive(Debug)]
|
||||
struct TransportFailure;
|
||||
impl std::fmt::Display for TransportFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Speech worker transport exited or timed out")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for TransportFailure {}
|
||||
pub(crate) fn recoverable(error: &anyhow::Error) -> bool {
|
||||
error.is::<TransportFailure>()
|
||||
|| error
|
||||
.downcast_ref::<NativeFailure>()
|
||||
.is_some_and(NativeFailure::recoverable)
|
||||
}
|
||||
|
||||
fn read_response<R: Read>(reader: &mut R) -> Result<Value> {
|
||||
let mut prefix = [0; 4];
|
||||
reader
|
||||
.read_exact(&mut prefix)
|
||||
.context("Speech worker closed response")?;
|
||||
let bytes = u32::from_le_bytes(prefix) as usize;
|
||||
if bytes == 0 || bytes > MAX_OUTPUT {
|
||||
return Err(ProtocolFailure.into());
|
||||
}
|
||||
let mut output = vec![0; bytes];
|
||||
reader.read_exact(&mut output)?;
|
||||
let value: Value = serde_json::from_slice(&output).map_err(|_| ProtocolFailure)?;
|
||||
if !value.is_object() {
|
||||
return Err(ProtocolFailure.into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) struct WorkerProcess {
|
||||
child: Child,
|
||||
stdin: Option<ChildStdin>,
|
||||
stdout: Option<ChildStdout>,
|
||||
stderr: Option<JoinHandle<()>>,
|
||||
failed: bool,
|
||||
distribution_auto: bool,
|
||||
}
|
||||
impl WorkerProcess {
|
||||
pub fn launch(worker: &Path, base: &Path, vad: &Path, device: Device) -> Result<Self> {
|
||||
let mut command = Command::new(worker);
|
||||
command.arg(base).arg(vad).arg(match device {
|
||||
Device::Auto => "auto",
|
||||
Device::Cpu => "cpu",
|
||||
});
|
||||
let result = Self::spawn(command, Duration::from_secs(30))?;
|
||||
ensure!(
|
||||
device == Device::Cpu || result.distribution_auto,
|
||||
"CPU-only native speech worker is an internal tool"
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
fn spawn(mut command: Command, startup_deadline: Duration) -> Result<Self> {
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000);
|
||||
}
|
||||
let mut child = command.spawn().context("Launch isolated speech worker")?;
|
||||
let stdin = child.stdin.take();
|
||||
let stdout = child.stdout.take();
|
||||
// Drain continuously without retaining transcripts or unbounded log bytes.
|
||||
let stderr = child.stderr.take().map(|mut pipe| {
|
||||
thread::spawn(move || {
|
||||
let mut buffer = [0; 4096];
|
||||
while pipe.read(&mut buffer).is_ok_and(|n| n > 0) {}
|
||||
})
|
||||
});
|
||||
let mut result = Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
failed: false,
|
||||
distribution_auto: false,
|
||||
};
|
||||
let ready = result.exchange(Vec::new(), startup_deadline)?;
|
||||
ensure!(
|
||||
ready["protocol"] == 1
|
||||
&& ready["runtime"] == UPSTREAM
|
||||
&& ready["ready"] == true
|
||||
&& ready["policy"] == POLICY,
|
||||
"Legacy or incompatible speech worker"
|
||||
);
|
||||
result.distribution_auto = ready["distribution_auto"].as_bool().unwrap_or(false);
|
||||
Ok(result)
|
||||
}
|
||||
pub fn request(&mut self, operation: u8, pcm: &[f32]) -> Result<Value> {
|
||||
ensure!(
|
||||
!self.failed && !pcm.is_empty() && pcm.len() <= MAX_SAMPLES,
|
||||
"Unavailable speech session or unbounded PCM"
|
||||
);
|
||||
let mut request = Vec::with_capacity(5 + pcm.len() * 4);
|
||||
request.push(operation);
|
||||
request.extend_from_slice(&(pcm.len() as u32).to_le_bytes());
|
||||
for value in pcm {
|
||||
request.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
let result = self.exchange(request, DEADLINE)?;
|
||||
if result["ok"] != true {
|
||||
return Err(match result["error"].as_str() {
|
||||
Some("base_load_failed" | "vad_load_failed") => NativeFailure::Initialization,
|
||||
Some("recognition_failed" | "vad_failed" | "vad_intervals_failed") => {
|
||||
NativeFailure::Compute
|
||||
}
|
||||
Some("malformed_recognition_utf8") => NativeFailure::MalformedText,
|
||||
Some("invalid_dtw_point") => NativeFailure::InvalidPoints,
|
||||
_ => NativeFailure::Rejected,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
ensure!(
|
||||
result["operation"] == operation && result["samples"] == pcm.len(),
|
||||
"Speech response identity differs"
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
fn exchange(&mut self, request: Vec<u8>, deadline: Duration) -> Result<Value> {
|
||||
let mut input = self.stdin.take().context("Missing speech input")?;
|
||||
let mut output = self.stdout.take().context("Missing speech output")?;
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let task = thread::spawn(move || {
|
||||
let result = (|| {
|
||||
if !request.is_empty() {
|
||||
input.write_all(&request)?;
|
||||
input.flush()?;
|
||||
}
|
||||
read_response(&mut output)
|
||||
})();
|
||||
let _ = sender.send((input, output, result));
|
||||
});
|
||||
let started = Instant::now();
|
||||
let result = loop {
|
||||
match receiver.recv_timeout(Duration::from_millis(20)) {
|
||||
Ok(value) => break Some(value),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
if started.elapsed() >= deadline || !matches!(self.child.try_wait(), Ok(None)) {
|
||||
break None;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break None,
|
||||
}
|
||||
};
|
||||
if result.is_none() {
|
||||
self.failed = true;
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
let _ = task.join();
|
||||
match result {
|
||||
Some((stdin, stdout, result)) => {
|
||||
self.stdin = Some(stdin);
|
||||
self.stdout = Some(stdout);
|
||||
if result.is_err() {
|
||||
self.failed = true;
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
result.map_err(|error| {
|
||||
if error.is::<std::io::Error>() {
|
||||
TransportFailure.into()
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})
|
||||
}
|
||||
None => Err(TransportFailure.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for WorkerProcess {
|
||||
fn drop(&mut self) {
|
||||
self.stdin.take();
|
||||
self.stdout.take();
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
if let Some(task) = self.stderr.take() {
|
||||
let _ = task.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn framing_rejects_oversize_truncated_and_invalid_utf8_before_json() {
|
||||
assert!(read_response(&mut &((MAX_OUTPUT as u32 + 1).to_le_bytes())[..]).is_err());
|
||||
assert!(read_response(&mut &[5, 0, 0, 0, b'{'][..]).is_err());
|
||||
assert!(read_response(&mut &[2, 0, 0, 0, 0xff, 0xff][..]).is_err());
|
||||
let mut frame = (11u32).to_le_bytes().to_vec();
|
||||
frame.extend_from_slice(b"{\"ok\":true}");
|
||||
assert_eq!(read_response(&mut frame.as_slice()).unwrap()["ok"], true);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Requires Python for actual owned subprocess transport fixtures, no inference"]
|
||||
fn actual_subprocess_timeout_and_overflow_are_reaped() {
|
||||
let python = std::env::var_os("SEARCHI_TEST_PYTHON").unwrap_or_else(|| "python".into());
|
||||
let ready=format!("import sys,struct,json,time; b=json.dumps({{'protocol':1,'ready':True,'runtime':'{UPSTREAM}','policy':'{POLICY}'}}).encode();sys.stdout.buffer.write(struct.pack('<I',len(b))+b);sys.stdout.buffer.flush();");
|
||||
let mut command = Command::new(&python);
|
||||
command.arg("-c").arg(format!("{ready}time.sleep(10)"));
|
||||
let mut process = WorkerProcess::spawn(command, Duration::from_secs(5)).unwrap();
|
||||
let error = process
|
||||
.exchange(vec![1], Duration::from_millis(50))
|
||||
.unwrap_err();
|
||||
assert!(recoverable(&error));
|
||||
assert!(process.child.try_wait().unwrap().is_some());
|
||||
assert!(process.failed);
|
||||
let mut command = Command::new(&python);
|
||||
command.arg("-c").arg(format!("{ready}sys.stdin.buffer.read(1);sys.stdout.buffer.write(struct.pack('<I',4194305));sys.stdout.buffer.flush();time.sleep(10)"));
|
||||
let mut process = WorkerProcess::spawn(command, Duration::from_secs(5)).unwrap();
|
||||
let error = process
|
||||
.exchange(vec![1], Duration::from_secs(1))
|
||||
.unwrap_err();
|
||||
assert!(!recoverable(&error));
|
||||
assert!(process.child.try_wait().unwrap().is_some());
|
||||
assert!(process.failed);
|
||||
}
|
||||
#[test]
|
||||
fn invalid_text_points_and_protocol_never_authorize_cpu_retry() {
|
||||
for error in [
|
||||
anyhow::Error::new(NativeFailure::MalformedText),
|
||||
anyhow::Error::new(NativeFailure::InvalidPoints),
|
||||
anyhow::Error::new(ProtocolFailure),
|
||||
] {
|
||||
assert!(!recoverable(&error));
|
||||
}
|
||||
assert!(recoverable(&NativeFailure::Initialization.into()));
|
||||
assert!(recoverable(&NativeFailure::Compute.into()));
|
||||
assert!(!recoverable(
|
||||
&read_response(&mut &[2, 0, 0, 0, 0xff, 0xff][..]).unwrap_err()
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user