Skip to main content

rightkit_qa/
engine.rs

1//! Black-box driving of an app's engine binary: one-shot CLI words and the
2//! framed stdio sidecar protocol (newline-delimited JSON through
3//! `rightkit-sidecar`). Nothing mocks the engine.
4use crate::process::{run_owned, RunOptions, Tracker};
5use crate::util::{err, Error, Result};
6use rightkit_child_process::{OwnedChild, OwnedCommand};
7use rightkit_sidecar::{read_json_line, write_json_line, FrameError};
8use serde_json::{json, Value};
9use std::io::{BufReader, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::sync::mpsc::{channel, Receiver, RecvTimeoutError};
13use std::sync::{Arc, Mutex};
14use std::time::{Duration, Instant};
15
16pub const FRAME_LIMIT: usize = 16 * 1024 * 1024;
17
18/// Start of the error a `Sidecar` request returns when no frame arrives in time.
19const TIMEOUT_PREFIX: &str = "sidecar timeout after";
20/// Frames [`Sidecar::events`] keeps unless [`Sidecar::set_event_limit`] changes it.
21const DEFAULT_EVENT_LIMIT: usize = 2_048;
22/// Bytes of engine stderr kept unless [`Sidecar::set_stderr_limit`] changes it.
23const DEFAULT_STDERR_LIMIT: usize = 256 * 1024;
24
25/// An engine binary and how to launch it without touching real user data.
26///
27/// Every launch (`cli`, `run`, `start_sidecar`) must name an isolated data directory, or it is
28/// refused before the process starts with a [`crate::userdata::UserDataViolation`] (rule
29/// `no-isolated-data-dir`). A directory is named when the caller's `CliOptions::data_dir` reaches
30/// the engine through `data_dir_env`, or `data_dir_env` is set in the launch environment, or
31/// `data_dir_arg` appears in the arguments, and the directory passes
32/// [`crate::userdata::check_engine_dirs`]. A `data_dir` on a target that declares neither is not
33/// named: the engine would never see it.
34#[derive(Debug, Clone)]
35pub struct EngineTarget {
36    pub binary: PathBuf,
37    /// Env applied to every engine process (e.g. `GENRIGHT_NO_KEYCHAIN=1`).
38    pub env: Vec<(String, String)>,
39    /// Env var that receives the per-scenario data dir (e.g. `GENRIGHT_APP_DATA`).
40    pub data_dir_env: Option<String>,
41    /// Flag the engine takes its data dir under (e.g. `--data-dir`). Declaring it lets a caller
42    /// name the isolated directory in the launch arguments; the harness only judges the value.
43    pub data_dir_arg: Option<String>,
44    /// Flags that select the real library (e.g. `--use-user-library`). A launch whose arguments
45    /// contain one, as `flag` or `flag=value`, is refused. Empty by default; the product declares
46    /// its own, this crate hard-codes none.
47    pub forbidden_args: Vec<String>,
48    /// Set by [`EngineTarget::allow_no_data_dir_for_refusal_probe`]; read it as a warning label.
49    pub allow_no_data_dir_for_refusal_probe: bool,
50}
51
52#[derive(Debug, Clone, Default)]
53pub struct CliOptions {
54    pub data_dir: Option<PathBuf>,
55    pub env: Vec<(String, String)>,
56    /// Appended as `--input <json>` the way the engine CLI contract expects.
57    pub input: Option<Value>,
58    pub timeout: Option<Duration>,
59}
60
61#[derive(Debug, Clone)]
62pub struct CliResult {
63    pub code: Option<i32>,
64    pub json: Value,
65    pub stdout: String,
66    pub stderr: String,
67    /// Every JSON object line the engine wrote to stderr (progress events etc.).
68    pub events: Vec<Value>,
69    pub timed_out: bool,
70}
71
72impl CliResult {
73    pub fn to_value(&self) -> Value {
74        json!({"code": self.code, "json": self.json, "stdout": self.stdout, "stderr": self.stderr, "events": self.events, "timed_out": self.timed_out})
75    }
76}
77
78impl EngineTarget {
79    /// A target with no environment, no data-dir variable or flag, and no deny list. Such a target
80    /// cannot be launched until it declares `data_dir_env` or `data_dir_arg` (or the probe
81    /// opt-out is used).
82    pub fn new(binary: impl Into<PathBuf>) -> Self {
83        Self {
84            binary: binary.into(),
85            env: Vec::new(),
86            data_dir_env: None,
87            data_dir_arg: None,
88            forbidden_args: Vec::new(),
89            allow_no_data_dir_for_refusal_probe: false,
90        }
91    }
92
93    /// Env var that receives the data dir (e.g. `GENRIGHT_APP_DATA`).
94    pub fn with_data_dir_env(mut self, var: &str) -> Self {
95        self.data_dir_env = Some(var.into());
96        self
97    }
98
99    /// Flag the engine takes its data dir under (e.g. `--data-dir`).
100    pub fn with_data_dir_arg(mut self, flag: &str) -> Self {
101        self.data_dir_arg = Some(flag.into());
102        self
103    }
104
105    /// Flags that select the real library; a launch carrying one is refused.
106    pub fn forbid_args(mut self, flags: &[&str]) -> Self {
107        self.forbidden_args = flags.iter().map(|f| (*f).to_owned()).collect();
108        self
109    }
110
111    /// Let launches of this target name no data directory, ONLY to assert the engine's own
112    /// refusal to run without one. Use it on a dedicated clone of the target, never on the target
113    /// that runs real scenarios. A second barrier is still required: on Unix the launch
114    /// environment (`CliOptions::env` or the target's `env`) must redirect `HOME` into a QA
115    /// directory, and `XDG_DATA_HOME` / `XDG_CONFIG_HOME` too wherever they are set or inherited;
116    /// on Windows the Known Folder API ignores the environment, so the launch is refused
117    /// whatever the caller passes. See [`crate::userdata::check_engine_launch`].
118    pub fn allow_no_data_dir_for_refusal_probe(mut self) -> Self {
119        self.allow_no_data_dir_for_refusal_probe = true;
120        self
121    }
122
123    /// The guard every launch path runs before it spawns anything.
124    fn check_launch(&self, args: &[String], opts: &CliOptions) -> Result<()> {
125        let env = self.env_for(opts.data_dir.as_deref(), &opts.env);
126        crate::userdata::check_engine_launch(&crate::userdata::EngineLaunch {
127            binary: &self.binary,
128            args,
129            data_dir: opts.data_dir.as_deref(),
130            data_dir_env: self.data_dir_env.as_deref(),
131            data_dir_arg: self.data_dir_arg.as_deref(),
132            forbidden_args: &self.forbidden_args,
133            env: &env,
134            refusal_probe: self.allow_no_data_dir_for_refusal_probe,
135        })?;
136        Ok(())
137    }
138
139    /// Bind this target to one scenario's test-mode root: `RIGHTKIT_UNDER_TEST=1` and
140    /// `RIGHTKIT_QA_DATA_DIR=<scenario_dir>`. They go last and replace any earlier value of the same
141    /// variable, so the ordinary config env cannot unbind them. A step's own `env` is applied after
142    /// this by [`EngineTarget::env_for`] and still wins, so a scenario can set or override either
143    /// variable to test a refusal. The scenario directory carries the runner binding
144    /// ([`crate::workspace::QaWorkspace::scenario_dir`]); the binding is not proof of the runner.
145    ///
146    /// A target that opted into [`EngineTarget::allow_no_data_dir_for_refusal_probe`] is left alone:
147    /// the probe asserts the engine's own refusal to run without a data directory, and a bound
148    /// test-mode root would hand it one.
149    pub(crate) fn bind_test_mode(&mut self, scenario_dir: &Path) {
150        if self.allow_no_data_dir_for_refusal_probe {
151            return;
152        }
153        let same = |a: &str, b: &str| {
154            if cfg!(windows) {
155                a.eq_ignore_ascii_case(b)
156            } else {
157                a == b
158            }
159        };
160        self.env
161            .retain(|(k, _)| !same(k, "RIGHTKIT_UNDER_TEST") && !same(k, "RIGHTKIT_QA_DATA_DIR"));
162        self.env.push(("RIGHTKIT_UNDER_TEST".into(), "1".into()));
163        self.env.push((
164            "RIGHTKIT_QA_DATA_DIR".into(),
165            scenario_dir.to_string_lossy().into(),
166        ));
167    }
168
169    /// The environment of one launch: the target's env (with a scenario's test-mode binding), the
170    /// data-dir variable, then the caller's own pairs, which win. `cli`, `start_sidecar` and the
171    /// launch guard all read it from here.
172    pub(crate) fn env_for(
173        &self,
174        data_dir: Option<&Path>,
175        extra: &[(String, String)],
176    ) -> Vec<(String, String)> {
177        let mut env = self.env.clone();
178        if let (Some(var), Some(dir)) = (&self.data_dir_env, data_dir) {
179            env.push((var.clone(), dir.to_string_lossy().into()));
180        }
181        env.extend(extra.iter().cloned());
182        env
183    }
184
185    pub fn cli(&self, args: &[String], opts: &CliOptions, tracker: &Tracker) -> Result<CliResult> {
186        if !self.binary.exists() {
187            return err(format!(
188                "engine binary not found: {}",
189                self.binary.display()
190            ));
191        }
192        self.check_launch(args, opts)?;
193        let mut argv = args.to_vec();
194        if let Some(input) = &opts.input {
195            argv.push("--input".into());
196            argv.push(input.to_string());
197        }
198        let out = run_owned(
199            &self.binary.to_string_lossy(),
200            &argv,
201            &RunOptions {
202                env: self.env_for(opts.data_dir.as_deref(), &opts.env),
203                timeout: opts.timeout,
204                label: format!("engine {}", args.join(" ")),
205                ..Default::default()
206            },
207            tracker,
208        )?;
209        let json = serde_json::from_str(out.stdout.trim()).unwrap_or(Value::Null);
210        let events = out
211            .stderr
212            .lines()
213            .map(str::trim)
214            .filter(|l| l.starts_with('{'))
215            .filter_map(|l| serde_json::from_str(l).ok())
216            .collect();
217        Ok(CliResult {
218            code: out.code,
219            json,
220            stdout: out.stdout,
221            stderr: out.stderr,
222            events,
223            timed_out: out.timed_out,
224        })
225    }
226
227    /// `engine run('job status', input)`: words split, non-zero exit or `json.error` fails.
228    pub fn run(
229        &self,
230        words: &str,
231        input: Value,
232        opts: &CliOptions,
233        tracker: &Tracker,
234    ) -> Result<Value> {
235        let args: Vec<String> = words.split_whitespace().map(String::from).collect();
236        let r = self.cli(
237            &args,
238            &CliOptions {
239                input: Some(input),
240                ..opts.clone()
241            },
242            tracker,
243        )?;
244        if r.code != Some(0) || r.json.get("error").map(|e| !e.is_null()).unwrap_or(false) {
245            return err(format!(
246                "engine command failed ({:?}): {words}\nstdout: {}\nstderr: {}",
247                r.code,
248                r.stdout,
249                crate::util::tail(&r.stderr, 2000)
250            ));
251        }
252        Ok(r.json)
253    }
254
255    pub fn start_sidecar(&self, opts: &CliOptions, tracker: &Tracker) -> Result<Sidecar> {
256        Sidecar::start(self, opts, tracker)
257    }
258}
259
260/// A running `--stdio` sidecar speaking
261/// `{"type":"request","id":N,"method":{"method":"command","params":{"name","input"}}}`
262/// and answering `{"type":"response","status":"ok|err","id":N,"result|error":...}`.
263pub struct Sidecar {
264    child: Option<OwnedChild>,
265    stdin: Option<Box<dyn Write + Send>>,
266    rx: Receiver<Value>,
267    seq: u64,
268    pub hello: Value,
269    pub pid: u32,
270    stderr: Arc<Mutex<StderrBuf>>,
271    /// Frames that were neither hello nor the awaited response (events), oldest first. Once the
272    /// list reaches twice the event limit (default 2,048, see [`Sidecar::set_event_limit`]) the
273    /// oldest are trimmed back to the limit; [`SidecarStatus::events_dropped`] counts them.
274    pub events: Vec<Value>,
275    /// Frames trimmed from `events` since the sidecar started.
276    events_dropped: u64,
277    event_limit: usize,
278    /// Id of the request being awaited, and whether a frame carrying that id has arrived. Set only
279    /// while `request` waits; `push_event` updates it as each frame comes in.
280    watch: Option<(u64, bool)>,
281    /// Forces the old GenRight `status` frames for every command when true. `false` does not
282    /// force jobs frames: the shape then comes from the hello (see [`Sidecar::set_frame_shape`]
283    /// to pin the jobs contract against a legacy hello).
284    pub legacy_status_frames: bool,
285    /// Shape the hello announced (see [`detect_legacy_frames`]); `None` when it names neither.
286    detected_legacy: Option<bool>,
287    /// Sidecar-level pin set by `set_frame_shape`; beats `legacy_status_frames` and the hello.
288    frame_shape: Option<bool>,
289    tracker: Tracker,
290}
291
292/// Runtime figures of a running sidecar, computed on call by [`Sidecar::status`].
293#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct SidecarStatus {
295    pub pid: u32,
296    /// Requests sent so far (the frame sequence).
297    pub requests_sent: u64,
298    /// Frames held in [`Sidecar::events`] (neither hello nor an awaited response). Bounded: at most
299    /// twice the event limit, default 2,048 frames.
300    pub events_buffered: usize,
301    /// Frames trimmed from [`Sidecar::events`] since the sidecar started.
302    pub events_dropped: u64,
303    /// Bytes of engine stderr kept in memory: the last stderr limit's worth (default 256 KiB).
304    pub stderr_bytes: usize,
305    /// Bytes of engine stderr trimmed from memory since the sidecar started.
306    pub stderr_dropped_bytes: u64,
307    /// Resident memory, CPU time and I/O counters of the harness process (the sidecar's reader
308    /// threads run in it; the engine child is a separate process), or `None` when the host cannot
309    /// report them. See `rightkit_child_process::ProcessUsage`.
310    pub process: Option<rightkit_child_process::ProcessUsage>,
311}
312
313/// Frame shape a sidecar's hello announces. rightkit-job-queue' `Dispatcher::hello()` always carries
314/// `contract_version`; GenRight's engine hello (`genright-protocol`, `EngineFrame::Hello`) carries
315/// `rpc_version` and never `contract_version`. `Some(false)` is the rightkit-job-queue contract,
316/// `Some(true)` the legacy GenRight frames, `None` when the hello has neither field.
317pub(crate) fn detect_legacy_frames(hello: &Value) -> Option<bool> {
318    if hello.get("contract_version").is_some() {
319        Some(false)
320    } else if hello.get("rpc_version").is_some() {
321        Some(true)
322    } else {
323        None
324    }
325}
326
327fn command_frame(name: &str, input: Value, legacy: bool) -> Value {
328    if legacy {
329        json!({"type": "request", "method": {"method": "command", "params": {"name": name, "input": input}}})
330    } else {
331        json!({"type": "request", "method": name, "params": input})
332    }
333}
334
335fn command_outcome(f: &Value, legacy: bool) -> Result<Value> {
336    let failed = if legacy {
337        f["status"] == "err"
338    } else {
339        f["ok"] != json!(true)
340    };
341    if failed {
342        return err(format!(
343            "{}: {}",
344            f["error"]["code"].as_str().unwrap_or("error"),
345            f["error"]["message"].as_str().unwrap_or("")
346        ));
347    }
348    Ok(f["result"].clone())
349}
350
351/// Where the frame shape of a command came from.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353enum ShapeSource {
354    /// A per-command `Option`, `set_frame_shape`, or `legacy_status_frames = true`.
355    Explicit,
356    /// The hello announced it.
357    Detected,
358    /// The hello named neither shape, so the jobs contract was used by default.
359    Fallback,
360}
361
362/// A request that got no response: the error, its id, and whether any frame carried that id.
363struct Exchange {
364    error: Error,
365    id: u64,
366    answered: bool,
367}
368
369/// Engine stderr, kept to its last `limit` bytes. Bytes before `start` were dropped; they stay in
370/// `text` until that dead prefix reaches the limit, so each byte moves O(1) times on average and
371/// `text` never holds more than about twice the limit.
372struct StderrBuf {
373    text: String,
374    start: usize,
375    limit: usize,
376    /// Bytes dropped from the front since the sidecar started.
377    dropped: u64,
378}
379
380impl StderrBuf {
381    fn new(limit: usize) -> Self {
382        Self {
383            text: String::new(),
384            start: 0,
385            limit,
386            dropped: 0,
387        }
388    }
389
390    fn push(&mut self, chunk: &str) {
391        self.text.push_str(chunk);
392        self.trim();
393    }
394
395    fn set_limit(&mut self, limit: usize) {
396        self.limit = limit;
397        self.trim();
398    }
399
400    /// Drops the oldest bytes beyond the limit. The cut moves forward to just after a newline when
401    /// one sits within an eighth of the limit, so a kept line is whole where possible.
402    fn trim(&mut self) {
403        if self.text.len() - self.start > self.limit {
404            let mut cut = self.text.len() - self.limit;
405            while !self.text.is_char_boundary(cut) {
406                cut += 1;
407            }
408            let window_end = (cut + self.limit / 8).min(self.text.len());
409            if let Some(p) = self.text.as_bytes()[cut..window_end]
410                .iter()
411                .position(|&b| b == b'\n')
412            {
413                cut += p + 1;
414            }
415            self.dropped += (cut - self.start) as u64;
416            self.start = cut;
417        }
418        if self.start >= self.limit {
419            self.text.drain(..self.start);
420            self.start = 0;
421        }
422    }
423
424    fn retained(&self) -> &str {
425        &self.text[self.start..]
426    }
427
428    fn bytes(&self) -> usize {
429        self.text.len() - self.start
430    }
431
432    /// The last `max` retained bytes, preceded by a marker when earlier output was dropped.
433    fn tail(&self, max: usize) -> String {
434        let tail = crate::util::tail(self.retained(), max);
435        if self.dropped == 0 {
436            tail
437        } else {
438            format!("[{} bytes of earlier stderr dropped]\n{tail}", self.dropped)
439        }
440    }
441}
442
443impl Sidecar {
444    fn start(engine: &EngineTarget, opts: &CliOptions, tracker: &Tracker) -> Result<Self> {
445        engine.check_launch(&["--stdio".to_string()], opts)?;
446        let mut cmd = Command::new(&engine.binary);
447        cmd.arg("--stdio")
448            .stdin(Stdio::piped())
449            .stdout(Stdio::piped())
450            .stderr(Stdio::piped());
451        for (k, v) in engine.env_for(opts.data_dir.as_deref(), &opts.env) {
452            cmd.env(k, v);
453        }
454        let mut owned = OwnedCommand::from_command(cmd);
455        owned.windows_hide();
456        let mut child = owned.spawn().map_err(|e| {
457            Error(format!(
458                "failed to start sidecar {}: {e}",
459                engine.binary.display()
460            ))
461        })?;
462        let pid = child.id();
463        tracker.register(pid, "engine --stdio");
464        let stdin = child.take_stdin();
465        let stdout = child
466            .take_stdout()
467            .ok_or_else(|| Error("sidecar stdout unavailable".into()))?;
468        let mut stderr_pipe = child.take_stderr();
469        let stderr = Arc::new(Mutex::new(StderrBuf::new(DEFAULT_STDERR_LIMIT)));
470        let sink = stderr.clone();
471        std::thread::spawn(move || {
472            use std::io::Read;
473            let mut buf = [0u8; 4096];
474            if let Some(p) = stderr_pipe.as_mut() {
475                while let Ok(n) = p.read(&mut buf) {
476                    if n == 0 {
477                        break;
478                    }
479                    sink.lock()
480                        .unwrap()
481                        .push(&String::from_utf8_lossy(&buf[..n]));
482                }
483            }
484        });
485        let (tx, rx) = channel();
486        std::thread::spawn(move || {
487            let mut reader = BufReader::new(stdout);
488            let mut buf = Vec::new();
489            loop {
490                match read_json_line::<_, Value>(&mut reader, &mut buf, FRAME_LIMIT) {
491                    Ok(Some(v)) => {
492                        if tx.send(v).is_err() {
493                            break;
494                        }
495                    }
496                    Ok(None) => break,
497                    Err(FrameError::Io(_)) => break,
498                    Err(_) => continue, // non-JSON line (log noise): skip, stay on the frame boundary
499                }
500            }
501        });
502        Self {
503            child: Some(child),
504            stdin: stdin.map(|s| Box::new(s) as Box<dyn Write + Send>),
505            rx,
506            seq: 0,
507            hello: Value::Null,
508            pid,
509            stderr,
510            events: vec![],
511            events_dropped: 0,
512            event_limit: DEFAULT_EVENT_LIMIT,
513            watch: None,
514            legacy_status_frames: false,
515            detected_legacy: None,
516            frame_shape: None,
517            tracker: tracker.clone(),
518        }
519        .finish_start()
520    }
521
522    /// Reads the hello and records the frame shape it announces.
523    fn finish_start(mut self) -> Result<Self> {
524        self.hello = self.await_frame(Duration::from_secs(30), |f| f["type"] == "hello")?;
525        self.detected_legacy = detect_legacy_frames(&self.hello);
526        Ok(self)
527    }
528
529    fn await_frame(
530        &mut self,
531        timeout: Duration,
532        mut want: impl FnMut(&Value) -> bool,
533    ) -> Result<Value> {
534        let deadline = Instant::now() + timeout;
535        loop {
536            let left = deadline.saturating_duration_since(Instant::now());
537            match self.rx.recv_timeout(left.max(Duration::from_millis(1))) {
538                Ok(f) if want(&f) => return Ok(f),
539                Ok(f) => self.push_event(f),
540                Err(RecvTimeoutError::Timeout) => {
541                    return err(format!(
542                        "{} {}ms; stderr tail: {}",
543                        TIMEOUT_PREFIX,
544                        timeout.as_millis(),
545                        self.stderr_tail()
546                    ))
547                }
548                Err(RecvTimeoutError::Disconnected) => {
549                    return err(format!(
550                        "sidecar closed its stdout; stderr tail: {}",
551                        self.stderr_tail()
552                    ))
553                }
554            }
555        }
556    }
557
558    /// Buffer counts and process resource usage, read now. No polling thread runs for this.
559    pub fn status(&self) -> SidecarStatus {
560        let (stderr_bytes, stderr_dropped_bytes) = self
561            .stderr
562            .lock()
563            .map_or((0, 0), |s| (s.bytes(), s.dropped));
564        SidecarStatus {
565            pid: self.pid,
566            requests_sent: self.seq,
567            events_buffered: self.events.len(),
568            events_dropped: self.events_dropped,
569            stderr_bytes,
570            stderr_dropped_bytes,
571            process: rightkit_child_process::process_usage().ok(),
572        }
573    }
574
575    /// Keeps the most recent `limit` frames in [`Sidecar::events`] (default 2,048). Frames beyond
576    /// the new limit are trimmed now, and the list is trimmed again once it reaches twice the limit.
577    pub fn set_event_limit(&mut self, limit: usize) {
578        self.event_limit = limit;
579        self.drop_oldest_events(limit);
580    }
581
582    /// Keeps the last `bytes` of engine stderr in memory (default 256 KiB).
583    pub fn set_stderr_limit(&mut self, bytes: usize) {
584        if let Ok(mut buf) = self.stderr.lock() {
585            buf.set_limit(bytes);
586        }
587    }
588
589    pub fn stderr_tail(&self) -> String {
590        self.stderr.lock().unwrap().tail(1500)
591    }
592
593    /// Buffers a frame that is neither the hello nor an awaited response. A request waiting on
594    /// its id is told about the frame here, as it arrives, so trimming never hides it.
595    fn push_event(&mut self, frame: Value) {
596        if let Some((id, seen)) = self.watch.as_mut() {
597            if frame["id"] == json!(*id) {
598                *seen = true;
599            }
600        }
601        self.events.push(frame);
602        if self.events.len() >= self.event_limit.saturating_mul(2).max(1) {
603            self.drop_oldest_events(self.event_limit);
604        }
605    }
606
607    /// Trims `events` to its most recent `keep` frames, counting what was removed.
608    fn drop_oldest_events(&mut self, keep: usize) {
609        if self.events.len() > keep {
610            let excess = self.events.len() - keep;
611            self.events.drain(..excess);
612            self.events_dropped += excess as u64;
613        }
614    }
615
616    /// Send an arbitrary frame and return the response with the same id.
617    pub fn raw(&mut self, frame: Value, timeout: Duration) -> Result<Value> {
618        self.request(frame, timeout).map_err(|x| x.error)
619    }
620
621    /// Writes `frame` with a fresh id and waits for the response with that id. A failure says
622    /// whether any frame carrying that id arrived, so a silent engine can be told apart.
623    fn request(
624        &mut self,
625        mut frame: Value,
626        timeout: Duration,
627    ) -> std::result::Result<Value, Exchange> {
628        self.seq += 1;
629        let id = self.seq;
630        frame["id"] = json!(id);
631        let written = match self.stdin.as_mut() {
632            Some(stdin) => write_json_line(stdin, &frame, FRAME_LIMIT)
633                .map_err(|e| Error(e.to_string()))
634                .and_then(|()| stdin.flush().map_err(|e| Error(e.to_string()))),
635            None => Err(Error("sidecar stdin closed".into())),
636        };
637        if let Err(error) = written {
638            return Err(Exchange {
639                error,
640                id,
641                answered: false,
642            });
643        }
644        self.watch = Some((id, false));
645        let waited = self.await_frame(timeout, |f| f["type"] == "response" && f["id"] == json!(id));
646        let answered = self.watch.take().is_some_and(|(_, seen)| seen);
647        waited.map_err(|error| Exchange {
648            error,
649            id,
650            answered,
651        })
652    }
653
654    /// The shape a command is sent in: a per-command `Option`, then the sidecar pin, then the
655    /// `legacy_status_frames` field, then what the hello announced, then the jobs contract.
656    fn resolve_shape(&self, per_command: Option<bool>) -> (bool, ShapeSource) {
657        if let Some(legacy) = per_command.or(self.frame_shape) {
658            return (legacy, ShapeSource::Explicit);
659        }
660        if self.legacy_status_frames {
661            return (true, ShapeSource::Explicit);
662        }
663        match self.detected_legacy {
664            Some(legacy) => (legacy, ShapeSource::Detected),
665            None => (false, ShapeSource::Fallback),
666        }
667    }
668
669    /// A command. Default is the rightkit-job-queue contract: `{type:"request", id, method: name,
670    /// params: input}` answered by `{type:"response", id, ok, result | error:{code,message}}`.
671    /// With `legacy_status_frames` the old GenRight shape is used (`method:{method:"command",
672    /// params:{name,input}}`, answered with `status: ok|err`). The shape a hello announces is
673    /// used when nothing is set; see [`Sidecar::resolve_shape`].
674    pub fn command(&mut self, name: &str, input: Value, timeout: Duration) -> Result<Value> {
675        self.command_with(name, input, timeout, None)
676    }
677
678    pub fn command_with(
679        &mut self,
680        name: &str,
681        input: Value,
682        timeout: Duration,
683        legacy_status_frames: Option<bool>,
684    ) -> Result<Value> {
685        let (legacy, source) = self.resolve_shape(legacy_status_frames);
686        match self.request(command_frame(name, input, legacy), timeout) {
687            Ok(f) => command_outcome(&f, legacy),
688            Err(x)
689                if source == ShapeSource::Fallback
690                    && !x.answered
691                    && x.error.0.starts_with(TIMEOUT_PREFIX) =>
692            {
693                err(format!(
694                    "engine did not answer the frame shape that was sent for `{name}`: sent the \
695                     rightkit-job-queue contract {{type:\"request\", id, method, params}} and no frame \
696                     came back for request id {} within {}ms. The hello carried neither \
697                     contract_version nor rpc_version, so the shape was not detected. If this engine \
698                     speaks GenRight's legacy frames ({{type:\"request\", id, \
699                     method:{{method:\"command\", params:{{name, input}}}}}} answered by status: \
700                     ok|err), set legacy_status_frames = true on sidecar_start or on this \
701                     sidecar_command. stderr tail: {}",
702                    x.id,
703                    timeout.as_millis(),
704                    self.stderr_tail()
705                ))
706            }
707            Err(x) => Err(x.error),
708        }
709    }
710
711    /// Pins the frame shape for every command on this sidecar: `Some(true)` the legacy GenRight
712    /// frames, `Some(false)` the rightkit-job-queue contract, `None` clears the pin. A per-command
713    /// `Option` still wins over the pin.
714    pub fn set_frame_shape(&mut self, legacy: Option<bool>) {
715        self.frame_shape = legacy;
716    }
717
718    pub fn shutdown(&mut self) {
719        self.seq += 1;
720        if let Some(stdin) = self.stdin.as_mut() {
721            let _ = write_json_line(
722                stdin,
723                &json!({"type":"request","id":self.seq,"method":{"method":"shutdown"}}),
724                FRAME_LIMIT,
725            );
726        }
727    }
728
729    /// Close stdin, give the engine a moment to exit, then kill the owned tree.
730    /// Returns the exit code if it exited on its own.
731    pub fn close(&mut self) -> Option<i32> {
732        self.stdin.take();
733        let mut code = None;
734        if let Some(mut child) = self.child.take() {
735            match child.wait_timeout(Duration::from_secs(5)) {
736                Ok(Some(s)) => code = s.code(),
737                _ => {
738                    if let (_, Some(note)) = crate::process::terminate_bounded(&mut child) {
739                        eprintln!("{note}");
740                    }
741                }
742            }
743        }
744        self.tracker.forget(self.pid);
745        code
746    }
747}
748
749impl Drop for Sidecar {
750    fn drop(&mut self) {
751        let _ = self.close();
752    }
753}
754
755/// Build the engine through the managed wrapper and return the executable it
756/// produced (from cargo's `compiler-artifact` message), never a stale copy.
757pub fn build_engine(
758    cwd: &Path,
759    command: &[String],
760    package: &str,
761    env: &[(String, String)],
762    tracker: &Tracker,
763) -> Result<PathBuf> {
764    let Some((program, rest)) = command.split_first() else {
765        return err("engine.build is empty");
766    };
767    let mut args = rest.to_vec();
768    if !args.iter().any(|a| a.starts_with("--message-format")) {
769        args.push("--message-format=json-render-diagnostics".into());
770    }
771    let out = run_owned(
772        program,
773        &args,
774        &RunOptions {
775            cwd: Some(cwd.to_path_buf()),
776            env: env.to_vec(),
777            timeout: Some(Duration::from_secs(45 * 60)),
778            label: "engine build".into(),
779            ..Default::default()
780        },
781        tracker,
782    )?;
783    if out.code != Some(0) {
784        return err(format!(
785            "engine build failed ({:?}):\n{}",
786            out.code,
787            crate::util::tail(&out.stderr, 4000)
788        ));
789    }
790    out.stdout
791        .lines()
792        .filter(|l| l.starts_with('{'))
793        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
794        .filter(|m| m["reason"] == "compiler-artifact" && m["target"]["name"] == package)
795        .filter_map(|m| m["executable"].as_str().map(PathBuf::from))
796        .next_back()
797        .ok_or_else(|| {
798            Error(format!(
799                "build produced no executable for package {package}"
800            ))
801        })
802}
803
804#[cfg(test)]
805mod command_shape_tests {
806    use super::*;
807
808    #[test]
809    fn jobs_contract_is_the_default_frame() {
810        let f = command_frame("scan", json!({"a": 1}), false);
811        assert_eq!(
812            f,
813            json!({"type":"request","method":"scan","params":{"a":1}})
814        );
815        let ok = json!({"type":"response","id":1,"ok":true,"result":{"n":2}});
816        assert_eq!(command_outcome(&ok, false).unwrap(), json!({"n":2}));
817        let bad =
818            json!({"type":"response","id":1,"ok":false,"error":{"code":"nope","message":"m"}});
819        assert_eq!(command_outcome(&bad, false).unwrap_err().0, "nope: m");
820        // A response with no `ok` is not success.
821        assert!(command_outcome(&json!({"type":"response","id":1}), false).is_err());
822    }
823
824    #[test]
825    fn legacy_status_frames_keep_the_old_shape() {
826        let f = command_frame("scan", json!({"a": 1}), true);
827        assert_eq!(
828            f,
829            json!({"type":"request","method":{"method":"command","params":{"name":"scan","input":{"a":1}}}})
830        );
831        let ok = json!({"type":"response","id":1,"status":"ok","result":{"n":2}});
832        assert_eq!(command_outcome(&ok, true).unwrap(), json!({"n":2}));
833        let bad =
834            json!({"type":"response","id":1,"status":"err","error":{"code":"c","message":"m"}});
835        assert_eq!(command_outcome(&bad, true).unwrap_err().0, "c: m");
836    }
837}
838
839#[cfg(test)]
840mod frame_shape_tests {
841    use super::*;
842    use std::sync::mpsc::Sender;
843
844    /// Bytes the sidecar wrote to its stdin, shared with the test.
845    #[derive(Clone, Default)]
846    struct Wire(Arc<Mutex<Vec<u8>>>);
847
848    impl Write for Wire {
849        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
850            self.0.lock().unwrap().extend_from_slice(buf);
851            Ok(buf.len())
852        }
853        fn flush(&mut self) -> std::io::Result<()> {
854            Ok(())
855        }
856    }
857
858    impl Wire {
859        fn sent(&self) -> Vec<Value> {
860            let bytes = self.0.lock().unwrap().clone();
861            String::from_utf8(bytes)
862                .unwrap()
863                .lines()
864                .map(|l| serde_json::from_str(l).unwrap())
865                .collect()
866        }
867    }
868
869    /// An in-memory sidecar. `frames` are the engine's output in order: the hello first, then
870    /// any answers. The returned sender keeps the reader open, so silence is a timeout, not a
871    /// closed stream.
872    fn sidecar(frames: Vec<Value>) -> (Sidecar, Wire, Sender<Value>) {
873        let (tx, rx) = channel();
874        for f in frames {
875            tx.send(f).unwrap();
876        }
877        let wire = Wire::default();
878        let s = Sidecar {
879            child: None,
880            stdin: Some(Box::new(wire.clone())),
881            rx,
882            seq: 0,
883            hello: Value::Null,
884            pid: 0,
885            stderr: Arc::new(Mutex::new(StderrBuf::new(DEFAULT_STDERR_LIMIT))),
886            events: vec![],
887            events_dropped: 0,
888            event_limit: DEFAULT_EVENT_LIMIT,
889            watch: None,
890            legacy_status_frames: false,
891            detected_legacy: None,
892            frame_shape: None,
893            tracker: Tracker::new(),
894        }
895        .finish_start()
896        .unwrap();
897        (s, wire, tx)
898    }
899
900    fn legacy_hello() -> Value {
901        json!({"type": "hello", "rpc_version": 1, "build": "0.1.0", "pid": 7})
902    }
903    fn jobs_hello() -> Value {
904        json!({"type": "hello", "engine": "demo", "version": "1.0.0", "contract_version": 1})
905    }
906    /// A hello that names neither field.
907    fn bare_hello() -> Value {
908        json!({"type": "hello", "engine": "demo"})
909    }
910    fn legacy_ok(id: u64) -> Value {
911        json!({"type": "response", "status": "ok", "id": id, "result": {"n": 2}})
912    }
913    fn jobs_ok(id: u64) -> Value {
914        json!({"type": "response", "id": id, "ok": true, "result": {"n": 2}})
915    }
916    fn input() -> Value {
917        json!({"a": 1})
918    }
919    fn legacy_frame(id: u64) -> Value {
920        json!({"type": "request", "id": id,
921               "method": {"method": "command", "params": {"name": "scan", "input": {"a": 1}}}})
922    }
923    fn jobs_frame(id: u64) -> Value {
924        json!({"type": "request", "id": id, "method": "scan", "params": {"a": 1}})
925    }
926    const WAIT: Duration = Duration::from_secs(5);
927    const SILENT: Duration = Duration::from_millis(50);
928
929    #[test]
930    fn detection_reads_the_hello_fields() {
931        assert_eq!(detect_legacy_frames(&legacy_hello()), Some(true));
932        assert_eq!(detect_legacy_frames(&jobs_hello()), Some(false));
933        assert_eq!(detect_legacy_frames(&bare_hello()), None);
934    }
935
936    #[test]
937    fn legacy_hello_selects_legacy_frames_without_a_flag() {
938        let (mut s, wire, _tx) = sidecar(vec![legacy_hello(), legacy_ok(1)]);
939        assert_eq!(s.command("scan", input(), WAIT).unwrap(), json!({"n": 2}));
940        assert_eq!(wire.sent(), vec![legacy_frame(1)]);
941    }
942
943    #[test]
944    fn jobs_hello_selects_contract_frames() {
945        let (mut s, wire, _tx) = sidecar(vec![jobs_hello(), jobs_ok(1)]);
946        assert_eq!(s.command("scan", input(), WAIT).unwrap(), json!({"n": 2}));
947        assert_eq!(wire.sent(), vec![jobs_frame(1)]);
948    }
949
950    #[test]
951    fn per_command_flag_beats_a_jobs_hello_both_ways() {
952        let (mut s, wire, _tx) = sidecar(vec![jobs_hello(), legacy_ok(1), jobs_ok(2)]);
953        s.command_with("scan", input(), WAIT, Some(true)).unwrap();
954        s.command_with("scan", input(), WAIT, Some(false)).unwrap();
955        assert_eq!(wire.sent(), vec![legacy_frame(1), jobs_frame(2)]);
956    }
957
958    #[test]
959    fn per_command_flag_beats_a_legacy_hello() {
960        let (mut s, wire, _tx) = sidecar(vec![legacy_hello(), jobs_ok(1)]);
961        s.command_with("scan", input(), WAIT, Some(false)).unwrap();
962        assert_eq!(wire.sent(), vec![jobs_frame(1)]);
963    }
964
965    #[test]
966    fn sidecar_field_and_pin_beat_the_hello() {
967        let (mut s, wire, _tx) = sidecar(vec![jobs_hello(), legacy_ok(1), jobs_ok(2)]);
968        s.legacy_status_frames = true;
969        s.command("scan", input(), WAIT).unwrap();
970        s.set_frame_shape(Some(false));
971        s.command("scan", input(), WAIT).unwrap();
972        assert_eq!(wire.sent(), vec![legacy_frame(1), jobs_frame(2)]);
973    }
974
975    #[test]
976    fn sidecar_pin_beats_a_legacy_hello() {
977        let (mut s, wire, _tx) = sidecar(vec![legacy_hello(), jobs_ok(1)]);
978        s.set_frame_shape(Some(false));
979        s.command("scan", input(), WAIT).unwrap();
980        assert_eq!(wire.sent(), vec![jobs_frame(1)]);
981    }
982
983    #[test]
984    fn ambiguous_hello_and_silence_names_the_shape_and_the_switch() {
985        let (mut s, wire, _tx) = sidecar(vec![bare_hello()]);
986        let e = s.command("scan", input(), SILENT).unwrap_err().0;
987        assert!(
988            e.contains("did not answer the frame shape that was sent"),
989            "{e}"
990        );
991        assert!(e.contains("{type:\"request\", id, method, params}"), "{e}");
992        assert!(e.contains("legacy_status_frames"), "{e}");
993        assert!(e.contains("`scan`"), "{e}");
994        assert_eq!(wire.sent(), vec![jobs_frame(1)]);
995    }
996
997    #[test]
998    fn ambiguous_hello_with_a_frame_for_the_id_keeps_the_plain_timeout() {
999        let event = json!({"type": "event", "id": 1, "event": "progress"});
1000        let (mut s, _wire, _tx) = sidecar(vec![bare_hello(), event]);
1001        let e = s.command("scan", input(), SILENT).unwrap_err().0;
1002        assert!(e.starts_with("sidecar timeout"), "{e}");
1003        assert!(!e.contains("did not answer"), "{e}");
1004    }
1005
1006    #[test]
1007    fn explicit_shape_and_silence_keep_the_plain_timeout() {
1008        let (mut s, _wire, _tx) = sidecar(vec![bare_hello()]);
1009        let e = s
1010            .command_with("scan", input(), SILENT, Some(false))
1011            .unwrap_err()
1012            .0;
1013        assert!(e.starts_with("sidecar timeout"), "{e}");
1014        assert!(!e.contains("did not answer"), "{e}");
1015    }
1016
1017    /// `count` event frames; none answers request 1.
1018    fn events(count: usize) -> Vec<Value> {
1019        (0..count)
1020            .map(|n| json!({"type": "event", "id": 0, "n": n}))
1021            .collect()
1022    }
1023
1024    #[test]
1025    fn a_full_ring_keeps_the_newest_events_and_the_waiter_still_gets_its_answer() {
1026        let mut frames = vec![bare_hello()];
1027        frames.extend(events(10_000));
1028        frames.push(jobs_ok(1));
1029        let (mut s, _wire, _tx) = sidecar(frames);
1030        let result = s.command("scan", input(), WAIT).unwrap();
1031        assert_eq!(result, json!({"n": 2}));
1032        let status = s.status();
1033        // Every event is either held or counted as dropped.
1034        assert_eq!(s.events.len() as u64 + status.events_dropped, 10_000);
1035        assert!(
1036            (DEFAULT_EVENT_LIMIT..2 * DEFAULT_EVENT_LIMIT).contains(&s.events.len()),
1037            "held {}",
1038            s.events.len()
1039        );
1040        assert_eq!(s.events.last().unwrap()["n"], json!(9_999));
1041        assert_eq!(status.events_buffered, s.events.len());
1042    }
1043
1044    #[test]
1045    fn the_event_limit_is_configurable_and_trims_at_once() {
1046        let mut frames = vec![bare_hello()];
1047        frames.extend(events(50));
1048        let (mut s, _wire, _tx) = sidecar(frames);
1049        assert!(s.command("scan", input(), SILENT).is_err());
1050        assert_eq!(s.events.len(), 50);
1051        s.set_event_limit(5);
1052        assert_eq!(s.events.len(), 5);
1053        assert_eq!(s.events[0]["n"], json!(45));
1054        assert_eq!(s.status().events_dropped, 45);
1055    }
1056
1057    #[test]
1058    fn an_answer_seen_before_the_ring_trimmed_it_is_still_reported_as_answered() {
1059        let early = json!({"type": "event", "id": 1, "event": "progress"});
1060        let mut frames = vec![bare_hello(), early];
1061        frames.extend(events(3 * DEFAULT_EVENT_LIMIT));
1062        let (mut s, _wire, _tx) = sidecar(frames);
1063        let brief = Duration::from_millis(500);
1064        let e = s.command("scan", input(), brief).unwrap_err().0;
1065        assert!(e.starts_with("sidecar timeout"), "{e}");
1066        assert!(!e.contains("did not answer"), "{e}");
1067        assert!(s.status().events_dropped > 0);
1068        assert!(s.events.iter().all(|f| f["id"] != json!(1)));
1069    }
1070
1071    #[test]
1072    fn stderr_keeps_its_last_bytes_on_a_line_boundary_and_says_what_it_dropped() {
1073        let mut buf = StderrBuf::new(21);
1074        buf.push(&format!("{}\n{}", "a".repeat(10), "b".repeat(20)));
1075        // The cut lands inside the first line; the newline just after it moves the cut past that line.
1076        assert_eq!(buf.retained(), "b".repeat(20));
1077        assert_eq!(buf.dropped, 11);
1078        assert_eq!(buf.bytes(), 20);
1079        assert!(buf
1080            .tail(1500)
1081            .starts_with("[11 bytes of earlier stderr dropped]\n"));
1082    }
1083
1084    #[test]
1085    fn stderr_stays_within_its_limit_however_much_is_written() {
1086        const LIMIT: usize = 4096;
1087        let mut buf = StderrBuf::new(LIMIT);
1088        let mut written = 0u64;
1089        for n in 0..20_000 {
1090            let line = format!("progress {n:08}\n");
1091            written += line.len() as u64;
1092            buf.push(&line);
1093            assert!(buf.bytes() <= LIMIT);
1094            assert!(buf.text.len() < 2 * LIMIT, "dead prefix was not compacted");
1095        }
1096        assert_eq!(buf.dropped + buf.bytes() as u64, written);
1097        assert!(buf.retained().starts_with("progress "));
1098        assert!(buf.retained().ends_with("progress 00019999\n"));
1099    }
1100
1101    #[test]
1102    fn stderr_cuts_never_split_a_character() {
1103        let mut buf = StderrBuf::new(7);
1104        buf.push(&"é".repeat(40));
1105        assert!(buf.retained().chars().all(|c| c == 'é'));
1106        assert!(buf.bytes() <= 7);
1107    }
1108}
1109
1110#[cfg(test)]
1111mod launch_guard_tests {
1112    use super::*;
1113    use crate::userdata::Rule;
1114
1115    fn target() -> EngineTarget {
1116        EngineTarget::new("/nonexistent/engine")
1117            .with_data_dir_env("APP_DATA")
1118            .forbid_args(&["--use-user-library"])
1119    }
1120
1121    fn refusal(r: Result<()>) -> String {
1122        r.unwrap_err().0
1123    }
1124
1125    #[test]
1126    fn a_launch_without_a_data_dir_is_refused_for_cli_and_sidecar_alike() {
1127        let t = target();
1128        let words = vec!["doctor".to_string()];
1129        let e = refusal(t.check_launch(&words, &CliOptions::default()));
1130        assert!(e.contains(&Rule::NoIsolatedDataDir.to_string()), "{e}");
1131        assert!(e.contains("/nonexistent/engine"), "{e}");
1132        let e = refusal(t.check_launch(&["--stdio".to_string()], &CliOptions::default()));
1133        assert!(e.contains(&Rule::NoIsolatedDataDir.to_string()), "{e}");
1134    }
1135
1136    #[test]
1137    fn a_data_dir_reaches_the_engine_only_through_a_declared_variable() {
1138        let dir = crate::storage::app_temp_dir().join("rkqa-engine-guard-never-created");
1139        let opts = CliOptions {
1140            data_dir: Some(dir),
1141            ..Default::default()
1142        };
1143        let words = vec!["doctor".to_string()];
1144        target().check_launch(&words, &opts).unwrap();
1145        let mut undeclared = target();
1146        undeclared.data_dir_env = None;
1147        let e = refusal(undeclared.check_launch(&words, &opts));
1148        assert!(e.contains(&Rule::NoIsolatedDataDir.to_string()), "{e}");
1149    }
1150
1151    #[test]
1152    fn a_forbidden_flag_is_refused_whatever_the_data_dir() {
1153        let dir = crate::storage::app_temp_dir().join("rkqa-engine-guard-never-created");
1154        let opts = CliOptions {
1155            data_dir: Some(dir),
1156            ..Default::default()
1157        };
1158        let words = vec!["doctor".to_string(), "--use-user-library".to_string()];
1159        let e = refusal(target().check_launch(&words, &opts));
1160        assert!(e.contains(&Rule::ForbiddenArgument.to_string()), "{e}");
1161        // The target's deny list is its own: a bare target forbids nothing.
1162        let mut bare = target();
1163        bare.forbidden_args.clear();
1164        bare.check_launch(&words, &opts).unwrap();
1165    }
1166
1167    #[test]
1168    fn the_probe_label_alone_never_opens_a_launch() {
1169        let t = target().allow_no_data_dir_for_refusal_probe();
1170        assert!(t.allow_no_data_dir_for_refusal_probe);
1171        // No HOME redirect in the launch environment: still refused (on Windows always).
1172        let e = refusal(t.check_launch(&["doctor".to_string()], &CliOptions::default()));
1173        assert!(e.contains(&Rule::ProbeUnavailable.to_string()), "{e}");
1174    }
1175}