Skip to main content

cpex_hosts_python/
worker.rs

1// Location: ./crates/cpex-hosts-python/src/worker.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// Worker client — launches `worker.py` in the plugin's venv and speaks the
7// newline-delimited JSON protocol to it.
8//
9// The async analog of `cpex/framework/isolated/venv_comm.py`. Where that uses
10// two threads and a `Queue` per request, this uses two tokio tasks and a
11// one-shot channel per request:
12//
13// ```text
14//   send_task ──> outbound channel ──> writer task ──> child stdin
15//                      registers a one-shot in `pending`
16//   child stdout ──> reader task ──> demux on request_id ──> that one-shot
17// ```
18//
19// Demuxing on `request_id` rather than assuming request/response ordering is
20// what lets several hook invocations be in flight against one worker at once —
21// the executor dispatches parallel-phase plugins concurrently, and a
22// FIFO-coupled client would serialize them.
23//
24// # Failure modes, kept distinct
25//
26// A hung worker (`Timeout`) and a dead worker (`WorkerDied`) call for different
27// operator responses, so they do not collapse into one error. Process death is
28// detected by reader EOF, at which point every pending one-shot is dropped —
29// each in-flight `send_task` then resolves to `WorkerDied` instead of waiting
30// out a timeout that can never be satisfied.
31
32use 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
44/// How long to wait for a worker to exit after a `shutdown` task before
45/// killing it. Matches `venv_comm.py`'s `stop_worker` grace window.
46const SHUTDOWN_GRACE_SECS: u64 = 5;
47
48/// Task type for a hook invocation, as `worker.py` dispatches on it.
49pub const TASK_LOAD_AND_RUN_HOOK: &str = "load_and_run_hook";
50
51/// Task type that asks the worker to exit.
52pub const TASK_SHUTDOWN: &str = "shutdown";
53
54/// Task type that asks the worker which wire-protocol features it implements.
55///
56/// Answered before any plugin code loads. A worker predating the handshake
57/// replies `{"status":"error","message":"task type not supported."}`, which the
58/// host reads as "no features" rather than as a transport fault — see
59/// [`WorkerClient::capabilities`].
60pub const TASK_CAPABILITIES: &str = "capabilities";
61
62/// Worker feature name for reading the task's `credential` field.
63pub const FEATURE_CREDENTIAL: &str = "credential";
64
65/// Worker feature name for delivering the task's `extensions` field to a hook.
66pub const FEATURE_EXTENSIONS: &str = "extensions";
67
68/// What a worker reported it can do, from the spawn-time handshake.
69///
70/// An empty feature set is a valid, meaningful answer: it is what an old worker
71/// that does not know the handshake reports, and it must make the host fail
72/// closed rather than assume support.
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct WorkerCapabilities {
75    /// The worker's declared protocol version, or 0 if it reported none.
76    pub protocol_version: u64,
77
78    /// Feature names the worker claims to implement.
79    pub features: BTreeSet<String>,
80
81    /// The installed `cpex` distribution version, for diagnostics only.
82    ///
83    /// Never gated on: two builds shipped as 0.1.2 with different worker
84    /// protocols, so the version string cannot carry a decision. `features` is
85    /// the contract.
86    pub cpex_version: Option<String>,
87}
88
89impl WorkerCapabilities {
90    /// The conservative answer for a worker that does not know the handshake.
91    pub fn none() -> Self {
92        Self::default()
93    }
94
95    /// Whether the worker claims the named feature.
96    pub fn has(&self, feature: &str) -> bool {
97        self.features.contains(feature)
98    }
99}
100
101/// Map of in-flight request ids to the channel awaiting each response.
102///
103/// Carries a `Result` rather than a bare `Value` so the reader task can hand a
104/// waiting caller a typed failure — an oversized response frame is a real
105/// answer to that request ("it cannot be delivered"), and reporting it beats
106/// letting the caller sit out the full timeout for a response that was already
107/// discarded.
108type Pending = Arc<Mutex<HashMap<String, oneshot::Sender<Result<Value, HostError>>>>>;
109
110/// How many recent stderr lines to retain for diagnosis.
111///
112/// A worker that dies at import time (a bad dependency pin, a missing module)
113/// says so on stderr and nowhere else. Without this, the host reports only
114/// "worker process died" and the actual cause is lost — the tail is small
115/// enough to be cheap and long enough to hold a Python traceback.
116const STDERR_RETAIN_LINES: usize = 40;
117
118/// Ring buffer of the worker's most recent stderr lines.
119type StderrTail = Arc<Mutex<std::collections::VecDeque<String>>>;
120
121/// Cap on one retained stderr line, independent of `max_content_size`.
122///
123/// Stderr is diagnostic, so an over-long line is truncated rather than treated
124/// as a protocol violation — but the retained copy still has to be bounded, or
125/// a worker that writes one enormous line drives host memory on its own.
126/// Sized to hold a Python traceback frame comfortably.
127const STDERR_MAX_LINE_BYTES: usize = 8 * 1024;
128
129/// Appended to a stderr line that was cut at `STDERR_MAX_LINE_BYTES`, so an
130/// operator reading the tail can tell truncation from a worker that really
131/// stopped mid-sentence.
132const TRUNCATION_MARKER: &str = "… [truncated by host]";
133
134/// Outcome of one bounded line read.
135enum BoundedLine {
136    /// A complete line, within the limit.
137    Line(String),
138
139    /// The line exceeded the limit. Carries the bytes read up to the cap, for
140    /// the diagnostic paths that want a prefix; the rest was discarded as it
141    /// arrived, so nothing beyond the cap was ever buffered.
142    Oversized { prefix: String, scanned: usize },
143
144    /// Stream closed.
145    Eof,
146}
147
148/// Read one `\n`-delimited line, refusing to buffer more than `limit` bytes.
149///
150/// `AsyncBufReadExt::lines` grows its `String` without bound, which is what
151/// makes an unbounded inbound stream a host memory problem regardless of the
152/// outbound `max_content_size` check. This reads through the `BufRead` fill
153/// buffer instead: once `limit` bytes have accumulated without a newline, the
154/// remainder of the line is consumed and dropped rather than retained, so the
155/// stream stays framed (the next read starts at a real line boundary) while
156/// peak memory stays at `limit` plus one fill buffer.
157async 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            // EOF. A trailing fragment with no newline is still a line.
169            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            // Only retain up to the cap; everything past it is consumed and
186            // dropped, which is what keeps this bounded.
187            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    // Trim a `\r` from CRLF, matching what `lines()` does.
202    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
217/// Set once the reader task observes that the worker's stdout has closed.
218///
219/// Needed because clearing `pending` only rescues requests that were *already*
220/// registered when the worker died. A send issued afterwards would otherwise
221/// register a fresh one-shot into a map nobody is reading, succeed at writing
222/// into an mpsc channel whose consumer has exited, and then wait out the full
223/// timeout for a response that can never arrive. Checking this flag turns that
224/// into an immediate `WorkerDied`.
225type Alive = Arc<std::sync::atomic::AtomicBool>;
226
227/// A live `worker.py` subprocess and the plumbing that talks to it.
228pub struct WorkerClient {
229    /// Outbound task lines, drained by the writer task.
230    outbound: mpsc::UnboundedSender<String>,
231
232    /// Response channels keyed by request id.
233    pending: Pending,
234
235    /// False once the reader task has seen the worker's stdout close.
236    alive: Alive,
237
238    /// Recent stderr, for explaining a death.
239    stderr_tail: StderrTail,
240
241    /// The child handle, kept so shutdown can wait on and kill it. `None`
242    /// once shutdown has consumed it.
243    child: Mutex<Option<Child>>,
244
245    /// Cap on one serialized task, enforced before the write.
246    max_content_size: usize,
247
248    /// Per-invocation response timeout.
249    timeout_secs: u64,
250}
251
252/// Manual rather than derived: the outbound channel carries serialized task
253/// lines, which on a credential-bearing hook include the plaintext token. A
254/// derive would put those in any debug dump of the client.
255impl 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    /// Launch `worker.py` with the venv's interpreter and start the I/O tasks.
266    ///
267    /// `cwd` becomes the child's working directory, which is where a plugin's
268    /// relative-path side effects land.
269    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            // Without this, a killed host leaves orphaned workers behind.
289            .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        // The inbound cap is the same `max_content_size` the outbound path
320        // enforces: one configured limit on the size of a protocol frame,
321        // applied in both directions.
322        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    /// Build a `WorkerDied` error, appending recent stderr when there is any.
342    ///
343    /// The stderr tail is what turns "worker process died" into something
344    /// actionable — an import error, a missing dependency, a traceback.
345    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    /// The worker's retained stderr lines, oldest first.
360    ///
361    /// Exposed so a test can assert the credential channel never surfaces here:
362    /// this buffer is the one place the host copies worker stderr, and the one
363    /// place it is echoed onward (into a `WorkerDied` message).
364    pub async fn stderr_lines(&self) -> Vec<String> {
365        self.stderr_tail.lock().await.iter().cloned().collect()
366    }
367
368    /// Whether the worker's stdout is still open.
369    ///
370    /// A `false` here is definitive — the process has closed its output. A
371    /// `true` is only as fresh as the reader task's last observation, so a
372    /// send can still race a death; that path is covered by the dropped-sender
373    /// branch in `send_task`.
374    pub fn is_alive(&self) -> bool {
375        self.alive.load(std::sync::atomic::Ordering::Acquire)
376    }
377
378    /// Send a task and await its response.
379    ///
380    /// The caller supplies the task body; this method injects the `request_id`
381    /// and handles registration, the size check, the write, and the timeout.
382    pub async fn send_task(&self, mut task: Value) -> Result<Value, HostError> {
383        // Checked up front: once the worker is known dead, waiting out the
384        // timeout tells the caller nothing it does not already know.
385        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        // Checked before registering or writing, so an oversized task costs
403        // nothing and cannot desync the worker's line-oriented reader.
404        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        // Registration happens before the write so a fast response cannot
415        // arrive before there is anywhere to route it.
416        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                // The reader task delivered a typed failure for this request —
428                // an over-limit response frame it had to discard.
429                Ok(Ok(Err(e))) => return Err(e),
430                // The sender was dropped without a value: the reader task saw EOF
431                // and cleared `pending`, so the worker is gone.
432                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    /// Ask the worker which wire-protocol features it implements.
449    ///
450    /// # Why an unsupported task type is not an error here
451    ///
452    /// A worker predating the handshake answers the unknown `capabilities` task
453    /// with `{"status":"error","message":"task type not supported."}`, which
454    /// `send_task` surfaces as [`HostError::WorkerError`]. That is the *expected*
455    /// reply from an old worker, not a transport fault, so it maps to an empty
456    /// feature set — the conservative answer that makes the caller fail closed
457    /// for any plugin needing a gated field.
458    ///
459    /// Everything else (a timeout, a dead worker, a malformed frame) stays an
460    /// error: those mean the probe itself did not complete, and treating them as
461    /// "no features" would report a specific, wrong diagnosis for a worker that
462    /// may well be current.
463    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    /// Ask the worker to exit, then wait out the grace window and kill it.
505    ///
506    /// Idempotent: a second call after the child has been reaped is a no-op,
507    /// which matters because `shutdown` may run from both the plugin lifecycle
508    /// and a drop path.
509    pub async fn shutdown(&self) -> Result<(), HostError> {
510        let Some(mut child) = self.child.lock().await.take() else {
511            return Ok(());
512        };
513
514        // `venv_comm.py` sends a fixed "shutdown" request id rather than a
515        // UUID, and the worker echoes it back; nothing awaits the reply.
516        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                // A worker that ignores the shutdown task (or is wedged inside
533                // plugin code) must not hold teardown open.
534                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
544/// Turn a worker response envelope into a result.
545///
546/// The worker signals failure with `{"status": "error", "message": ...}`; any
547/// other envelope is the hook result. `request_id` is stripped — it is
548/// transport bookkeeping, and `venv_comm.py` removes it too before returning.
549fn 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
565/// Drain outbound task lines into the child's stdin.
566///
567/// Ends when the channel closes (client dropped) or the pipe breaks (worker
568/// gone). Either way the reader loop is what surfaces the failure to callers.
569async 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
585/// Read response lines and route each to the one-shot for its request id.
586///
587/// Each frame is read under `limit` (the configured `max_content_size`), so a
588/// hostile or runaway worker cannot grow host memory by never emitting a
589/// newline. An over-limit frame is dropped rather than buffered: the waiting
590/// caller gets a typed `ResponseTooLarge`, and because the rest of the line is
591/// consumed to its newline the stream stays framed for the next response.
592///
593/// On EOF, `pending` is drained and every sender dropped, which resolves each
594/// in-flight `send_task` to `WorkerDied` rather than leaving it to time out.
595async 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                    // A plugin that printed to stdout lands here. Skipping the
613                    // line keeps the stream in sync; the request still times
614                    // out or gets its real response later.
615                    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                        // Receiver gone means the caller already timed out.
627                        let _ = tx.send(Ok(response));
628                    },
629                    None => {
630                        // The fixed "shutdown" id lands here by design, as does
631                        // any late response whose caller has given up.
632                        tracing::debug!(request_id, "response for an unknown request id");
633                    },
634                }
635            },
636            Ok(BoundedLine::Oversized { prefix, scanned }) => {
637                // The frame is gone — only `limit` bytes of it were ever kept.
638                // Recovering the request id from that prefix is best-effort
639                // (JSON field order is not guaranteed), so the fallback is to
640                // fail every in-flight request: something the worker sent could
641                // not be delivered, and guessing which caller it belonged to
642                // would be worse than telling all of them.
643                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                        // No recoverable id. Fail everyone rather than leave a
662                        // caller waiting on a response that no longer exists.
663                        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    // Order matters: mark dead *before* clearing, so a send that races this
686    // teardown either sees the flag and fails fast, or finds its sender
687    // dropped. Either way it does not wait out the timeout.
688    alive.store(false, std::sync::atomic::Ordering::Release);
689
690    // Dropping the senders is what converts a dead worker into an immediate
691    // error for everyone still waiting.
692    pending.lock().await.clear();
693}
694
695/// The error a caller gets when its response frame was over the inbound cap.
696///
697/// A dedicated constructor because the failure needs one stable shape and one
698/// stable wording wherever the reader raises it. `HostError::TaskTooLarge` is
699/// deliberately not reused: its `Display` says "serialized task", which would
700/// point an operator at the *outbound* check and at their own payload rather
701/// than at the worker's reply.
702fn response_too_large(size: usize, limit: usize) -> HostError {
703    HostError::ResponseTooLarge { size, limit }
704}
705
706/// Best-effort recovery of a `request_id` from a truncated response frame.
707///
708/// The frame is not parseable JSON — it was cut mid-value — so this scans for
709/// the `"request_id"` key textually. `worker.py` emits the id early enough that
710/// this usually succeeds, which lets the reader fail exactly the one caller
711/// whose response was dropped instead of every in-flight request. A miss is
712/// expected and handled by the caller, not an error.
713fn 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    // A truncated frame can end mid-id; without a closing quote there is no
720    // way to know the value is complete, so treat it as unrecoverable.
721    let closing = rest.find('"')?;
722    Some(rest[..closing].to_string())
723}
724
725/// Log the worker's stderr and retain a bounded tail for diagnosis.
726///
727/// Logged at debug: the worker's own logging goes here, and a credential-bearing
728/// hook must never surface token material through it. `worker.py` scrubs its
729/// side (it holds the plaintext only to scrub it back out of anything the
730/// plugin logs, raises, or returns); this side never parses these lines or
731/// echoes them anywhere except into a `WorkerDied` message, which is the one
732/// place an operator genuinely needs them.
733///
734/// # Bounding
735///
736/// Stderr gets a *softer* policy than stdout. It carries no framing and no
737/// request routing — it is the channel that explains a death, so discarding it
738/// or killing the worker over it would destroy the diagnostic the operator
739/// needs precisely when things are going wrong. Each line is therefore
740/// truncated at `STDERR_MAX_LINE_BYTES` with an explicit marker (so an operator
741/// can tell a host cut from a worker that stopped mid-sentence) rather than
742/// raising an error, and the `STDERR_RETAIN_LINES` ring already caps the total.
743/// Together those give a hard ceiling of roughly
744/// `STDERR_RETAIN_LINES * STDERR_MAX_LINE_BYTES` on retained stderr, with peak
745/// read memory bounded per line — so a worker that spews stderr forever, in
746/// however few newlines, cannot grow the host.
747async 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
778/// Resolve `worker.py` inside a venv's site-packages.
779///
780/// The worker ships with the `cpex` framework, which the plugin's requirements
781/// pull into the venv — so the host does not carry its own copy. Both
782/// `lib/pythonX.Y/site-packages` (Unix) and `Lib/site-packages` (Windows) are
783/// searched because the interpreter version is not known up front.
784pub fn resolve_worker_script(venv_path: &Path, relative: &str) -> Result<PathBuf, HostError> {
785    // An absolute override (used by tests and by hosts that vendor a worker)
786    // bypasses venv resolution entirely.
787    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    // Last resort: a worker sitting directly under the venv.
807    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    /// A stub worker that speaks the same protocol as `worker.py`.
828    ///
829    /// Deterministic stand-in for the real thing: no venv, no framework
830    /// import, and each behavior selectable per task so the failure modes are
831    /// reachable without contriving a real plugin to misbehave.
832    ///
833    /// Recognized `task_type` values:
834    ///   - `echo`      — reply with `{"status":"success","echo":<payload>}`
835    ///   - `slow`      — sleep `delay` seconds, then echo (drives the timeout)
836    ///   - `fail`      — reply `{"status":"error","message":<message>}`
837    ///   - `die`       — exit immediately without replying
838    ///   - `noisy`     — print a non-JSON line first, then reply
839    ///   - `huge`      — reply with a padded response of `size` bytes
840    ///   - `unframed`  — stream `size` bytes with no newline at all, then reply
841    ///   - `loud`      — write a `size`-byte stderr line, then reply
842    ///   - `deaf`      — ignore `shutdown` and keep running (drives the kill)
843    ///   - `shutdown`  — reply and exit, unless previously put in `deaf` mode
844    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    /// Write the stub to disk and spawn a client against it.
908    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    /// Absolute path to python3, since `WorkerClient::spawn` requires an
929    /// existing interpreter path rather than a PATH lookup.
930    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        // Demux correctness. The stub answers the slower task second, so a
961        // client that assumed FIFO ordering would hand each caller the other's
962        // response — this test fails loudly on that.
963        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        // Give the slow task a head start so it is genuinely outstanding.
981        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        // Scales the demux check past two, where an off-by-one in the pending
997        // map would still look fine.
998        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        // The client must still be usable — rejecting a task locally neither
1047        // wrote to the pipe nor desynced the worker.
1048        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    // --- inbound bounds ------------------------------------------------------
1056
1057    #[tokio::test]
1058    async fn an_oversized_response_frame_is_rejected_rather_than_buffered() {
1059        // The reviewer's finding: `max_content_size` was enforced only outbound,
1060        // so a worker could return a frame of any size and the host would grow
1061        // its heap to hold it. Before the fix this test hangs to the timeout
1062        // (the giant frame is buffered, parsed, and delivered as a success);
1063        // after it, the caller gets a prompt protocol error.
1064        if skip_without_python3("an_oversized_response_frame_is_rejected_rather_than_buffered") {
1065            return;
1066        }
1067        let dir = TempDir::new();
1068        // Big enough for the outbound task, far smaller than the reply.
1069        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        // Framing survived: the discarded frame was consumed to its newline, so
1097        // the next response lands on a real line boundary.
1098        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        // The worst case for a line reader: bytes with no newline in sight.
1108        // `lines()` would buffer all of them; `read_bounded_line` stops
1109        // retaining at the cap and discards the rest as it arrives.
1110        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        // The stub appended a real response after the flood; the reader must
1126        // have resynced on that newline rather than staying wedged.
1127        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        // Stderr is diagnostic, so the policy differs from stdout: bound it and
1137        // mark the cut, but keep the worker alive — this is the channel that
1138        // explains a failure, and losing the worker over a verbose traceback
1139        // would trade a diagnostic for an outage.
1140        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        // And the prefix is genuinely useful, not an empty stub.
1171        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        // Per-line truncation alone is not enough: many long lines must also be
1177        // capped, which is what the retain ring does. Together they put a hard
1178        // ceiling on stderr memory.
1179        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    // --- bounded line reading (no subprocess needed) -------------------------
1208
1209    #[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        // Resync: the oversized line was consumed through its newline.
1239        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        // Off-by-one guard: the cap is the largest *acceptable* size, matching
1248        // the outbound `line.len() > max_content_size` comparison.
1249        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    /// Name a `BoundedLine` for a panic message, since it has no `Debug`.
1279    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        // What lets the reader fail exactly the one caller whose response was
1290        // dropped instead of every in-flight request.
1291        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        // Each of these must be a clean `None` — a wrong guess would fail the
1304        // wrong caller, which is worse than failing all of them.
1305        for prefix in [
1306            r#"{"status":"success","pad":"PPPP"#, // id not in the prefix
1307            r#"{"request_id": "cut-off-mid-val"#, // no closing quote
1308            r#"{"request_id""#,                   // no colon
1309            "",                                   // nothing at all
1310        ] {
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        // Reusing `TaskTooLarge` would point an operator at their own outbound
1322        // payload for a fault in the worker's reply.
1323        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        // The important one: a dead worker must not leave a caller waiting out
1382        // a timeout that can never be satisfied. The generous 30s client
1383        // timeout means a hang here would show up as a test that runs for 30s
1384        // and then reports Timeout instead of WorkerDied.
1385        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        // A send issued *after* the death must also fail fast. Clearing
1420        // `pending` on EOF only rescues already-registered requests; without an
1421        // explicit liveness flag this send would register into an unwatched map
1422        // and wait out the full 30s timeout.
1423        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        // Plugins print. A stray line must be skipped, not consumed as if it
1444        // were the response to the pending request.
1445        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        // Idempotent: teardown can be reached more than once.
1473        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        // Put the stub into a mode where it acknowledges shutdown and then
1488        // spins forever.
1489        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        // A worker that dies during startup — a bad dependency pin, a missing
1514        // module — explains itself on stderr and nowhere else. Without the
1515        // tail, the host reports a bare "worker process died" and the operator
1516        // has nothing to act on.
1517        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    // --- response interpretation (no subprocess needed) ---------------------
1562
1563    #[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        // The hook path returns `model_dump()` of a result model, which has no
1595        // `status` key at all — treating "no status" as an error would fail
1596        // every successful invocation.
1597        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    // --- worker script resolution ------------------------------------------
1605
1606    #[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}