1use std::collections::{BTreeSet, HashMap};
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use std::sync::Arc;
36
37use serde_json::Value;
38use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
39use tokio::process::{Child, Command};
40use tokio::sync::{mpsc, oneshot, Mutex};
41
42use crate::error::HostError;
43
44const SHUTDOWN_GRACE_SECS: u64 = 5;
47
48pub const TASK_LOAD_AND_RUN_HOOK: &str = "load_and_run_hook";
50
51pub const TASK_SHUTDOWN: &str = "shutdown";
53
54pub const TASK_CAPABILITIES: &str = "capabilities";
61
62pub const FEATURE_CREDENTIAL: &str = "credential";
64
65pub const FEATURE_EXTENSIONS: &str = "extensions";
67
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct WorkerCapabilities {
75 pub protocol_version: u64,
77
78 pub features: BTreeSet<String>,
80
81 pub cpex_version: Option<String>,
87}
88
89impl WorkerCapabilities {
90 pub fn none() -> Self {
92 Self::default()
93 }
94
95 pub fn has(&self, feature: &str) -> bool {
97 self.features.contains(feature)
98 }
99}
100
101type Pending = Arc<Mutex<HashMap<String, oneshot::Sender<Result<Value, HostError>>>>>;
109
110const STDERR_RETAIN_LINES: usize = 40;
117
118type StderrTail = Arc<Mutex<std::collections::VecDeque<String>>>;
120
121const STDERR_MAX_LINE_BYTES: usize = 8 * 1024;
128
129const TRUNCATION_MARKER: &str = "… [truncated by host]";
133
134enum BoundedLine {
136 Line(String),
138
139 Oversized { prefix: String, scanned: usize },
143
144 Eof,
146}
147
148async fn read_bounded_line<R>(reader: &mut R, limit: usize) -> std::io::Result<BoundedLine>
158where
159 R: AsyncBufRead + Unpin,
160{
161 let mut buffer = Vec::new();
162 let mut scanned = 0usize;
163 let mut over = false;
164
165 loop {
166 let available = reader.fill_buf().await?;
167 if available.is_empty() {
168 if scanned == 0 {
170 return Ok(BoundedLine::Eof);
171 }
172 break;
173 }
174
175 let (chunk, consumed, done) = match available.iter().position(|b| *b == b'\n') {
176 Some(index) => (&available[..index], index + 1, true),
177 None => {
178 let all = available.len();
179 (available, all, false)
180 },
181 };
182
183 scanned += chunk.len();
184 if !over {
185 let room = limit.saturating_sub(buffer.len());
188 let keep = chunk.len().min(room);
189 buffer.extend_from_slice(&chunk[..keep]);
190 if scanned > limit {
191 over = true;
192 }
193 }
194
195 reader.consume(consumed);
196 if done {
197 break;
198 }
199 }
200
201 if buffer.last() == Some(&b'\r') {
203 buffer.pop();
204 }
205
206 let text = String::from_utf8_lossy(&buffer).into_owned();
207 if over {
208 Ok(BoundedLine::Oversized {
209 prefix: text,
210 scanned,
211 })
212 } else {
213 Ok(BoundedLine::Line(text))
214 }
215}
216
217type Alive = Arc<std::sync::atomic::AtomicBool>;
226
227pub struct WorkerClient {
229 outbound: mpsc::UnboundedSender<String>,
231
232 pending: Pending,
234
235 alive: Alive,
237
238 stderr_tail: StderrTail,
240
241 child: Mutex<Option<Child>>,
244
245 max_content_size: usize,
247
248 timeout_secs: u64,
250}
251
252impl std::fmt::Debug for WorkerClient {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 f.debug_struct("WorkerClient")
258 .field("max_content_size", &self.max_content_size)
259 .field("timeout_secs", &self.timeout_secs)
260 .finish_non_exhaustive()
261 }
262}
263
264impl WorkerClient {
265 pub async fn spawn(
270 python: &Path,
271 script_path: &Path,
272 cwd: Option<&Path>,
273 max_content_size: usize,
274 timeout_secs: u64,
275 ) -> Result<Self, HostError> {
276 if !python.exists() {
277 return Err(HostError::WorkerStart {
278 message: format!("venv interpreter not found at {}", python.display()),
279 });
280 }
281
282 let mut command = Command::new(python);
283 command
284 .arg(script_path)
285 .stdin(Stdio::piped())
286 .stdout(Stdio::piped())
287 .stderr(Stdio::piped())
288 .kill_on_drop(true);
290
291 if let Some(dir) = cwd {
292 command.current_dir(dir);
293 }
294
295 let mut child = command.spawn().map_err(|e| HostError::WorkerStart {
296 message: format!(
297 "could not spawn {} {}: {e}",
298 python.display(),
299 script_path.display()
300 ),
301 })?;
302
303 let stdin = child.stdin.take().ok_or_else(|| HostError::WorkerStart {
304 message: "worker stdin was not piped".into(),
305 })?;
306 let stdout = child.stdout.take().ok_or_else(|| HostError::WorkerStart {
307 message: "worker stdout was not piped".into(),
308 })?;
309 let stderr = child.stderr.take().ok_or_else(|| HostError::WorkerStart {
310 message: "worker stderr was not piped".into(),
311 })?;
312
313 let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
314 let alive: Alive = Arc::new(std::sync::atomic::AtomicBool::new(true));
315 let stderr_tail: StderrTail = Arc::new(Mutex::new(std::collections::VecDeque::new()));
316 let (outbound, outbound_rx) = mpsc::unbounded_channel::<String>();
317
318 tokio::spawn(writer_loop(stdin, outbound_rx));
319 tokio::spawn(reader_loop(
323 stdout,
324 Arc::clone(&pending),
325 Arc::clone(&alive),
326 max_content_size,
327 ));
328 tokio::spawn(stderr_loop(stderr, Arc::clone(&stderr_tail)));
329
330 Ok(Self {
331 outbound,
332 pending,
333 alive,
334 stderr_tail,
335 child: Mutex::new(Some(child)),
336 max_content_size,
337 timeout_secs,
338 })
339 }
340
341 async fn died(&self, context: &str) -> HostError {
346 let tail = self.stderr_tail.lock().await;
347 if tail.is_empty() {
348 return HostError::WorkerDied {
349 message: context.to_string(),
350 };
351 }
352
353 let recent: Vec<&str> = tail.iter().map(String::as_str).collect();
354 HostError::WorkerDied {
355 message: format!("{context}; recent worker stderr: {}", recent.join(" | ")),
356 }
357 }
358
359 pub async fn stderr_lines(&self) -> Vec<String> {
365 self.stderr_tail.lock().await.iter().cloned().collect()
366 }
367
368 pub fn is_alive(&self) -> bool {
375 self.alive.load(std::sync::atomic::Ordering::Acquire)
376 }
377
378 pub async fn send_task(&self, mut task: Value) -> Result<Value, HostError> {
383 if !self.is_alive() {
386 return Err(self.died("worker process is no longer running").await);
387 }
388
389 let request_id = uuid::Uuid::new_v4().to_string();
390
391 {
392 let obj = task.as_object_mut().ok_or_else(|| HostError::Protocol {
393 message: "task must be a JSON object".into(),
394 })?;
395 obj.insert("request_id".into(), Value::String(request_id.clone()));
396 }
397
398 let line = serde_json::to_string(&task).map_err(|e| HostError::Protocol {
399 message: format!("could not serialize task: {e}"),
400 })?;
401
402 if line.len() > self.max_content_size {
405 return Err(HostError::TaskTooLarge {
406 size: line.len(),
407 limit: self.max_content_size,
408 });
409 }
410
411 let (tx, rx) = oneshot::channel();
412 self.pending.lock().await.insert(request_id.clone(), tx);
413
414 if self.outbound.send(format!("{line}\n")).is_err() {
417 self.pending.lock().await.remove(&request_id);
418 return Err(self
419 .died("writer task is gone — the worker is no longer accepting tasks")
420 .await);
421 }
422
423 let response =
424 match tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), rx).await
425 {
426 Ok(Ok(Ok(response))) => response,
427 Ok(Ok(Err(e))) => return Err(e),
430 Ok(Err(_)) => {
433 return Err(self
434 .died("worker exited while the request was in flight")
435 .await)
436 },
437 Err(_) => {
438 self.pending.lock().await.remove(&request_id);
439 return Err(HostError::Timeout {
440 timeout_secs: self.timeout_secs,
441 });
442 },
443 };
444
445 interpret_response(response)
446 }
447
448 pub async fn capabilities(&self) -> Result<WorkerCapabilities, HostError> {
464 let response = match self
465 .send_task(serde_json::json!({ "task_type": TASK_CAPABILITIES }))
466 .await
467 {
468 Ok(response) => response,
469 Err(HostError::WorkerError { message }) => {
470 tracing::info!(
471 reply = %message,
472 "worker does not implement the capabilities handshake; \
473 treating it as supporting no gated wire features"
474 );
475 return Ok(WorkerCapabilities::none());
476 },
477 Err(e) => return Err(e),
478 };
479
480 let features = response
481 .get("features")
482 .and_then(Value::as_array)
483 .map(|list| {
484 list.iter()
485 .filter_map(Value::as_str)
486 .map(str::to_string)
487 .collect::<BTreeSet<String>>()
488 })
489 .unwrap_or_default();
490
491 Ok(WorkerCapabilities {
492 protocol_version: response
493 .get("protocol_version")
494 .and_then(Value::as_u64)
495 .unwrap_or(0),
496 features,
497 cpex_version: response
498 .get("cpex_version")
499 .and_then(Value::as_str)
500 .map(str::to_string),
501 })
502 }
503
504 pub async fn shutdown(&self) -> Result<(), HostError> {
510 let Some(mut child) = self.child.lock().await.take() else {
511 return Ok(());
512 };
513
514 let task = serde_json::json!({ "task_type": TASK_SHUTDOWN, "request_id": TASK_SHUTDOWN });
517 if let Ok(line) = serde_json::to_string(&task) {
518 let _ = self.outbound.send(format!("{line}\n"));
519 }
520
521 match tokio::time::timeout(
522 std::time::Duration::from_secs(SHUTDOWN_GRACE_SECS),
523 child.wait(),
524 )
525 .await
526 {
527 Ok(Ok(_status)) => Ok(()),
528 Ok(Err(e)) => Err(HostError::WorkerDied {
529 message: format!("could not wait on the worker process: {e}"),
530 }),
531 Err(_) => {
532 tracing::warn!("worker did not exit within {SHUTDOWN_GRACE_SECS}s; killing it");
535 child.kill().await.map_err(|e| HostError::WorkerDied {
536 message: format!("could not kill the unresponsive worker: {e}"),
537 })?;
538 Ok(())
539 },
540 }
541 }
542}
543
544fn interpret_response(mut response: Value) -> Result<Value, HostError> {
550 if let Some(obj) = response.as_object_mut() {
551 obj.remove("request_id");
552
553 if obj.get("status").and_then(Value::as_str) == Some("error") {
554 let message = obj
555 .get("message")
556 .and_then(Value::as_str)
557 .unwrap_or("worker reported an error with no message")
558 .to_string();
559 return Err(HostError::WorkerError { message });
560 }
561 }
562 Ok(response)
563}
564
565async fn writer_loop(
570 mut stdin: tokio::process::ChildStdin,
571 mut outbound: mpsc::UnboundedReceiver<String>,
572) {
573 while let Some(line) = outbound.recv().await {
574 if stdin.write_all(line.as_bytes()).await.is_err() {
575 tracing::debug!("worker stdin closed; writer stopping");
576 break;
577 }
578 if stdin.flush().await.is_err() {
579 tracing::debug!("worker stdin flush failed; writer stopping");
580 break;
581 }
582 }
583}
584
585async fn reader_loop(
596 stdout: tokio::process::ChildStdout,
597 pending: Pending,
598 alive: Alive,
599 limit: usize,
600) {
601 let mut reader = BufReader::new(stdout);
602
603 loop {
604 match read_bounded_line(&mut reader, limit).await {
605 Ok(BoundedLine::Line(line)) => {
606 let line = line.trim();
607 if line.is_empty() {
608 continue;
609 }
610
611 let Ok(response) = serde_json::from_str::<Value>(line) else {
612 tracing::warn!("worker emitted a non-JSON stdout line; ignoring it");
616 continue;
617 };
618
619 let Some(request_id) = response.get("request_id").and_then(Value::as_str) else {
620 tracing::warn!("worker response had no request_id; dropping it");
621 continue;
622 };
623
624 match pending.lock().await.remove(request_id) {
625 Some(tx) => {
626 let _ = tx.send(Ok(response));
628 },
629 None => {
630 tracing::debug!(request_id, "response for an unknown request id");
633 },
634 }
635 },
636 Ok(BoundedLine::Oversized { prefix, scanned }) => {
637 tracing::error!(
644 scanned,
645 limit,
646 "worker response frame exceeded max_content_size; \
647 the frame was discarded rather than buffered"
648 );
649
650 let request_id = request_id_from_prefix(&prefix);
651 let mut pending = pending.lock().await;
652 match request_id.and_then(|id| pending.remove(&id).map(|tx| (id, tx))) {
653 Some((id, tx)) => {
654 tracing::warn!(
655 request_id = %id,
656 "failing the request whose response was over the size limit"
657 );
658 let _ = tx.send(Err(response_too_large(scanned, limit)));
659 },
660 None => {
661 for (id, tx) in pending.drain() {
664 tracing::warn!(
665 request_id = %id,
666 "failing an in-flight request: an over-limit response frame was \
667 discarded and its owner could not be identified"
668 );
669 let _ = tx.send(Err(response_too_large(scanned, limit)));
670 }
671 },
672 }
673 },
674 Ok(BoundedLine::Eof) => {
675 tracing::debug!("worker stdout closed");
676 break;
677 },
678 Err(e) => {
679 tracing::warn!("error reading worker stdout: {e}");
680 break;
681 },
682 }
683 }
684
685 alive.store(false, std::sync::atomic::Ordering::Release);
689
690 pending.lock().await.clear();
693}
694
695fn response_too_large(size: usize, limit: usize) -> HostError {
703 HostError::ResponseTooLarge { size, limit }
704}
705
706fn request_id_from_prefix(prefix: &str) -> Option<String> {
714 const KEY: &str = "\"request_id\"";
715 let after_key = &prefix[prefix.find(KEY)? + KEY.len()..];
716 let after_colon = &after_key[after_key.find(':')? + 1..];
717 let opening = after_colon.find('"')?;
718 let rest = &after_colon[opening + 1..];
719 let closing = rest.find('"')?;
722 Some(rest[..closing].to_string())
723}
724
725async fn stderr_loop(stderr: tokio::process::ChildStderr, tail: StderrTail) {
748 let mut reader = BufReader::new(stderr);
749
750 loop {
751 let line = match read_bounded_line(&mut reader, STDERR_MAX_LINE_BYTES).await {
752 Ok(BoundedLine::Line(line)) => line,
753 Ok(BoundedLine::Oversized { prefix, scanned }) => {
754 tracing::warn!(
755 scanned,
756 limit = STDERR_MAX_LINE_BYTES,
757 "worker stderr line exceeded the per-line limit; retaining a truncated prefix"
758 );
759 format!("{prefix}{TRUNCATION_MARKER}")
760 },
761 Ok(BoundedLine::Eof) => break,
762 Err(e) => {
763 tracing::debug!("error reading worker stderr: {e}");
764 break;
765 },
766 };
767
768 tracing::debug!(target: "cpex_hosts_python::worker_stderr", "{line}");
769
770 let mut tail = tail.lock().await;
771 if tail.len() == STDERR_RETAIN_LINES {
772 tail.pop_front();
773 }
774 tail.push_back(line);
775 }
776}
777
778pub fn resolve_worker_script(venv_path: &Path, relative: &str) -> Result<PathBuf, HostError> {
785 let relative_path = Path::new(relative);
788 if relative_path.is_absolute() {
789 return Ok(relative_path.to_path_buf());
790 }
791
792 let mut candidates = Vec::new();
793
794 let unix_lib = venv_path.join("lib");
795 if let Ok(entries) = std::fs::read_dir(&unix_lib) {
796 for entry in entries.flatten() {
797 candidates.push(entry.path().join("site-packages").join(relative_path));
798 }
799 }
800 candidates.push(
801 venv_path
802 .join("Lib")
803 .join("site-packages")
804 .join(relative_path),
805 );
806 candidates.push(venv_path.join(relative_path));
808
809 candidates
810 .iter()
811 .find(|p| p.exists())
812 .cloned()
813 .ok_or_else(|| HostError::WorkerStart {
814 message: format!(
815 "could not find '{relative}' in the venv at {} — is `cpex` installed in it? \
816 (a plugin's requirements file must pull in the framework that ships worker.py)",
817 venv_path.display()
818 ),
819 })
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825 use crate::testing::{skip_without_python3, TempDir};
826
827 const STUB_WORKER: &str = r#"
845import json, sys, time
846
847deaf = False
848while True:
849 line = sys.stdin.readline()
850 if not line:
851 break
852 line = line.strip()
853 if not line:
854 continue
855 task = json.loads(line)
856 kind = task.get("task_type")
857 rid = task.get("request_id", "unknown")
858
859 if kind == "shutdown":
860 if deaf:
861 # Acknowledge but refuse to exit, so the host must kill us.
862 print(json.dumps({"status": "success", "request_id": rid}), flush=True)
863 while True:
864 time.sleep(0.05)
865 print(json.dumps({"status": "success", "message": "Shutting down", "request_id": rid}), flush=True)
866 break
867 if kind == "deaf":
868 deaf = True
869 print(json.dumps({"status": "success", "request_id": rid}), flush=True)
870 continue
871 if kind == "die":
872 sys.exit(3)
873 if kind == "slow":
874 time.sleep(float(task.get("delay", 1.0)))
875 print(json.dumps({"status": "success", "echo": task.get("payload"), "request_id": rid}), flush=True)
876 continue
877 if kind == "fail":
878 print(json.dumps({"status": "error", "message": task.get("message", "stub failure"), "request_id": rid}), flush=True)
879 continue
880 if kind == "noisy":
881 print("this is not JSON and must not desync the stream", flush=True)
882 print(json.dumps({"status": "success", "echo": task.get("payload"), "request_id": rid}), flush=True)
883 continue
884 if kind == "huge":
885 # request_id first, so the host can still name the caller from the
886 # truncated prefix it kept.
887 size = int(task.get("size", 1024))
888 print(json.dumps({"request_id": rid, "status": "success", "pad": "P" * size}), flush=True)
889 continue
890 if kind == "unframed":
891 # No newline at all: the pathological case for a line reader.
892 size = int(task.get("size", 1024))
893 sys.stdout.write("U" * size)
894 sys.stdout.flush()
895 print(json.dumps({"request_id": rid, "status": "success"}), flush=True)
896 continue
897 if kind == "loud":
898 size = int(task.get("size", 1024))
899 sys.stderr.write("L" * size + "\n")
900 sys.stderr.flush()
901 print(json.dumps({"status": "success", "echo": task.get("payload"), "request_id": rid}), flush=True)
902 continue
903
904 print(json.dumps({"status": "success", "echo": task.get("payload"), "request_id": rid}), flush=True)
905"#;
906
907 async fn stub_client(
909 dir: &TempDir,
910 max_content_size: usize,
911 timeout_secs: u64,
912 ) -> WorkerClient {
913 let script = dir.path().join("stub_worker.py");
914 std::fs::write(&script, STUB_WORKER).unwrap();
915
916 let python = which_python3();
917 WorkerClient::spawn(
918 &python,
919 &script,
920 Some(dir.path()),
921 max_content_size,
922 timeout_secs,
923 )
924 .await
925 .expect("stub worker spawns")
926 }
927
928 fn which_python3() -> PathBuf {
931 let out = std::process::Command::new("sh")
932 .args(["-c", "command -v python3"])
933 .output()
934 .expect("run command -v");
935 PathBuf::from(String::from_utf8_lossy(&out.stdout).trim())
936 }
937
938 #[tokio::test]
939 async fn a_task_round_trips_to_its_response() {
940 if skip_without_python3("a_task_round_trips_to_its_response") {
941 return;
942 }
943 let dir = TempDir::new();
944 let client = stub_client(&dir, 1_000_000, 10).await;
945
946 let response = client
947 .send_task(serde_json::json!({ "task_type": "echo", "payload": {"tool": "search"} }))
948 .await
949 .expect("the stub replies");
950
951 assert_eq!(response["echo"]["tool"], "search");
952 assert!(
953 response.get("request_id").is_none(),
954 "request_id is transport bookkeeping and must be stripped"
955 );
956 }
957
958 #[tokio::test]
959 async fn concurrent_sends_each_receive_their_own_response() {
960 if skip_without_python3("concurrent_sends_each_receive_their_own_response") {
964 return;
965 }
966 let dir = TempDir::new();
967 let client = Arc::new(stub_client(&dir, 1_000_000, 10).await);
968
969 let slow = {
970 let client = Arc::clone(&client);
971 tokio::spawn(async move {
972 client
973 .send_task(serde_json::json!({
974 "task_type": "slow", "delay": 0.4, "payload": "slow-one"
975 }))
976 .await
977 })
978 };
979
980 tokio::time::sleep(std::time::Duration::from_millis(80)).await;
982
983 let fast = client
984 .send_task(serde_json::json!({ "task_type": "echo", "payload": "fast-one" }))
985 .await
986 .expect("the fast task replies");
987
988 let slow = slow.await.unwrap().expect("the slow task replies");
989
990 assert_eq!(fast["echo"], "fast-one");
991 assert_eq!(slow["echo"], "slow-one");
992 }
993
994 #[tokio::test]
995 async fn many_concurrent_sends_all_resolve_correctly() {
996 if skip_without_python3("many_concurrent_sends_all_resolve_correctly") {
999 return;
1000 }
1001 let dir = TempDir::new();
1002 let client = Arc::new(stub_client(&dir, 1_000_000, 20).await);
1003
1004 let mut handles = Vec::with_capacity(16);
1005 for i in 0..16 {
1006 let client = Arc::clone(&client);
1007 handles.push(tokio::spawn(async move {
1008 let response = client
1009 .send_task(serde_json::json!({ "task_type": "echo", "payload": i }))
1010 .await
1011 .expect("each task replies");
1012 (i, response["echo"].as_i64().unwrap())
1013 }));
1014 }
1015
1016 for handle in handles {
1017 let (sent, echoed) = handle.await.unwrap();
1018 assert_eq!(
1019 sent, echoed,
1020 "response {echoed} was delivered to the caller that sent {sent}"
1021 );
1022 }
1023 }
1024
1025 #[tokio::test]
1026 async fn an_oversized_task_is_rejected_before_it_is_sent() {
1027 if skip_without_python3("an_oversized_task_is_rejected_before_it_is_sent") {
1028 return;
1029 }
1030 let dir = TempDir::new();
1031 let client = stub_client(&dir, 256, 10).await;
1032
1033 let err = client
1034 .send_task(serde_json::json!({ "task_type": "echo", "payload": "x".repeat(1024) }))
1035 .await
1036 .expect_err("a task over the cap must be rejected");
1037
1038 match err {
1039 HostError::TaskTooLarge { size, limit } => {
1040 assert!(size > limit);
1041 assert_eq!(limit, 256);
1042 },
1043 other => panic!("expected TaskTooLarge, got {other:?}"),
1044 }
1045
1046 let response = client
1049 .send_task(serde_json::json!({ "task_type": "echo", "payload": "small" }))
1050 .await
1051 .expect("the client survives a rejected task");
1052 assert_eq!(response["echo"], "small");
1053 }
1054
1055 #[tokio::test]
1058 async fn an_oversized_response_frame_is_rejected_rather_than_buffered() {
1059 if skip_without_python3("an_oversized_response_frame_is_rejected_rather_than_buffered") {
1065 return;
1066 }
1067 let dir = TempDir::new();
1068 let client = stub_client(&dir, 4096, 10).await;
1070
1071 let err = client
1072 .send_task(serde_json::json!({ "task_type": "huge", "size": 300_000 }))
1073 .await
1074 .expect_err("a response over the cap cannot be delivered");
1075
1076 match err {
1077 HostError::ResponseTooLarge { size, limit } => {
1078 assert_eq!(limit, 4096, "the error carries the configured cap");
1079 assert!(
1080 size >= limit,
1081 "the reported size must be at least the cap it broke: {size}"
1082 );
1083 let message = err.to_string();
1084 assert!(
1085 message.contains("4096") && message.contains("discarded"),
1086 "the error should name the limit and say the frame was dropped: {message}"
1087 );
1088 assert!(
1089 !message.contains("PPPP"),
1090 "the error must not echo the frame it refused: {message}"
1091 );
1092 },
1093 other => panic!("expected a ResponseTooLarge error, got {other:?}"),
1094 }
1095
1096 let response = client
1099 .send_task(serde_json::json!({ "task_type": "echo", "payload": "still-framed" }))
1100 .await
1101 .expect("the stream is still in sync after a dropped frame");
1102 assert_eq!(response["echo"], "still-framed");
1103 }
1104
1105 #[tokio::test]
1106 async fn an_unterminated_response_stream_does_not_grow_without_bound() {
1107 if skip_without_python3("an_unterminated_response_stream_does_not_grow_without_bound") {
1111 return;
1112 }
1113 let dir = TempDir::new();
1114 let client = stub_client(&dir, 4096, 10).await;
1115
1116 let err = client
1117 .send_task(serde_json::json!({ "task_type": "unframed", "size": 500_000 }))
1118 .await
1119 .expect_err("an unframed flood cannot answer the request");
1120 assert!(
1121 matches!(err, HostError::ResponseTooLarge { .. }),
1122 "got {err:?}"
1123 );
1124
1125 let response = client
1128 .send_task(serde_json::json!({ "task_type": "echo", "payload": "resynced" }))
1129 .await
1130 .expect("the reader recovers at the next newline");
1131 assert_eq!(response["echo"], "resynced");
1132 }
1133
1134 #[tokio::test]
1135 async fn oversized_stderr_is_truncated_rather_than_killing_the_worker() {
1136 if skip_without_python3("oversized_stderr_is_truncated_rather_than_killing_the_worker") {
1141 return;
1142 }
1143 let dir = TempDir::new();
1144 let client = stub_client(&dir, 1_000_000, 10).await;
1145
1146 let response = client
1147 .send_task(serde_json::json!({
1148 "task_type": "loud", "size": 200_000, "payload": "survived"
1149 }))
1150 .await
1151 .expect("a loud worker is still a working worker");
1152 assert_eq!(response["echo"], "survived");
1153 assert!(client.is_alive());
1154
1155 let lines = client.stderr_lines().await;
1156 let loud = lines
1157 .iter()
1158 .find(|line| line.starts_with("LLL"))
1159 .expect("the loud line was retained");
1160
1161 assert!(
1162 loud.ends_with(TRUNCATION_MARKER),
1163 "a cut line must say so, or an operator reads it as the worker stopping mid-sentence"
1164 );
1165 assert!(
1166 loud.len() <= STDERR_MAX_LINE_BYTES + TRUNCATION_MARKER.len(),
1167 "the retained line is bounded: {} bytes",
1168 loud.len()
1169 );
1170 assert!(loud.len() > STDERR_MAX_LINE_BYTES / 2);
1172 }
1173
1174 #[tokio::test]
1175 async fn total_retained_stderr_stays_bounded_under_a_flood() {
1176 if skip_without_python3("total_retained_stderr_stays_bounded_under_a_flood") {
1180 return;
1181 }
1182 let dir = TempDir::new();
1183 let client = stub_client(&dir, 1_000_000, 20).await;
1184
1185 for _ in 0..(STDERR_RETAIN_LINES * 2) {
1186 client
1187 .send_task(serde_json::json!({
1188 "task_type": "loud", "size": 20_000, "payload": "ok"
1189 }))
1190 .await
1191 .expect("the worker keeps serving");
1192 }
1193
1194 let lines = client.stderr_lines().await;
1195 assert!(
1196 lines.len() <= STDERR_RETAIN_LINES,
1197 "the ring must cap line count: {}",
1198 lines.len()
1199 );
1200 let total: usize = lines.iter().map(String::len).sum();
1201 assert!(
1202 total <= STDERR_RETAIN_LINES * (STDERR_MAX_LINE_BYTES + TRUNCATION_MARKER.len()),
1203 "retained stderr must have a hard ceiling: {total} bytes"
1204 );
1205 }
1206
1207 #[tokio::test]
1210 async fn a_bounded_read_returns_whole_lines_under_the_limit() {
1211 let mut reader = BufReader::new(&b"first\nsecond\n"[..]);
1212
1213 for expected in ["first", "second"] {
1214 match read_bounded_line(&mut reader, 64).await.unwrap() {
1215 BoundedLine::Line(line) => assert_eq!(line, expected),
1216 other => panic!("expected a line, got {}", describe(&other)),
1217 }
1218 }
1219 assert!(matches!(
1220 read_bounded_line(&mut reader, 64).await.unwrap(),
1221 BoundedLine::Eof
1222 ));
1223 }
1224
1225 #[tokio::test]
1226 async fn a_bounded_read_reports_the_scanned_size_and_keeps_only_the_cap() {
1227 let input = format!("{}\nafter\n", "x".repeat(500));
1228 let mut reader = BufReader::new(input.as_bytes());
1229
1230 match read_bounded_line(&mut reader, 100).await.unwrap() {
1231 BoundedLine::Oversized { prefix, scanned } => {
1232 assert_eq!(prefix.len(), 100, "nothing past the cap is retained");
1233 assert_eq!(scanned, 500, "the true size is still reported");
1234 },
1235 other => panic!("expected Oversized, got {}", describe(&other)),
1236 }
1237
1238 match read_bounded_line(&mut reader, 100).await.unwrap() {
1240 BoundedLine::Line(line) => assert_eq!(line, "after"),
1241 other => panic!("expected the next line, got {}", describe(&other)),
1242 }
1243 }
1244
1245 #[tokio::test]
1246 async fn a_line_exactly_at_the_limit_is_not_oversized() {
1247 let input = "xxxxx\n";
1250 let mut reader = BufReader::new(input.as_bytes());
1251 match read_bounded_line(&mut reader, 5).await.unwrap() {
1252 BoundedLine::Line(line) => assert_eq!(line, "xxxxx"),
1253 other => panic!(
1254 "5 bytes under a 5-byte cap is fine, got {}",
1255 describe(&other)
1256 ),
1257 }
1258 }
1259
1260 #[tokio::test]
1261 async fn a_trailing_fragment_without_a_newline_is_still_a_line() {
1262 let mut reader = BufReader::new(&b"no trailing newline"[..]);
1263 match read_bounded_line(&mut reader, 64).await.unwrap() {
1264 BoundedLine::Line(line) => assert_eq!(line, "no trailing newline"),
1265 other => panic!("expected a line, got {}", describe(&other)),
1266 }
1267 }
1268
1269 #[tokio::test]
1270 async fn a_bounded_read_trims_crlf_like_lines_does() {
1271 let mut reader = BufReader::new(&b"windows\r\n"[..]);
1272 match read_bounded_line(&mut reader, 64).await.unwrap() {
1273 BoundedLine::Line(line) => assert_eq!(line, "windows"),
1274 other => panic!("expected a line, got {}", describe(&other)),
1275 }
1276 }
1277
1278 fn describe(line: &BoundedLine) -> &'static str {
1280 match line {
1281 BoundedLine::Line(_) => "Line",
1282 BoundedLine::Oversized { .. } => "Oversized",
1283 BoundedLine::Eof => "Eof",
1284 }
1285 }
1286
1287 #[test]
1288 fn a_request_id_is_recovered_from_a_truncated_frame() {
1289 assert_eq!(
1292 request_id_from_prefix(r#"{"request_id": "abc-123", "status": "succ"#),
1293 Some("abc-123".to_string())
1294 );
1295 assert_eq!(
1296 request_id_from_prefix(r#"{"status":"success","request_id":"xyz","pad":"PPP"#),
1297 Some("xyz".to_string())
1298 );
1299 }
1300
1301 #[test]
1302 fn an_unrecoverable_request_id_is_reported_as_absent() {
1303 for prefix in [
1306 r#"{"status":"success","pad":"PPPP"#, r#"{"request_id": "cut-off-mid-val"#, r#"{"request_id""#, "", ] {
1311 assert_eq!(
1312 request_id_from_prefix(prefix),
1313 None,
1314 "should not guess an id from {prefix:?}"
1315 );
1316 }
1317 }
1318
1319 #[test]
1320 fn the_over_limit_error_names_the_response_not_the_task() {
1321 let err = response_too_large(9_000, 4_096);
1324 let message = err.to_string();
1325 assert!(matches!(
1326 err,
1327 HostError::ResponseTooLarge {
1328 size: 9_000,
1329 limit: 4_096
1330 }
1331 ));
1332 assert_eq!(err.code(), "response_too_large");
1333 assert!(message.contains("response"), "{message}");
1334 assert!(!message.contains("serialized task"), "{message}");
1335 assert!(
1336 message.contains("9000") && message.contains("4096"),
1337 "{message}"
1338 );
1339 }
1340
1341 #[tokio::test]
1342 async fn no_response_within_the_timeout_yields_a_timeout_error() {
1343 if skip_without_python3("no_response_within_the_timeout_yields_a_timeout_error") {
1344 return;
1345 }
1346 let dir = TempDir::new();
1347 let client = stub_client(&dir, 1_000_000, 1).await;
1348
1349 let err = client
1350 .send_task(serde_json::json!({ "task_type": "slow", "delay": 5.0 }))
1351 .await
1352 .expect_err("a task that outruns the timeout must error");
1353
1354 assert!(
1355 matches!(err, HostError::Timeout { timeout_secs: 1 }),
1356 "got {err:?}"
1357 );
1358 }
1359
1360 #[tokio::test]
1361 async fn a_worker_error_response_becomes_a_worker_error() {
1362 if skip_without_python3("a_worker_error_response_becomes_a_worker_error") {
1363 return;
1364 }
1365 let dir = TempDir::new();
1366 let client = stub_client(&dir, 1_000_000, 10).await;
1367
1368 let err = client
1369 .send_task(serde_json::json!({ "task_type": "fail", "message": "plugin blew up" }))
1370 .await
1371 .expect_err("status=error must not be returned as success");
1372
1373 match err {
1374 HostError::WorkerError { message } => assert!(message.contains("plugin blew up")),
1375 other => panic!("expected WorkerError, got {other:?}"),
1376 }
1377 }
1378
1379 #[tokio::test]
1380 async fn worker_death_mid_flight_resolves_rather_than_hangs() {
1381 if skip_without_python3("worker_death_mid_flight_resolves_rather_than_hangs") {
1386 return;
1387 }
1388 let dir = TempDir::new();
1389 let client = stub_client(&dir, 1_000_000, 30).await;
1390
1391 let started = std::time::Instant::now();
1392 let err = client
1393 .send_task(serde_json::json!({ "task_type": "die" }))
1394 .await
1395 .expect_err("a worker that exits without replying must error");
1396
1397 assert!(
1398 matches!(err, HostError::WorkerDied { .. }),
1399 "expected WorkerDied (not Timeout), got {err:?}"
1400 );
1401 assert!(
1402 started.elapsed() < std::time::Duration::from_secs(5),
1403 "the error must arrive on worker death, not on timeout expiry"
1404 );
1405 }
1406
1407 #[tokio::test]
1408 async fn sends_after_worker_death_fail_fast() {
1409 if skip_without_python3("sends_after_worker_death_fail_fast") {
1410 return;
1411 }
1412 let dir = TempDir::new();
1413 let client = stub_client(&dir, 1_000_000, 30).await;
1414
1415 let _ = client
1416 .send_task(serde_json::json!({ "task_type": "die" }))
1417 .await;
1418
1419 let started = std::time::Instant::now();
1424 let err = client
1425 .send_task(serde_json::json!({ "task_type": "echo", "payload": "anyone home?" }))
1426 .await
1427 .expect_err("a send to a dead worker cannot succeed");
1428
1429 assert!(
1430 matches!(err, HostError::WorkerDied { .. }),
1431 "expected WorkerDied, got {err:?}"
1432 );
1433 assert!(
1434 started.elapsed() < std::time::Duration::from_secs(2),
1435 "a send to a known-dead worker must fail immediately, not on timeout: {:?}",
1436 started.elapsed()
1437 );
1438 assert!(!client.is_alive());
1439 }
1440
1441 #[tokio::test]
1442 async fn a_non_json_stdout_line_does_not_desync_the_stream() {
1443 if skip_without_python3("a_non_json_stdout_line_does_not_desync_the_stream") {
1446 return;
1447 }
1448 let dir = TempDir::new();
1449 let client = stub_client(&dir, 1_000_000, 10).await;
1450
1451 let response = client
1452 .send_task(serde_json::json!({ "task_type": "noisy", "payload": "after-the-noise" }))
1453 .await
1454 .expect("the real response still arrives");
1455 assert_eq!(response["echo"], "after-the-noise");
1456 }
1457
1458 #[tokio::test]
1459 async fn shutdown_terminates_a_live_worker() {
1460 if skip_without_python3("shutdown_terminates_a_live_worker") {
1461 return;
1462 }
1463 let dir = TempDir::new();
1464 let client = stub_client(&dir, 1_000_000, 10).await;
1465
1466 client
1467 .send_task(serde_json::json!({ "task_type": "echo", "payload": "alive" }))
1468 .await
1469 .expect("worker is up");
1470
1471 client.shutdown().await.expect("a cooperative worker exits");
1472 client
1474 .shutdown()
1475 .await
1476 .expect("a second shutdown is a no-op");
1477 }
1478
1479 #[tokio::test]
1480 async fn a_worker_that_ignores_shutdown_is_killed_after_the_grace_window() {
1481 if skip_without_python3("a_worker_that_ignores_shutdown_is_killed_after_the_grace_window") {
1482 return;
1483 }
1484 let dir = TempDir::new();
1485 let client = stub_client(&dir, 1_000_000, 10).await;
1486
1487 client
1490 .send_task(serde_json::json!({ "task_type": "deaf" }))
1491 .await
1492 .expect("stub accepts deaf mode");
1493
1494 let started = std::time::Instant::now();
1495 client
1496 .shutdown()
1497 .await
1498 .expect("an unresponsive worker is killed, not waited on forever");
1499
1500 let elapsed = started.elapsed();
1501 assert!(
1502 elapsed >= std::time::Duration::from_secs(SHUTDOWN_GRACE_SECS),
1503 "the grace window should be honored before killing: {elapsed:?}"
1504 );
1505 assert!(
1506 elapsed < std::time::Duration::from_secs(SHUTDOWN_GRACE_SECS + 5),
1507 "the kill must happen promptly once the window expires: {elapsed:?}"
1508 );
1509 }
1510
1511 #[tokio::test]
1512 async fn a_death_error_carries_the_workers_stderr() {
1513 if skip_without_python3("a_death_error_carries_the_workers_stderr") {
1518 return;
1519 }
1520 let dir = TempDir::new();
1521 let script = dir.path().join("import_error.py");
1522 std::fs::write(
1523 &script,
1524 "import sys\nprint('ImportError: cannot import name McpError', file=sys.stderr, flush=True)\nsys.exit(1)\n",
1525 )
1526 .unwrap();
1527
1528 let client =
1529 WorkerClient::spawn(&which_python3(), &script, Some(dir.path()), 1_000_000, 30)
1530 .await
1531 .expect("the process starts, then exits");
1532
1533 let err = client
1534 .send_task(serde_json::json!({ "task_type": "echo" }))
1535 .await
1536 .expect_err("a dead worker cannot answer");
1537
1538 let message = err.to_string();
1539 assert!(matches!(err, HostError::WorkerDied { .. }), "got {err:?}");
1540 assert!(
1541 message.contains("McpError"),
1542 "the death error should carry the stderr that explains it: {message}"
1543 );
1544 }
1545
1546 #[tokio::test]
1547 async fn spawning_against_a_missing_interpreter_errors() {
1548 let err = WorkerClient::spawn(
1549 Path::new("/nonexistent/bin/python"),
1550 Path::new("/tmp/worker.py"),
1551 None,
1552 1024,
1553 5,
1554 )
1555 .await
1556 .expect_err("a missing interpreter cannot be launched");
1557
1558 assert!(matches!(err, HostError::WorkerStart { .. }), "got {err:?}");
1559 }
1560
1561 #[test]
1564 fn an_error_envelope_becomes_a_worker_error() {
1565 let err = interpret_response(serde_json::json!({
1566 "status": "error", "message": "boom", "request_id": "abc"
1567 }))
1568 .expect_err("status=error is a failure");
1569 match err {
1570 HostError::WorkerError { message } => assert_eq!(message, "boom"),
1571 other => panic!("expected WorkerError, got {other:?}"),
1572 }
1573 }
1574
1575 #[test]
1576 fn an_error_envelope_without_a_message_still_errors() {
1577 let err = interpret_response(serde_json::json!({ "status": "error" }))
1578 .expect_err("a message-less error is still an error");
1579 assert!(matches!(err, HostError::WorkerError { .. }));
1580 }
1581
1582 #[test]
1583 fn a_success_envelope_is_returned_without_its_request_id() {
1584 let response = interpret_response(serde_json::json!({
1585 "status": "success", "continue_processing": true, "request_id": "abc"
1586 }))
1587 .expect("success passes through");
1588 assert!(response.get("request_id").is_none());
1589 assert_eq!(response["continue_processing"], true);
1590 }
1591
1592 #[test]
1593 fn a_result_envelope_with_no_status_field_is_success() {
1594 let response = interpret_response(serde_json::json!({
1598 "continue_processing": false, "violation": {"code": "pii"}
1599 }))
1600 .expect("a bare result model is not an error");
1601 assert_eq!(response["violation"]["code"], "pii");
1602 }
1603
1604 #[test]
1607 fn an_absolute_script_path_bypasses_venv_resolution() {
1608 let resolved = resolve_worker_script(Path::new("/venv"), "/opt/custom/worker.py").unwrap();
1609 assert_eq!(resolved, PathBuf::from("/opt/custom/worker.py"));
1610 }
1611
1612 #[test]
1613 fn the_worker_script_is_found_in_unix_site_packages() {
1614 let dir = TempDir::new();
1615 let site = dir
1616 .path()
1617 .join("lib")
1618 .join("python3.12")
1619 .join("site-packages");
1620 let worker = site.join("cpex").join("framework").join("isolated");
1621 std::fs::create_dir_all(&worker).unwrap();
1622 std::fs::write(worker.join("worker.py"), "# stub").unwrap();
1623
1624 let resolved =
1625 resolve_worker_script(dir.path(), "cpex/framework/isolated/worker.py").unwrap();
1626 assert_eq!(resolved, worker.join("worker.py"));
1627 }
1628
1629 #[test]
1630 fn a_missing_worker_script_names_the_venv_and_the_likely_cause() {
1631 let dir = TempDir::new();
1632 let err = resolve_worker_script(dir.path(), "cpex/framework/isolated/worker.py")
1633 .expect_err("an empty venv has no worker");
1634
1635 let message = err.to_string();
1636 assert!(message.contains("worker.py"));
1637 assert!(
1638 message.contains("cpex"),
1639 "the message should point at the missing framework: {message}"
1640 );
1641 }
1642}