use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, TryRecvError};
use std::sync::Mutex;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use crate::embed::{Embedder, EmbedderIdentity};
use crate::error::{MnemoError, Result};
pub const DEFAULT_EMBED_TIMEOUT: Duration = Duration::from_secs(60);
enum ReaderMsg {
Line(String),
Eof,
Err(String),
}
pub struct EmbedCmd {
identity: EmbedderIdentity,
state: Mutex<Option<ChildState>>,
timeout: Duration,
cmd_display: String,
argv: Vec<String>,
}
struct ChildState {
child: Child,
stdin: ChildStdin,
stdout_rx: Receiver<ReaderMsg>,
reader: Option<JoinHandle<()>>,
}
impl EmbedCmd {
pub fn new(cmd: &str, identity: EmbedderIdentity) -> Result<Self> {
let argv = shlex_split(cmd).ok_or_else(|| {
MnemoError::Invalid(format!(
"could not parse embed-cmd (unbalanced quote?): {cmd:?}"
))
})?;
if argv.is_empty() {
return Err(MnemoError::Invalid(
"embed-cmd is empty — pass a program path".into(),
));
}
let state = spawn_child(&argv)?;
Ok(Self {
identity,
state: Mutex::new(Some(state)),
timeout: DEFAULT_EMBED_TIMEOUT,
cmd_display: cmd.to_string(),
argv,
})
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
impl Drop for EmbedCmd {
fn drop(&mut self) {
if let Ok(mut slot) = self.state.lock() {
if let Some(st) = slot.take() {
teardown(st);
}
}
}
}
fn teardown(mut st: ChildState) {
let _ = st.stdin.flush();
drop(st.stdin);
let _ = st.child.try_wait();
let _ = st.child.kill();
let _ = st.child.wait();
drop(st.stdout_rx);
if let Some(j) = st.reader.take() {
let _ = j.join();
}
}
fn spawn_child(argv: &[String]) -> Result<ChildState> {
let (program, args) = argv.split_first().expect("argv non-empty by caller");
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| {
MnemoError::Invalid(format!(
"could not spawn embed-cmd {program:?}: {e} \
(program must be on PATH and executable)"
))
})?;
let stdin = child.stdin.take().expect("stdin was piped above");
let stdout = BufReader::new(child.stdout.take().expect("stdout was piped above"));
let (tx, rx) = mpsc::channel::<ReaderMsg>();
let reader = std::thread::Builder::new()
.name("mnemo-embed-cmd-reader".into())
.spawn(move || {
let mut rdr = stdout;
let mut buf = String::new();
loop {
buf.clear();
match rdr.read_line(&mut buf) {
Ok(0) => {
let _ = tx.send(ReaderMsg::Eof);
break;
}
Ok(_) => {
if tx.send(ReaderMsg::Line(buf.clone())).is_err() {
break;
}
}
Err(e) => {
let _ = tx.send(ReaderMsg::Err(e.to_string()));
break;
}
}
}
})
.map_err(|e| {
MnemoError::Invalid(format!("could not start embed-cmd reader thread: {e}"))
})?;
Ok(ChildState {
child,
stdin,
stdout_rx: rx,
reader: Some(reader),
})
}
impl EmbedCmd {
fn ensure_child<'a>(
&self,
slot: &'a mut Option<ChildState>,
reason_if_respawn: &str,
) -> Result<&'a mut ChildState> {
if slot.is_none() {
eprintln!(
"mnemo embed-cmd: restarting {} ({})",
self.cmd_display, reason_if_respawn
);
*slot = Some(spawn_child(&self.argv)?);
}
Ok(slot.as_mut().expect("just filled"))
}
fn fail_and_restart(&self, slot: &mut Option<ChildState>, msg: String) -> MnemoError {
if let Some(st) = slot.take() {
teardown(st);
}
MnemoError::Invalid(msg)
}
}
impl Embedder for EmbedCmd {
fn identity(&self) -> EmbedderIdentity {
self.identity.clone()
}
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let mut slot = self.state.lock().map_err(|_| {
MnemoError::Invalid("embed-cmd state poisoned by an earlier panic".into())
})?;
self.ensure_child(&mut slot, "previous request errored")?;
{
let st = slot.as_mut().expect("ensure_child above");
match st.stdout_rx.try_recv() {
Ok(ReaderMsg::Line(line)) => {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} wrote to stdout outside a reply (line: {:?}). \
Stdout is reserved for replies only; log to stderr instead. \
The child has been killed; the next call will respawn.",
self.cmd_display,
line.trim_end()
),
));
}
Ok(ReaderMsg::Eof) => {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} closed stdout before this request. \
The next call will respawn.",
self.cmd_display
),
));
}
Ok(ReaderMsg::Err(e)) => {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} stdout read error before this request: {e}. \
The next call will respawn.",
self.cmd_display
),
));
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} reader thread died. The next call will respawn.",
self.cmd_display
),
));
}
}
}
{
let st = slot.as_mut().expect("ensure_child above");
if let Some(status) = st.child.try_wait().map_err(|e| {
MnemoError::Invalid(format!(
"could not probe embed-cmd {}: {e}",
self.cmd_display
))
})? {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} exited before this request: {status}. \
The next call will respawn.",
self.cmd_display
),
));
}
}
{
let st = slot.as_mut().expect("ensure_child above");
for t in texts {
let line = serde_json::to_string(t).map_err(|e| {
MnemoError::Invalid(format!("could not encode embed-cmd input: {e}"))
})?;
if let Err(e) = writeln!(st.stdin, "{line}") {
return Err(self.fail_and_restart(
&mut slot,
format!(
"could not write to embed-cmd {}: {e}. The next call will respawn.",
self.cmd_display
),
));
}
}
if let Err(e) = st.stdin.flush() {
return Err(self.fail_and_restart(
&mut slot,
format!(
"could not flush embed-cmd {}: {e}. The next call will respawn.",
self.cmd_display
),
));
}
}
let deadline = Instant::now() + self.timeout;
let mut out = Vec::with_capacity(texts.len());
for i in 0..texts.len() {
let remaining = deadline
.checked_duration_since(Instant::now())
.unwrap_or(Duration::ZERO);
let msg = {
let st = slot.as_mut().expect("ensure_child above");
st.stdout_rx.recv_timeout(remaining)
};
let line = match msg {
Ok(ReaderMsg::Line(l)) => l,
Ok(ReaderMsg::Eof) => {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} closed stdout while waiting for reply {}/{}. \
The next call will respawn.",
self.cmd_display,
i + 1,
texts.len()
),
));
}
Ok(ReaderMsg::Err(e)) => {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} stdout read error on reply {}/{}: {e}. \
The next call will respawn.",
self.cmd_display,
i + 1,
texts.len()
),
));
}
Err(RecvTimeoutError::Timeout) => {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} timed out after {:?} waiting for reply {}/{}. \
The next call will respawn.",
self.cmd_display,
self.timeout,
i + 1,
texts.len()
),
));
}
Err(RecvTimeoutError::Disconnected) => {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} reader thread exited mid-request. \
The next call will respawn.",
self.cmd_display
),
));
}
};
let v: Vec<f32> = match serde_json::from_str(line.trim()) {
Ok(v) => v,
Err(e) => {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} returned malformed JSON on reply {}/{}: {e} (line: {:?}). \
The next call will respawn.",
self.cmd_display,
i + 1,
texts.len(),
line.trim_end()
),
));
}
};
if v.len() != self.identity.dimensions {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} returned {} dims on reply {}/{} but identity says {}. \
The next call will respawn.",
self.cmd_display,
v.len(),
i + 1,
texts.len(),
self.identity.dimensions
),
));
}
if v.iter().all(|&x| x == 0.0) {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} returned an all-zero vector on reply {}/{} \
(an all-zero vector cannot be scored by cosine or dot-product \
similarity; the module docs promise this is a protocol error). \
The next call will respawn.",
self.cmd_display,
i + 1,
texts.len()
),
));
}
if v.iter().any(|x| !x.is_finite()) {
return Err(self.fail_and_restart(
&mut slot,
format!(
"embed-cmd {} returned a non-finite value (NaN or ±∞) on \
reply {}/{}. The next call will respawn.",
self.cmd_display,
i + 1,
texts.len()
),
));
}
out.push(v);
}
Ok(out)
}
}
pub(crate) fn shlex_split(input: &str) -> Option<Vec<String>> {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut in_single = false;
let mut in_double = false;
let mut had_content = false;
for c in input.chars() {
match c {
'\'' if !in_double => {
in_single = !in_single;
had_content = true;
}
'"' if !in_single => {
in_double = !in_double;
had_content = true;
}
c if c.is_whitespace() && !in_single && !in_double => {
if had_content {
out.push(std::mem::take(&mut cur));
had_content = false;
}
}
c => {
cur.push(c);
had_content = true;
}
}
}
if in_single || in_double {
return None;
}
if had_content {
out.push(cur);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shlex_handles_bare_quoted_mixed() {
assert_eq!(shlex_split("a b c").unwrap(), vec!["a", "b", "c"]);
assert_eq!(
shlex_split("python3 'my script.py' --model foo").unwrap(),
vec!["python3", "my script.py", "--model", "foo"]
);
assert_eq!(
shlex_split(r#""C:\Program Files\x.exe" --flag"#).unwrap(),
vec![r"C:\Program Files\x.exe", "--flag"]
);
}
#[test]
fn shlex_rejects_unbalanced() {
assert!(shlex_split("python 'oops").is_none());
assert!(shlex_split("python \"oops").is_none());
}
#[test]
fn shlex_handles_empty() {
assert_eq!(shlex_split("").unwrap(), Vec::<String>::new());
assert_eq!(shlex_split(" ").unwrap(), Vec::<String>::new());
}
}