use std::ffi::OsString;
use std::process::Stdio;
use std::sync::Arc;
use car_inference::stream::StreamEvent;
use car_inference::tasks::generate::GenerateRequest;
use car_inference::{
InferenceConfig, InferenceEngine, InferenceError, InferenceResult, LocalGenerationOffload,
};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader, Lines};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::Mutex;
pub const WORKER_ENV: &str = "CAR_INFERENCE_WORKER";
#[derive(Serialize, Deserialize)]
enum WorkerRequest {
Generate { request: Box<GenerateRequest> },
Stream { request: Box<GenerateRequest> },
}
#[derive(Serialize, Deserialize)]
enum WorkerResponse {
Result(Box<InferenceResult>),
Event(Box<StreamEvent>),
StreamEnd,
Error(String),
}
enum Exchange<T> {
Ok(T),
Reported(String),
Dead(String),
}
struct WorkerProc {
child: Child,
stdin: ChildStdin,
stdout: Lines<BufReader<ChildStdout>>,
}
impl WorkerProc {
fn kill(mut self) {
let _ = self.child.start_kill();
}
}
#[derive(Clone)]
pub struct WorkerOffload {
inner: Arc<Mutex<Option<WorkerProc>>>,
program: OsString,
args: Arc<Vec<OsString>>,
}
impl WorkerOffload {
pub fn new() -> std::io::Result<Self> {
let exe = std::env::current_exe()?;
Ok(Self::with_command(
exe,
vec![OsString::from("--mlx-worker")],
))
}
pub fn with_command(program: impl Into<OsString>, args: Vec<OsString>) -> Self {
Self {
inner: Arc::new(Mutex::new(None)),
program: program.into(),
args: Arc::new(args),
}
}
fn spawn(&self) -> Result<WorkerProc, InferenceError> {
let mut cmd = Command::new(&self.program);
cmd.args(self.args.iter())
.env(WORKER_ENV, "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| {
InferenceError::InferenceFailed(format!("failed to spawn inference worker: {e}"))
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| InferenceError::InferenceFailed("worker stdin unavailable".into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| InferenceError::InferenceFailed("worker stdout unavailable".into()))?;
Ok(WorkerProc {
child,
stdin,
stdout: BufReader::new(stdout).lines(),
})
}
fn take_or_spawn(&self, slot: &mut Option<WorkerProc>) -> Result<WorkerProc, InferenceError> {
if let Some(mut p) = slot.take() {
match p.child.try_wait() {
Ok(None) => return Ok(p),
_ => p.kill(),
}
}
self.spawn()
}
}
async fn write_line<W, T>(w: &mut W, value: &T) -> std::io::Result<()>
where
W: AsyncWrite + Unpin,
T: Serialize,
{
let mut line = serde_json::to_string(value).map_err(std::io::Error::other)?;
line.push('\n');
w.write_all(line.as_bytes()).await?;
w.flush().await
}
async fn do_generate(proc: &mut WorkerProc, request: GenerateRequest) -> Exchange<InferenceResult> {
let req = WorkerRequest::Generate {
request: Box::new(request),
};
if let Err(e) = write_line(&mut proc.stdin, &req).await {
return Exchange::Dead(format!("write to inference worker failed: {e}"));
}
match proc.stdout.next_line().await {
Ok(Some(line)) => match serde_json::from_str::<WorkerResponse>(&line) {
Ok(WorkerResponse::Result(ir)) => Exchange::Ok(*ir),
Ok(WorkerResponse::Error(msg)) => Exchange::Reported(msg),
Ok(_) => Exchange::Dead("inference worker sent an unexpected response frame".into()),
Err(e) => Exchange::Dead(format!("inference worker sent invalid JSON: {e}")),
},
Ok(None) => Exchange::Dead("inference worker exited mid-request (EOF)".into()),
Err(e) => Exchange::Dead(format!("read from inference worker failed: {e}")),
}
}
#[async_trait::async_trait]
impl LocalGenerationOffload for WorkerOffload {
async fn generate(&self, request: GenerateRequest) -> Result<InferenceResult, InferenceError> {
let mut slot = self.inner.lock().await;
let mut proc = self.take_or_spawn(&mut slot)?;
match do_generate(&mut proc, request).await {
Exchange::Ok(ir) => {
*slot = Some(proc); Ok(ir)
}
Exchange::Reported(msg) => {
*slot = Some(proc);
Err(InferenceError::InferenceFailed(msg))
}
Exchange::Dead(msg) => {
proc.kill();
Err(InferenceError::InferenceFailed(format!(
"on-device inference worker crashed and was restarted; \
this request failed but the daemon is up: {msg}"
)))
}
}
}
async fn stream(
&self,
request: GenerateRequest,
) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, InferenceError> {
let mut guard = self.inner.clone().lock_owned().await;
let mut proc = self.take_or_spawn(&mut guard)?;
{
let req = WorkerRequest::Stream {
request: Box::new(request),
};
if let Err(e) = write_line(&mut proc.stdin, &req).await {
proc.kill(); return Err(InferenceError::InferenceFailed(format!(
"write to inference worker failed: {e}"
)));
}
}
let (tx, rx) = tokio::sync::mpsc::channel::<StreamEvent>(64);
tokio::spawn(async move {
let mut clean = false;
loop {
match proc.stdout.next_line().await {
Ok(Some(line)) => match serde_json::from_str::<WorkerResponse>(&line) {
Ok(WorkerResponse::Event(ev)) => {
if tx.send(*ev).await.is_err() {
break; }
}
Ok(WorkerResponse::StreamEnd) => {
clean = true;
break;
}
Ok(WorkerResponse::Error(msg)) => {
tracing::warn!(error = %msg, "inference worker stream error");
let _ = tx.send(StreamEvent::StopReason("error".into())).await;
break;
}
Ok(WorkerResponse::Result(_)) | Err(_) => {
let _ = tx.send(StreamEvent::StopReason("error".into())).await;
break;
}
},
Ok(None) | Err(_) => {
let _ = tx.send(StreamEvent::StopReason("error".into())).await;
break;
}
}
}
if clean {
*guard = Some(proc);
} else {
proc.kill();
}
});
Ok(rx)
}
}
pub async fn run_mlx_worker() {
std::env::set_var(WORKER_ENV, "1");
let engine = Arc::new(InferenceEngine::new(InferenceConfig::default()));
let mut lines = BufReader::new(tokio::io::stdin()).lines();
let mut stdout = tokio::io::stdout();
while let Ok(Some(line)) = lines.next_line().await {
let line = line.trim();
if line.is_empty() {
continue;
}
let req: WorkerRequest = match serde_json::from_str(line) {
Ok(r) => r,
Err(e) => {
let _ = write_line(
&mut stdout,
&WorkerResponse::Error(format!("malformed worker request: {e}")),
)
.await;
continue;
}
};
match req {
WorkerRequest::Generate { request } => {
let resp = match engine.generate_tracked(*request).await {
Ok(ir) => WorkerResponse::Result(Box::new(ir)),
Err(e) => WorkerResponse::Error(e.to_string()),
};
if write_line(&mut stdout, &resp).await.is_err() {
break; }
}
WorkerRequest::Stream { request } => {
match engine.generate_tracked_stream(*request).await {
Ok(mut tracked) => {
let mut broke = false;
while let Some(ev) = tracked.events.recv().await {
if write_line(&mut stdout, &WorkerResponse::Event(Box::new(ev)))
.await
.is_err()
{
broke = true;
break;
}
}
if broke {
break;
}
if write_line(&mut stdout, &WorkerResponse::StreamEnd)
.await
.is_err()
{
break;
}
}
Err(e) => {
if write_line(&mut stdout, &WorkerResponse::Error(e.to_string()))
.await
.is_err()
{
break;
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const RESULT_LINE: &str = r#"{"Result":{"text":"pong","tool_calls":[],"trace_id":"t","model_used":"stub","latency_ms":1,"usage":null}}"#;
#[test]
fn envelope_serde_round_trips() {
let req = WorkerRequest::Generate {
request: Box::new(GenerateRequest {
prompt: "hi".into(),
..Default::default()
}),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.starts_with(r#"{"Generate":"#), "got {json}");
assert!(matches!(
serde_json::from_str::<WorkerRequest>(&json).unwrap(),
WorkerRequest::Generate { .. }
));
assert!(matches!(
serde_json::from_str::<WorkerResponse>(RESULT_LINE).unwrap(),
WorkerResponse::Result(_)
));
let ev = WorkerResponse::Event(Box::new(StreamEvent::TextDelta("hi".into())));
let ev_json = serde_json::to_string(&ev).unwrap();
assert_eq!(ev_json, r#"{"Event":{"TextDelta":"hi"}}"#);
assert_eq!(
serde_json::to_string(&WorkerResponse::StreamEnd).unwrap(),
r#""StreamEnd""#
);
let sr = StreamEvent::StopReason("length".into());
let sr2: StreamEvent = serde_json::from_str(&serde_json::to_string(&sr).unwrap()).unwrap();
assert!(matches!(sr2, StreamEvent::StopReason(s) if s == "length"));
}
#[cfg(unix)]
fn sh(script: &str) -> WorkerOffload {
WorkerOffload::with_command("sh", vec![OsString::from("-c"), OsString::from(script)])
}
#[cfg(unix)]
#[tokio::test]
async fn generate_round_trips_and_reuses_the_worker() {
let off = sh(&format!(
"while IFS= read -r line; do printf '%s\\n' '{RESULT_LINE}'; done"
));
let r1 = off.generate(GenerateRequest::default()).await.unwrap();
assert_eq!(r1.text, "pong");
let r2 = off.generate(GenerateRequest::default()).await.unwrap();
assert_eq!(r2.text, "pong");
}
#[cfg(unix)]
#[tokio::test]
async fn worker_crash_fails_one_call_gracefully_then_respawns() {
let off = sh("exit 1");
let e1 = off.generate(GenerateRequest::default()).await.unwrap_err();
assert!(
e1.to_string().contains("crashed and was restarted"),
"unexpected error: {e1}"
);
let e2 = off.generate(GenerateRequest::default()).await.unwrap_err();
assert!(
e2.to_string().contains("crashed and was restarted"),
"unexpected error: {e2}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn stream_forwards_events_then_closes() {
let off = sh("IFS= read -r line; \
printf '%s\\n' '{\"Event\":{\"TextDelta\":\"he\"}}'; \
printf '%s\\n' '{\"Event\":{\"TextDelta\":\"llo\"}}'; \
printf '%s\\n' '\"StreamEnd\"'; \
while IFS= read -r l; do :; done");
let mut rx = off.stream(GenerateRequest::default()).await.unwrap();
let mut got = String::new();
while let Some(ev) = rx.recv().await {
if let StreamEvent::TextDelta(t) = ev {
got.push_str(&t);
}
}
assert_eq!(got, "hello");
}
#[cfg(unix)]
#[tokio::test]
async fn stream_worker_death_closes_with_error_end() {
let off = sh("IFS= read -r line; printf '%s\\n' '{\"Event\":{\"TextDelta\":\"partial\"}}'");
let mut rx = off.stream(GenerateRequest::default()).await.unwrap();
let mut saw_text = false;
let mut saw_error_end = false;
while let Some(ev) = rx.recv().await {
match ev {
StreamEvent::TextDelta(t) if t == "partial" => saw_text = true,
StreamEvent::StopReason(s) if s == "error" => saw_error_end = true,
_ => {}
}
}
assert!(saw_text, "should have forwarded the partial delta");
assert!(
saw_error_end,
"mid-stream death should surface an error end"
);
}
}