1use 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
18const TIMEOUT_PREFIX: &str = "sidecar timeout after";
20const DEFAULT_EVENT_LIMIT: usize = 2_048;
22const DEFAULT_STDERR_LIMIT: usize = 256 * 1024;
24
25#[derive(Debug, Clone)]
35pub struct EngineTarget {
36 pub binary: PathBuf,
37 pub env: Vec<(String, String)>,
39 pub data_dir_env: Option<String>,
41 pub data_dir_arg: Option<String>,
44 pub forbidden_args: Vec<String>,
48 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 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 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 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 pub fn with_data_dir_env(mut self, var: &str) -> Self {
95 self.data_dir_env = Some(var.into());
96 self
97 }
98
99 pub fn with_data_dir_arg(mut self, flag: &str) -> Self {
101 self.data_dir_arg = Some(flag.into());
102 self
103 }
104
105 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 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 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 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 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 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
260pub 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 pub events: Vec<Value>,
275 events_dropped: u64,
277 event_limit: usize,
278 watch: Option<(u64, bool)>,
281 pub legacy_status_frames: bool,
285 detected_legacy: Option<bool>,
287 frame_shape: Option<bool>,
289 tracker: Tracker,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct SidecarStatus {
295 pub pid: u32,
296 pub requests_sent: u64,
298 pub events_buffered: usize,
301 pub events_dropped: u64,
303 pub stderr_bytes: usize,
305 pub stderr_dropped_bytes: u64,
307 pub process: Option<rightkit_child_process::ProcessUsage>,
311}
312
313pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353enum ShapeSource {
354 Explicit,
356 Detected,
358 Fallback,
360}
361
362struct Exchange {
364 error: Error,
365 id: u64,
366 answered: bool,
367}
368
369struct StderrBuf {
373 text: String,
374 start: usize,
375 limit: usize,
376 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 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 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, }
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 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 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 pub fn set_event_limit(&mut self, limit: usize) {
578 self.event_limit = limit;
579 self.drop_oldest_events(limit);
580 }
581
582 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 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 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 pub fn raw(&mut self, frame: Value, timeout: Duration) -> Result<Value> {
618 self.request(frame, timeout).map_err(|x| x.error)
619 }
620
621 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 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 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 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 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
755pub 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 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 #[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 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 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 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 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 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 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 let e = refusal(t.check_launch(&["doctor".to_string()], &CliOptions::default()));
1173 assert!(e.contains(&Rule::ProbeUnavailable.to_string()), "{e}");
1174 }
1175}