mnemo_engine/embed_cmd.rs
1//! [`EmbedCmd`] — spawn a child process once, speak a line-delimited
2//! JSON protocol to it, reuse it for every subsequent embed (Phase 3,
3//! PR 10b + 10b.2).
4//!
5//! Motivation: a Python embedder loading `sentence-transformers` can
6//! take seconds just to import, so the obvious design — spawn one
7//! child per embed — makes MCP unusably slow. Instead the child runs
8//! once per [`crate::Mnemo`] handle, reads a line of JSON per request
9//! from stdin, writes a line of JSON per response to stdout, and keeps
10//! running until the handle is dropped (closing stdin, which the
11//! child uses as its exit signal).
12//!
13//! ### Protocol
14//! - **Input (one per line):** a JSON-encoded string. Newlines inside
15//! the string are escaped as `\n` by JSON, so framing is unambiguous.
16//! - **Output (one per line):** a JSON array of floats, in the same
17//! order as the inputs.
18//! - The child **must `flush()` stdout after each line** or the next
19//! [`EmbedCmd::embed`] call will time out (default 60s, configurable
20//! via [`EmbedCmd::with_timeout`] and the `--embed-timeout` CLI
21//! flag).
22//! - **Stdout is reserved for replies only.** Any line written to
23//! stdout outside a reply (startup banners, log messages, warnings)
24//! is treated as a protocol violation — EmbedCmd restarts the child
25//! and surfaces a clear error. Log to **stderr** instead; stderr is
26//! inherited to the parent and is safe to use.
27//! - Dimensionality is enforced per-vector; wrong-width or malformed
28//! replies cause the child to be killed and respawned on the next
29//! call, so no mismatched vector is ever returned.
30//!
31//! ### Reference embedder (Python + sentence-transformers)
32//! ```python
33//! import sys, json
34//! from sentence_transformers import SentenceTransformer
35//! m = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
36//! for line in sys.stdin:
37//! text = json.loads(line) # one string
38//! v = m.encode([text], normalize_embeddings=True)[0].tolist()
39//! print(json.dumps(v), flush=True) # one JSON array
40//! # Any logs: print("…", file=sys.stderr, flush=True)
41//! ```
42//! Then start the engine with:
43//! `mnemo serve --mcp agent.mnemo --embed-cmd "python3 embedder.py" --embed-name "sentence-transformers/all-MiniLM-L6-v2"`
44//!
45//! ### Correctness guarantees (PR 10b.2)
46//! - **Real timeouts.** A background thread owns the child's stdout
47//! and drains lines into a channel; [`EmbedCmd::embed`] reads via
48//! `recv_timeout`, so a silent-but-alive child trips the timeout
49//! deterministically. The reader thread also keeps stdout drained
50//! while we write stdin, which prevents the large-batch deadlock a
51//! write-all-then-read-all design would have.
52//! - **Never a mismatched vector.** Any protocol error (write failure,
53//! timeout, EOF, malformed JSON, wrong width, zero vector, or
54//! unsolicited stdout output before a request) kills the child, drops
55//! the reader thread, and marks the adapter for restart. The next
56//! [`EmbedCmd::embed`] call spawns a fresh child + reader thread
57//! with the same argv/env/timeout before writing anything, and logs
58//! one line to stderr saying it restarted and why. The alternative
59//! — reading leftover output as a new reply — would silently
60//! corrupt recall quality, which is strictly worse than any error.
61
62use std::io::{BufRead, BufReader, Write};
63use std::process::{Child, ChildStdin, Command, Stdio};
64use std::sync::mpsc::{self, Receiver, RecvTimeoutError, TryRecvError};
65use std::sync::Mutex;
66use std::thread::JoinHandle;
67use std::time::{Duration, Instant};
68
69use crate::embed::{Embedder, EmbedderIdentity};
70use crate::error::{MnemoError, Result};
71
72/// Default per-request timeout. Set generously because a cold-start
73/// `sentence-transformers` call can genuinely take a few seconds the
74/// first time; configurable via [`EmbedCmd::with_timeout`] (Rust API)
75/// or the `--embed-timeout` CLI flag.
76pub const DEFAULT_EMBED_TIMEOUT: Duration = Duration::from_secs(60);
77
78/// What the reader thread sends over the channel. One `Line` per
79/// stdout line the child wrote, then exactly one terminal `Eof` or
80/// `Err(_)` and the thread exits.
81enum ReaderMsg {
82 Line(String),
83 Eof,
84 Err(String),
85}
86
87/// Persistent-child-process adapter for a user-supplied embedder
88/// command. See the module docs for the protocol.
89pub struct EmbedCmd {
90 identity: EmbedderIdentity,
91 /// Running-child state lives inside a `Mutex` so [`Embedder`] can
92 /// be `Sync` (the engine holds the embedder as `Arc<dyn Embedder>`
93 /// and the trait's `embed(&self, …)` is called via `&self`). The
94 /// lock is only held for the duration of one batch, which the
95 /// `&mut Mnemo` serialization already enforces at the call site.
96 state: Mutex<Option<ChildState>>,
97 timeout: Duration,
98 /// Pretty command string for error messages — the raw argv as
99 /// parsed, joined back for display. Not used for execution.
100 cmd_display: String,
101 /// Argv the next spawn will use. Captured from `cmd` at
102 /// construction so a respawn after error reuses identical args.
103 argv: Vec<String>,
104}
105
106/// All the handles belonging to one running child + its reader thread.
107struct ChildState {
108 child: Child,
109 stdin: ChildStdin,
110 /// Receives each stdout line (or the terminal `Eof`/`Err`) from
111 /// the reader thread. Dropping the sender inside the thread lets
112 /// `recv_timeout` detect EOF after the terminal message.
113 stdout_rx: Receiver<ReaderMsg>,
114 /// Reader thread handle, kept for `Drop`. Joining is best-effort —
115 /// the thread exits once the child's stdout closes, which happens
116 /// when we kill the child in [`EmbedCmd::teardown`].
117 reader: Option<JoinHandle<()>>,
118}
119
120impl EmbedCmd {
121 /// Spawn `cmd` (a shell-style command string: `"python3 embedder.py"`
122 /// or `"my-tool --model=foo"`) and prepare it to accept embed
123 /// requests. The command is split by whitespace and single/double
124 /// quotes — see [`shlex_split`] — and the program is launched
125 /// directly, **not** through a shell. On Windows this means
126 /// forward-slash paths work; on Unix it means no `~` expansion
127 /// and no environment-variable substitution.
128 ///
129 /// `identity` is the [`EmbedderIdentity`] this adapter will report;
130 /// it is matched against the database's manifest by
131 /// [`crate::Mnemo::set_embedder`]. Pick values that describe what
132 /// the child process actually computes — mismatched identities
133 /// would corrupt recall silently.
134 pub fn new(cmd: &str, identity: EmbedderIdentity) -> Result<Self> {
135 let argv = shlex_split(cmd).ok_or_else(|| {
136 MnemoError::Invalid(format!(
137 "could not parse embed-cmd (unbalanced quote?): {cmd:?}"
138 ))
139 })?;
140 if argv.is_empty() {
141 return Err(MnemoError::Invalid(
142 "embed-cmd is empty — pass a program path".into(),
143 ));
144 }
145 let state = spawn_child(&argv)?;
146 Ok(Self {
147 identity,
148 state: Mutex::new(Some(state)),
149 timeout: DEFAULT_EMBED_TIMEOUT,
150 cmd_display: cmd.to_string(),
151 argv,
152 })
153 }
154
155 /// Replace the per-request timeout. See [`DEFAULT_EMBED_TIMEOUT`].
156 pub fn with_timeout(mut self, timeout: Duration) -> Self {
157 self.timeout = timeout;
158 self
159 }
160}
161
162impl Drop for EmbedCmd {
163 fn drop(&mut self) {
164 if let Ok(mut slot) = self.state.lock() {
165 if let Some(st) = slot.take() {
166 teardown(st);
167 }
168 }
169 }
170}
171
172/// Kill the child, best-effort reap, drop the stdin / receiver, and
173/// join the reader thread. Used both on `Drop` and whenever a protocol
174/// error marks the adapter for restart.
175fn teardown(mut st: ChildState) {
176 let _ = st.stdin.flush();
177 // Dropping stdin by moving it out ends the child's own read loop
178 // on EOF for well-behaved embedders.
179 drop(st.stdin);
180 // Give the child a brief moment to exit on its own.
181 let _ = st.child.try_wait();
182 // Then kill and reap. `wait()` is required to avoid a zombie.
183 let _ = st.child.kill();
184 let _ = st.child.wait();
185 // Dropping the receiver unblocks the reader thread's send on the
186 // next line (channel closed → send errors → thread exits). Joining
187 // is best-effort: if the thread was blocked in `read_line` and the
188 // child-killed stdout close didn't wake it fast enough, we don't
189 // want to hold Drop indefinitely.
190 drop(st.stdout_rx);
191 if let Some(j) = st.reader.take() {
192 // Discard the join result — any panic in the reader thread is
193 // already behind us; we can't propagate it from Drop anyway.
194 let _ = j.join();
195 }
196}
197
198/// Spawn one child + reader thread. Returns a fresh [`ChildState`].
199fn spawn_child(argv: &[String]) -> Result<ChildState> {
200 let (program, args) = argv.split_first().expect("argv non-empty by caller");
201 let mut child = Command::new(program)
202 .args(args)
203 .stdin(Stdio::piped())
204 .stdout(Stdio::piped())
205 .stderr(Stdio::inherit())
206 .spawn()
207 .map_err(|e| {
208 MnemoError::Invalid(format!(
209 "could not spawn embed-cmd {program:?}: {e} \
210 (program must be on PATH and executable)"
211 ))
212 })?;
213 let stdin = child.stdin.take().expect("stdin was piped above");
214 let stdout = BufReader::new(child.stdout.take().expect("stdout was piped above"));
215 let (tx, rx) = mpsc::channel::<ReaderMsg>();
216 let reader = std::thread::Builder::new()
217 .name("mnemo-embed-cmd-reader".into())
218 .spawn(move || {
219 let mut rdr = stdout;
220 let mut buf = String::new();
221 loop {
222 buf.clear();
223 match rdr.read_line(&mut buf) {
224 Ok(0) => {
225 let _ = tx.send(ReaderMsg::Eof);
226 break;
227 }
228 Ok(_) => {
229 if tx.send(ReaderMsg::Line(buf.clone())).is_err() {
230 // Receiver dropped — nothing to do but exit.
231 break;
232 }
233 }
234 Err(e) => {
235 let _ = tx.send(ReaderMsg::Err(e.to_string()));
236 break;
237 }
238 }
239 }
240 })
241 .map_err(|e| {
242 MnemoError::Invalid(format!("could not start embed-cmd reader thread: {e}"))
243 })?;
244 Ok(ChildState {
245 child,
246 stdin,
247 stdout_rx: rx,
248 reader: Some(reader),
249 })
250}
251
252impl EmbedCmd {
253 /// Ensure a running child is available, respawning if necessary.
254 /// On respawn, logs one stderr line explaining why.
255 fn ensure_child<'a>(
256 &self,
257 slot: &'a mut Option<ChildState>,
258 reason_if_respawn: &str,
259 ) -> Result<&'a mut ChildState> {
260 if slot.is_none() {
261 eprintln!(
262 "mnemo embed-cmd: restarting {} ({})",
263 self.cmd_display, reason_if_respawn
264 );
265 *slot = Some(spawn_child(&self.argv)?);
266 }
267 Ok(slot.as_mut().expect("just filled"))
268 }
269
270 /// Fail the current request with `msg`, kill the child, mark for
271 /// restart. The next `embed()` call spawns a fresh child.
272 fn fail_and_restart(&self, slot: &mut Option<ChildState>, msg: String) -> MnemoError {
273 if let Some(st) = slot.take() {
274 teardown(st);
275 }
276 MnemoError::Invalid(msg)
277 }
278}
279
280impl Embedder for EmbedCmd {
281 fn identity(&self) -> EmbedderIdentity {
282 self.identity.clone()
283 }
284
285 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
286 if texts.is_empty() {
287 return Ok(Vec::new());
288 }
289 let mut slot = self.state.lock().map_err(|_| {
290 MnemoError::Invalid("embed-cmd state poisoned by an earlier panic".into())
291 })?;
292
293 // Respawn if the previous call tore the child down.
294 self.ensure_child(&mut slot, "previous request errored")?;
295
296 // Before writing, probe the channel. Any message waiting here
297 // is unsolicited (the previous call either read all its replies
298 // or restarted); treat it as a protocol violation. One probe
299 // is enough — the first stale message already means the pipe
300 // state is wrong, and we're going to kill + respawn anyway.
301 {
302 let st = slot.as_mut().expect("ensure_child above");
303 match st.stdout_rx.try_recv() {
304 Ok(ReaderMsg::Line(line)) => {
305 return Err(self.fail_and_restart(
306 &mut slot,
307 format!(
308 "embed-cmd {} wrote to stdout outside a reply (line: {:?}). \
309 Stdout is reserved for replies only; log to stderr instead. \
310 The child has been killed; the next call will respawn.",
311 self.cmd_display,
312 line.trim_end()
313 ),
314 ));
315 }
316 Ok(ReaderMsg::Eof) => {
317 return Err(self.fail_and_restart(
318 &mut slot,
319 format!(
320 "embed-cmd {} closed stdout before this request. \
321 The next call will respawn.",
322 self.cmd_display
323 ),
324 ));
325 }
326 Ok(ReaderMsg::Err(e)) => {
327 return Err(self.fail_and_restart(
328 &mut slot,
329 format!(
330 "embed-cmd {} stdout read error before this request: {e}. \
331 The next call will respawn.",
332 self.cmd_display
333 ),
334 ));
335 }
336 Err(TryRecvError::Empty) => {}
337 Err(TryRecvError::Disconnected) => {
338 // Reader thread gone; respawn next call.
339 return Err(self.fail_and_restart(
340 &mut slot,
341 format!(
342 "embed-cmd {} reader thread died. The next call will respawn.",
343 self.cmd_display
344 ),
345 ));
346 }
347 }
348 }
349
350 // Also probe for a dead child (OS-level reap).
351 {
352 let st = slot.as_mut().expect("ensure_child above");
353 if let Some(status) = st.child.try_wait().map_err(|e| {
354 MnemoError::Invalid(format!(
355 "could not probe embed-cmd {}: {e}",
356 self.cmd_display
357 ))
358 })? {
359 return Err(self.fail_and_restart(
360 &mut slot,
361 format!(
362 "embed-cmd {} exited before this request: {status}. \
363 The next call will respawn.",
364 self.cmd_display
365 ),
366 ));
367 }
368 }
369
370 // Write all inputs. The reader thread drains stdout in parallel
371 // so we don't deadlock on a large batch where the child's
372 // stdout fills before our stdin writes finish.
373 {
374 let st = slot.as_mut().expect("ensure_child above");
375 for t in texts {
376 let line = serde_json::to_string(t).map_err(|e| {
377 MnemoError::Invalid(format!("could not encode embed-cmd input: {e}"))
378 })?;
379 if let Err(e) = writeln!(st.stdin, "{line}") {
380 return Err(self.fail_and_restart(
381 &mut slot,
382 format!(
383 "could not write to embed-cmd {}: {e}. The next call will respawn.",
384 self.cmd_display
385 ),
386 ));
387 }
388 }
389 if let Err(e) = st.stdin.flush() {
390 return Err(self.fail_and_restart(
391 &mut slot,
392 format!(
393 "could not flush embed-cmd {}: {e}. The next call will respawn.",
394 self.cmd_display
395 ),
396 ));
397 }
398 }
399
400 // Read exactly `texts.len()` reply lines, honoring the timeout.
401 // Any failure (timeout, EOF mid-batch, malformed JSON, wrong
402 // width, zero vector) kills the child so stale leftover output
403 // can't be mis-attributed to a later call.
404 let deadline = Instant::now() + self.timeout;
405 let mut out = Vec::with_capacity(texts.len());
406 for i in 0..texts.len() {
407 let remaining = deadline
408 .checked_duration_since(Instant::now())
409 .unwrap_or(Duration::ZERO);
410 let msg = {
411 let st = slot.as_mut().expect("ensure_child above");
412 st.stdout_rx.recv_timeout(remaining)
413 };
414 let line = match msg {
415 Ok(ReaderMsg::Line(l)) => l,
416 Ok(ReaderMsg::Eof) => {
417 return Err(self.fail_and_restart(
418 &mut slot,
419 format!(
420 "embed-cmd {} closed stdout while waiting for reply {}/{}. \
421 The next call will respawn.",
422 self.cmd_display,
423 i + 1,
424 texts.len()
425 ),
426 ));
427 }
428 Ok(ReaderMsg::Err(e)) => {
429 return Err(self.fail_and_restart(
430 &mut slot,
431 format!(
432 "embed-cmd {} stdout read error on reply {}/{}: {e}. \
433 The next call will respawn.",
434 self.cmd_display,
435 i + 1,
436 texts.len()
437 ),
438 ));
439 }
440 Err(RecvTimeoutError::Timeout) => {
441 return Err(self.fail_and_restart(
442 &mut slot,
443 format!(
444 "embed-cmd {} timed out after {:?} waiting for reply {}/{}. \
445 The next call will respawn.",
446 self.cmd_display,
447 self.timeout,
448 i + 1,
449 texts.len()
450 ),
451 ));
452 }
453 Err(RecvTimeoutError::Disconnected) => {
454 return Err(self.fail_and_restart(
455 &mut slot,
456 format!(
457 "embed-cmd {} reader thread exited mid-request. \
458 The next call will respawn.",
459 self.cmd_display
460 ),
461 ));
462 }
463 };
464 let v: Vec<f32> = match serde_json::from_str(line.trim()) {
465 Ok(v) => v,
466 Err(e) => {
467 return Err(self.fail_and_restart(
468 &mut slot,
469 format!(
470 "embed-cmd {} returned malformed JSON on reply {}/{}: {e} (line: {:?}). \
471 The next call will respawn.",
472 self.cmd_display,
473 i + 1,
474 texts.len(),
475 line.trim_end()
476 ),
477 ));
478 }
479 };
480 if v.len() != self.identity.dimensions {
481 return Err(self.fail_and_restart(
482 &mut slot,
483 format!(
484 "embed-cmd {} returned {} dims on reply {}/{} but identity says {}. \
485 The next call will respawn.",
486 self.cmd_display,
487 v.len(),
488 i + 1,
489 texts.len(),
490 self.identity.dimensions
491 ),
492 ));
493 }
494 // FIX-1 R-001.6: the module doc (Correctness guarantees)
495 // already promises "any protocol error (… zero vector …)
496 // kills the child", but the check was missing before this
497 // PR — a buggy embedder returning `[0, 0, 0, …]` or
498 // `[NaN, …]` on one text would silently poison recall
499 // (Mnemo::remember refuses all-zero itself, but a cache
500 // write via `cache_get_semantic` or a `recall --text` call
501 // happily consumed the result). Reject both shapes
502 // through the same `fail_and_restart` path as a wrong
503 // width so the child is killed, the reader thread drops,
504 // and the next call spawns a fresh child.
505 if v.iter().all(|&x| x == 0.0) {
506 return Err(self.fail_and_restart(
507 &mut slot,
508 format!(
509 "embed-cmd {} returned an all-zero vector on reply {}/{} \
510 (an all-zero vector cannot be scored by cosine or dot-product \
511 similarity; the module docs promise this is a protocol error). \
512 The next call will respawn.",
513 self.cmd_display,
514 i + 1,
515 texts.len()
516 ),
517 ));
518 }
519 if v.iter().any(|x| !x.is_finite()) {
520 return Err(self.fail_and_restart(
521 &mut slot,
522 format!(
523 "embed-cmd {} returned a non-finite value (NaN or ±∞) on \
524 reply {}/{}. The next call will respawn.",
525 self.cmd_display,
526 i + 1,
527 texts.len()
528 ),
529 ));
530 }
531 out.push(v);
532 }
533 Ok(out)
534 }
535}
536
537/// Minimal shell-style tokenizer: splits on whitespace, honors
538/// single and double quotes, no backslash-escaping, no shell
539/// expansions. Returns `None` on an unbalanced quote.
540///
541/// Enough for the embedder-cmd use case where users pass something
542/// like `python3 embedder.py --model foo` or
543/// `"C:\Program Files\myembed.exe" --model foo`.
544pub(crate) fn shlex_split(input: &str) -> Option<Vec<String>> {
545 let mut out: Vec<String> = Vec::new();
546 let mut cur = String::new();
547 let mut in_single = false;
548 let mut in_double = false;
549 let mut had_content = false;
550 for c in input.chars() {
551 match c {
552 '\'' if !in_double => {
553 in_single = !in_single;
554 had_content = true;
555 }
556 '"' if !in_single => {
557 in_double = !in_double;
558 had_content = true;
559 }
560 c if c.is_whitespace() && !in_single && !in_double => {
561 if had_content {
562 out.push(std::mem::take(&mut cur));
563 had_content = false;
564 }
565 }
566 c => {
567 cur.push(c);
568 had_content = true;
569 }
570 }
571 }
572 if in_single || in_double {
573 return None;
574 }
575 if had_content {
576 out.push(cur);
577 }
578 Some(out)
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn shlex_handles_bare_quoted_mixed() {
587 assert_eq!(shlex_split("a b c").unwrap(), vec!["a", "b", "c"]);
588 assert_eq!(
589 shlex_split("python3 'my script.py' --model foo").unwrap(),
590 vec!["python3", "my script.py", "--model", "foo"]
591 );
592 assert_eq!(
593 shlex_split(r#""C:\Program Files\x.exe" --flag"#).unwrap(),
594 vec![r"C:\Program Files\x.exe", "--flag"]
595 );
596 }
597
598 #[test]
599 fn shlex_rejects_unbalanced() {
600 assert!(shlex_split("python 'oops").is_none());
601 assert!(shlex_split("python \"oops").is_none());
602 }
603
604 #[test]
605 fn shlex_handles_empty() {
606 assert_eq!(shlex_split("").unwrap(), Vec::<String>::new());
607 assert_eq!(shlex_split(" ").unwrap(), Vec::<String>::new());
608 }
609}