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 fn env_for(
140 &self,
141 data_dir: Option<&Path>,
142 extra: &[(String, String)],
143 ) -> Vec<(String, String)> {
144 let mut env = self.env.clone();
145 if let (Some(var), Some(dir)) = (&self.data_dir_env, data_dir) {
146 env.push((var.clone(), dir.to_string_lossy().into()));
147 }
148 env.extend(extra.iter().cloned());
149 env
150 }
151
152 pub fn cli(&self, args: &[String], opts: &CliOptions, tracker: &Tracker) -> Result<CliResult> {
153 if !self.binary.exists() {
154 return err(format!(
155 "engine binary not found: {}",
156 self.binary.display()
157 ));
158 }
159 self.check_launch(args, opts)?;
160 let mut argv = args.to_vec();
161 if let Some(input) = &opts.input {
162 argv.push("--input".into());
163 argv.push(input.to_string());
164 }
165 let out = run_owned(
166 &self.binary.to_string_lossy(),
167 &argv,
168 &RunOptions {
169 env: self.env_for(opts.data_dir.as_deref(), &opts.env),
170 timeout: opts.timeout,
171 label: format!("engine {}", args.join(" ")),
172 ..Default::default()
173 },
174 tracker,
175 )?;
176 let json = serde_json::from_str(out.stdout.trim()).unwrap_or(Value::Null);
177 let events = out
178 .stderr
179 .lines()
180 .map(str::trim)
181 .filter(|l| l.starts_with('{'))
182 .filter_map(|l| serde_json::from_str(l).ok())
183 .collect();
184 Ok(CliResult {
185 code: out.code,
186 json,
187 stdout: out.stdout,
188 stderr: out.stderr,
189 events,
190 timed_out: out.timed_out,
191 })
192 }
193
194 pub fn run(
196 &self,
197 words: &str,
198 input: Value,
199 opts: &CliOptions,
200 tracker: &Tracker,
201 ) -> Result<Value> {
202 let args: Vec<String> = words.split_whitespace().map(String::from).collect();
203 let r = self.cli(
204 &args,
205 &CliOptions {
206 input: Some(input),
207 ..opts.clone()
208 },
209 tracker,
210 )?;
211 if r.code != Some(0) || r.json.get("error").map(|e| !e.is_null()).unwrap_or(false) {
212 return err(format!(
213 "engine command failed ({:?}): {words}\nstdout: {}\nstderr: {}",
214 r.code,
215 r.stdout,
216 crate::util::tail(&r.stderr, 2000)
217 ));
218 }
219 Ok(r.json)
220 }
221
222 pub fn start_sidecar(&self, opts: &CliOptions, tracker: &Tracker) -> Result<Sidecar> {
223 Sidecar::start(self, opts, tracker)
224 }
225}
226
227pub struct Sidecar {
231 child: Option<OwnedChild>,
232 stdin: Option<Box<dyn Write + Send>>,
233 rx: Receiver<Value>,
234 seq: u64,
235 pub hello: Value,
236 pub pid: u32,
237 stderr: Arc<Mutex<StderrBuf>>,
238 pub events: Vec<Value>,
242 events_dropped: u64,
244 event_limit: usize,
245 watch: Option<(u64, bool)>,
248 pub legacy_status_frames: bool,
252 detected_legacy: Option<bool>,
254 frame_shape: Option<bool>,
256 tracker: Tracker,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq)]
261pub struct SidecarStatus {
262 pub pid: u32,
263 pub requests_sent: u64,
265 pub events_buffered: usize,
268 pub events_dropped: u64,
270 pub stderr_bytes: usize,
272 pub stderr_dropped_bytes: u64,
274 pub process: Option<rightkit_child_process::ProcessUsage>,
278}
279
280pub(crate) fn detect_legacy_frames(hello: &Value) -> Option<bool> {
285 if hello.get("contract_version").is_some() {
286 Some(false)
287 } else if hello.get("rpc_version").is_some() {
288 Some(true)
289 } else {
290 None
291 }
292}
293
294fn command_frame(name: &str, input: Value, legacy: bool) -> Value {
295 if legacy {
296 json!({"type": "request", "method": {"method": "command", "params": {"name": name, "input": input}}})
297 } else {
298 json!({"type": "request", "method": name, "params": input})
299 }
300}
301
302fn command_outcome(f: &Value, legacy: bool) -> Result<Value> {
303 let failed = if legacy {
304 f["status"] == "err"
305 } else {
306 f["ok"] != json!(true)
307 };
308 if failed {
309 return err(format!(
310 "{}: {}",
311 f["error"]["code"].as_str().unwrap_or("error"),
312 f["error"]["message"].as_str().unwrap_or("")
313 ));
314 }
315 Ok(f["result"].clone())
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320enum ShapeSource {
321 Explicit,
323 Detected,
325 Fallback,
327}
328
329struct Exchange {
331 error: Error,
332 id: u64,
333 answered: bool,
334}
335
336struct StderrBuf {
340 text: String,
341 start: usize,
342 limit: usize,
343 dropped: u64,
345}
346
347impl StderrBuf {
348 fn new(limit: usize) -> Self {
349 Self {
350 text: String::new(),
351 start: 0,
352 limit,
353 dropped: 0,
354 }
355 }
356
357 fn push(&mut self, chunk: &str) {
358 self.text.push_str(chunk);
359 self.trim();
360 }
361
362 fn set_limit(&mut self, limit: usize) {
363 self.limit = limit;
364 self.trim();
365 }
366
367 fn trim(&mut self) {
370 if self.text.len() - self.start > self.limit {
371 let mut cut = self.text.len() - self.limit;
372 while !self.text.is_char_boundary(cut) {
373 cut += 1;
374 }
375 let window_end = (cut + self.limit / 8).min(self.text.len());
376 if let Some(p) = self.text.as_bytes()[cut..window_end]
377 .iter()
378 .position(|&b| b == b'\n')
379 {
380 cut += p + 1;
381 }
382 self.dropped += (cut - self.start) as u64;
383 self.start = cut;
384 }
385 if self.start >= self.limit {
386 self.text.drain(..self.start);
387 self.start = 0;
388 }
389 }
390
391 fn retained(&self) -> &str {
392 &self.text[self.start..]
393 }
394
395 fn bytes(&self) -> usize {
396 self.text.len() - self.start
397 }
398
399 fn tail(&self, max: usize) -> String {
401 let tail = crate::util::tail(self.retained(), max);
402 if self.dropped == 0 {
403 tail
404 } else {
405 format!("[{} bytes of earlier stderr dropped]\n{tail}", self.dropped)
406 }
407 }
408}
409
410impl Sidecar {
411 fn start(engine: &EngineTarget, opts: &CliOptions, tracker: &Tracker) -> Result<Self> {
412 engine.check_launch(&["--stdio".to_string()], opts)?;
413 let mut cmd = Command::new(&engine.binary);
414 cmd.arg("--stdio")
415 .stdin(Stdio::piped())
416 .stdout(Stdio::piped())
417 .stderr(Stdio::piped());
418 for (k, v) in engine.env_for(opts.data_dir.as_deref(), &opts.env) {
419 cmd.env(k, v);
420 }
421 let mut owned = OwnedCommand::from_command(cmd);
422 owned.windows_hide();
423 let mut child = owned.spawn().map_err(|e| {
424 Error(format!(
425 "failed to start sidecar {}: {e}",
426 engine.binary.display()
427 ))
428 })?;
429 let pid = child.id();
430 tracker.register(pid, "engine --stdio");
431 let stdin = child.take_stdin();
432 let stdout = child
433 .take_stdout()
434 .ok_or_else(|| Error("sidecar stdout unavailable".into()))?;
435 let mut stderr_pipe = child.take_stderr();
436 let stderr = Arc::new(Mutex::new(StderrBuf::new(DEFAULT_STDERR_LIMIT)));
437 let sink = stderr.clone();
438 std::thread::spawn(move || {
439 use std::io::Read;
440 let mut buf = [0u8; 4096];
441 if let Some(p) = stderr_pipe.as_mut() {
442 while let Ok(n) = p.read(&mut buf) {
443 if n == 0 {
444 break;
445 }
446 sink.lock()
447 .unwrap()
448 .push(&String::from_utf8_lossy(&buf[..n]));
449 }
450 }
451 });
452 let (tx, rx) = channel();
453 std::thread::spawn(move || {
454 let mut reader = BufReader::new(stdout);
455 let mut buf = Vec::new();
456 loop {
457 match read_json_line::<_, Value>(&mut reader, &mut buf, FRAME_LIMIT) {
458 Ok(Some(v)) => {
459 if tx.send(v).is_err() {
460 break;
461 }
462 }
463 Ok(None) => break,
464 Err(FrameError::Io(_)) => break,
465 Err(_) => continue, }
467 }
468 });
469 Self {
470 child: Some(child),
471 stdin: stdin.map(|s| Box::new(s) as Box<dyn Write + Send>),
472 rx,
473 seq: 0,
474 hello: Value::Null,
475 pid,
476 stderr,
477 events: vec![],
478 events_dropped: 0,
479 event_limit: DEFAULT_EVENT_LIMIT,
480 watch: None,
481 legacy_status_frames: false,
482 detected_legacy: None,
483 frame_shape: None,
484 tracker: tracker.clone(),
485 }
486 .finish_start()
487 }
488
489 fn finish_start(mut self) -> Result<Self> {
491 self.hello = self.await_frame(Duration::from_secs(30), |f| f["type"] == "hello")?;
492 self.detected_legacy = detect_legacy_frames(&self.hello);
493 Ok(self)
494 }
495
496 fn await_frame(
497 &mut self,
498 timeout: Duration,
499 mut want: impl FnMut(&Value) -> bool,
500 ) -> Result<Value> {
501 let deadline = Instant::now() + timeout;
502 loop {
503 let left = deadline.saturating_duration_since(Instant::now());
504 match self.rx.recv_timeout(left.max(Duration::from_millis(1))) {
505 Ok(f) if want(&f) => return Ok(f),
506 Ok(f) => self.push_event(f),
507 Err(RecvTimeoutError::Timeout) => {
508 return err(format!(
509 "{} {}ms; stderr tail: {}",
510 TIMEOUT_PREFIX,
511 timeout.as_millis(),
512 self.stderr_tail()
513 ))
514 }
515 Err(RecvTimeoutError::Disconnected) => {
516 return err(format!(
517 "sidecar closed its stdout; stderr tail: {}",
518 self.stderr_tail()
519 ))
520 }
521 }
522 }
523 }
524
525 pub fn status(&self) -> SidecarStatus {
527 let (stderr_bytes, stderr_dropped_bytes) = self
528 .stderr
529 .lock()
530 .map_or((0, 0), |s| (s.bytes(), s.dropped));
531 SidecarStatus {
532 pid: self.pid,
533 requests_sent: self.seq,
534 events_buffered: self.events.len(),
535 events_dropped: self.events_dropped,
536 stderr_bytes,
537 stderr_dropped_bytes,
538 process: rightkit_child_process::process_usage().ok(),
539 }
540 }
541
542 pub fn set_event_limit(&mut self, limit: usize) {
545 self.event_limit = limit;
546 self.drop_oldest_events(limit);
547 }
548
549 pub fn set_stderr_limit(&mut self, bytes: usize) {
551 if let Ok(mut buf) = self.stderr.lock() {
552 buf.set_limit(bytes);
553 }
554 }
555
556 pub fn stderr_tail(&self) -> String {
557 self.stderr.lock().unwrap().tail(1500)
558 }
559
560 fn push_event(&mut self, frame: Value) {
563 if let Some((id, seen)) = self.watch.as_mut() {
564 if frame["id"] == json!(*id) {
565 *seen = true;
566 }
567 }
568 self.events.push(frame);
569 if self.events.len() >= self.event_limit.saturating_mul(2).max(1) {
570 self.drop_oldest_events(self.event_limit);
571 }
572 }
573
574 fn drop_oldest_events(&mut self, keep: usize) {
576 if self.events.len() > keep {
577 let excess = self.events.len() - keep;
578 self.events.drain(..excess);
579 self.events_dropped += excess as u64;
580 }
581 }
582
583 pub fn raw(&mut self, frame: Value, timeout: Duration) -> Result<Value> {
585 self.request(frame, timeout).map_err(|x| x.error)
586 }
587
588 fn request(
591 &mut self,
592 mut frame: Value,
593 timeout: Duration,
594 ) -> std::result::Result<Value, Exchange> {
595 self.seq += 1;
596 let id = self.seq;
597 frame["id"] = json!(id);
598 let written = match self.stdin.as_mut() {
599 Some(stdin) => write_json_line(stdin, &frame, FRAME_LIMIT)
600 .map_err(|e| Error(e.to_string()))
601 .and_then(|()| stdin.flush().map_err(|e| Error(e.to_string()))),
602 None => Err(Error("sidecar stdin closed".into())),
603 };
604 if let Err(error) = written {
605 return Err(Exchange {
606 error,
607 id,
608 answered: false,
609 });
610 }
611 self.watch = Some((id, false));
612 let waited = self.await_frame(timeout, |f| f["type"] == "response" && f["id"] == json!(id));
613 let answered = self.watch.take().is_some_and(|(_, seen)| seen);
614 waited.map_err(|error| Exchange {
615 error,
616 id,
617 answered,
618 })
619 }
620
621 fn resolve_shape(&self, per_command: Option<bool>) -> (bool, ShapeSource) {
624 if let Some(legacy) = per_command.or(self.frame_shape) {
625 return (legacy, ShapeSource::Explicit);
626 }
627 if self.legacy_status_frames {
628 return (true, ShapeSource::Explicit);
629 }
630 match self.detected_legacy {
631 Some(legacy) => (legacy, ShapeSource::Detected),
632 None => (false, ShapeSource::Fallback),
633 }
634 }
635
636 pub fn command(&mut self, name: &str, input: Value, timeout: Duration) -> Result<Value> {
642 self.command_with(name, input, timeout, None)
643 }
644
645 pub fn command_with(
646 &mut self,
647 name: &str,
648 input: Value,
649 timeout: Duration,
650 legacy_status_frames: Option<bool>,
651 ) -> Result<Value> {
652 let (legacy, source) = self.resolve_shape(legacy_status_frames);
653 match self.request(command_frame(name, input, legacy), timeout) {
654 Ok(f) => command_outcome(&f, legacy),
655 Err(x)
656 if source == ShapeSource::Fallback
657 && !x.answered
658 && x.error.0.starts_with(TIMEOUT_PREFIX) =>
659 {
660 err(format!(
661 "engine did not answer the frame shape that was sent for `{name}`: sent the \
662 rightkit-job-queue contract {{type:\"request\", id, method, params}} and no frame \
663 came back for request id {} within {}ms. The hello carried neither \
664 contract_version nor rpc_version, so the shape was not detected. If this engine \
665 speaks GenRight's legacy frames ({{type:\"request\", id, \
666 method:{{method:\"command\", params:{{name, input}}}}}} answered by status: \
667 ok|err), set legacy_status_frames = true on sidecar_start or on this \
668 sidecar_command. stderr tail: {}",
669 x.id,
670 timeout.as_millis(),
671 self.stderr_tail()
672 ))
673 }
674 Err(x) => Err(x.error),
675 }
676 }
677
678 pub fn set_frame_shape(&mut self, legacy: Option<bool>) {
682 self.frame_shape = legacy;
683 }
684
685 pub fn shutdown(&mut self) {
686 self.seq += 1;
687 if let Some(stdin) = self.stdin.as_mut() {
688 let _ = write_json_line(
689 stdin,
690 &json!({"type":"request","id":self.seq,"method":{"method":"shutdown"}}),
691 FRAME_LIMIT,
692 );
693 }
694 }
695
696 pub fn close(&mut self) -> Option<i32> {
699 self.stdin.take();
700 let mut code = None;
701 if let Some(mut child) = self.child.take() {
702 match child.wait_timeout(Duration::from_secs(5)) {
703 Ok(Some(s)) => code = s.code(),
704 _ => {
705 if let (_, Some(note)) = crate::process::terminate_bounded(&mut child) {
706 eprintln!("{note}");
707 }
708 }
709 }
710 }
711 self.tracker.forget(self.pid);
712 code
713 }
714}
715
716impl Drop for Sidecar {
717 fn drop(&mut self) {
718 let _ = self.close();
719 }
720}
721
722pub fn build_engine(
725 cwd: &Path,
726 command: &[String],
727 package: &str,
728 env: &[(String, String)],
729 tracker: &Tracker,
730) -> Result<PathBuf> {
731 let Some((program, rest)) = command.split_first() else {
732 return err("engine.build is empty");
733 };
734 let mut args = rest.to_vec();
735 if !args.iter().any(|a| a.starts_with("--message-format")) {
736 args.push("--message-format=json-render-diagnostics".into());
737 }
738 let out = run_owned(
739 program,
740 &args,
741 &RunOptions {
742 cwd: Some(cwd.to_path_buf()),
743 env: env.to_vec(),
744 timeout: Some(Duration::from_secs(45 * 60)),
745 label: "engine build".into(),
746 ..Default::default()
747 },
748 tracker,
749 )?;
750 if out.code != Some(0) {
751 return err(format!(
752 "engine build failed ({:?}):\n{}",
753 out.code,
754 crate::util::tail(&out.stderr, 4000)
755 ));
756 }
757 out.stdout
758 .lines()
759 .filter(|l| l.starts_with('{'))
760 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
761 .filter(|m| m["reason"] == "compiler-artifact" && m["target"]["name"] == package)
762 .filter_map(|m| m["executable"].as_str().map(PathBuf::from))
763 .next_back()
764 .ok_or_else(|| {
765 Error(format!(
766 "build produced no executable for package {package}"
767 ))
768 })
769}
770
771#[cfg(test)]
772mod command_shape_tests {
773 use super::*;
774
775 #[test]
776 fn jobs_contract_is_the_default_frame() {
777 let f = command_frame("scan", json!({"a": 1}), false);
778 assert_eq!(
779 f,
780 json!({"type":"request","method":"scan","params":{"a":1}})
781 );
782 let ok = json!({"type":"response","id":1,"ok":true,"result":{"n":2}});
783 assert_eq!(command_outcome(&ok, false).unwrap(), json!({"n":2}));
784 let bad =
785 json!({"type":"response","id":1,"ok":false,"error":{"code":"nope","message":"m"}});
786 assert_eq!(command_outcome(&bad, false).unwrap_err().0, "nope: m");
787 assert!(command_outcome(&json!({"type":"response","id":1}), false).is_err());
789 }
790
791 #[test]
792 fn legacy_status_frames_keep_the_old_shape() {
793 let f = command_frame("scan", json!({"a": 1}), true);
794 assert_eq!(
795 f,
796 json!({"type":"request","method":{"method":"command","params":{"name":"scan","input":{"a":1}}}})
797 );
798 let ok = json!({"type":"response","id":1,"status":"ok","result":{"n":2}});
799 assert_eq!(command_outcome(&ok, true).unwrap(), json!({"n":2}));
800 let bad =
801 json!({"type":"response","id":1,"status":"err","error":{"code":"c","message":"m"}});
802 assert_eq!(command_outcome(&bad, true).unwrap_err().0, "c: m");
803 }
804}
805
806#[cfg(test)]
807mod frame_shape_tests {
808 use super::*;
809 use std::sync::mpsc::Sender;
810
811 #[derive(Clone, Default)]
813 struct Wire(Arc<Mutex<Vec<u8>>>);
814
815 impl Write for Wire {
816 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
817 self.0.lock().unwrap().extend_from_slice(buf);
818 Ok(buf.len())
819 }
820 fn flush(&mut self) -> std::io::Result<()> {
821 Ok(())
822 }
823 }
824
825 impl Wire {
826 fn sent(&self) -> Vec<Value> {
827 let bytes = self.0.lock().unwrap().clone();
828 String::from_utf8(bytes)
829 .unwrap()
830 .lines()
831 .map(|l| serde_json::from_str(l).unwrap())
832 .collect()
833 }
834 }
835
836 fn sidecar(frames: Vec<Value>) -> (Sidecar, Wire, Sender<Value>) {
840 let (tx, rx) = channel();
841 for f in frames {
842 tx.send(f).unwrap();
843 }
844 let wire = Wire::default();
845 let s = Sidecar {
846 child: None,
847 stdin: Some(Box::new(wire.clone())),
848 rx,
849 seq: 0,
850 hello: Value::Null,
851 pid: 0,
852 stderr: Arc::new(Mutex::new(StderrBuf::new(DEFAULT_STDERR_LIMIT))),
853 events: vec![],
854 events_dropped: 0,
855 event_limit: DEFAULT_EVENT_LIMIT,
856 watch: None,
857 legacy_status_frames: false,
858 detected_legacy: None,
859 frame_shape: None,
860 tracker: Tracker::new(),
861 }
862 .finish_start()
863 .unwrap();
864 (s, wire, tx)
865 }
866
867 fn legacy_hello() -> Value {
868 json!({"type": "hello", "rpc_version": 1, "build": "0.1.0", "pid": 7})
869 }
870 fn jobs_hello() -> Value {
871 json!({"type": "hello", "engine": "demo", "version": "1.0.0", "contract_version": 1})
872 }
873 fn bare_hello() -> Value {
875 json!({"type": "hello", "engine": "demo"})
876 }
877 fn legacy_ok(id: u64) -> Value {
878 json!({"type": "response", "status": "ok", "id": id, "result": {"n": 2}})
879 }
880 fn jobs_ok(id: u64) -> Value {
881 json!({"type": "response", "id": id, "ok": true, "result": {"n": 2}})
882 }
883 fn input() -> Value {
884 json!({"a": 1})
885 }
886 fn legacy_frame(id: u64) -> Value {
887 json!({"type": "request", "id": id,
888 "method": {"method": "command", "params": {"name": "scan", "input": {"a": 1}}}})
889 }
890 fn jobs_frame(id: u64) -> Value {
891 json!({"type": "request", "id": id, "method": "scan", "params": {"a": 1}})
892 }
893 const WAIT: Duration = Duration::from_secs(5);
894 const SILENT: Duration = Duration::from_millis(50);
895
896 #[test]
897 fn detection_reads_the_hello_fields() {
898 assert_eq!(detect_legacy_frames(&legacy_hello()), Some(true));
899 assert_eq!(detect_legacy_frames(&jobs_hello()), Some(false));
900 assert_eq!(detect_legacy_frames(&bare_hello()), None);
901 }
902
903 #[test]
904 fn legacy_hello_selects_legacy_frames_without_a_flag() {
905 let (mut s, wire, _tx) = sidecar(vec![legacy_hello(), legacy_ok(1)]);
906 assert_eq!(s.command("scan", input(), WAIT).unwrap(), json!({"n": 2}));
907 assert_eq!(wire.sent(), vec![legacy_frame(1)]);
908 }
909
910 #[test]
911 fn jobs_hello_selects_contract_frames() {
912 let (mut s, wire, _tx) = sidecar(vec![jobs_hello(), jobs_ok(1)]);
913 assert_eq!(s.command("scan", input(), WAIT).unwrap(), json!({"n": 2}));
914 assert_eq!(wire.sent(), vec![jobs_frame(1)]);
915 }
916
917 #[test]
918 fn per_command_flag_beats_a_jobs_hello_both_ways() {
919 let (mut s, wire, _tx) = sidecar(vec![jobs_hello(), legacy_ok(1), jobs_ok(2)]);
920 s.command_with("scan", input(), WAIT, Some(true)).unwrap();
921 s.command_with("scan", input(), WAIT, Some(false)).unwrap();
922 assert_eq!(wire.sent(), vec![legacy_frame(1), jobs_frame(2)]);
923 }
924
925 #[test]
926 fn per_command_flag_beats_a_legacy_hello() {
927 let (mut s, wire, _tx) = sidecar(vec![legacy_hello(), jobs_ok(1)]);
928 s.command_with("scan", input(), WAIT, Some(false)).unwrap();
929 assert_eq!(wire.sent(), vec![jobs_frame(1)]);
930 }
931
932 #[test]
933 fn sidecar_field_and_pin_beat_the_hello() {
934 let (mut s, wire, _tx) = sidecar(vec![jobs_hello(), legacy_ok(1), jobs_ok(2)]);
935 s.legacy_status_frames = true;
936 s.command("scan", input(), WAIT).unwrap();
937 s.set_frame_shape(Some(false));
938 s.command("scan", input(), WAIT).unwrap();
939 assert_eq!(wire.sent(), vec![legacy_frame(1), jobs_frame(2)]);
940 }
941
942 #[test]
943 fn sidecar_pin_beats_a_legacy_hello() {
944 let (mut s, wire, _tx) = sidecar(vec![legacy_hello(), jobs_ok(1)]);
945 s.set_frame_shape(Some(false));
946 s.command("scan", input(), WAIT).unwrap();
947 assert_eq!(wire.sent(), vec![jobs_frame(1)]);
948 }
949
950 #[test]
951 fn ambiguous_hello_and_silence_names_the_shape_and_the_switch() {
952 let (mut s, wire, _tx) = sidecar(vec![bare_hello()]);
953 let e = s.command("scan", input(), SILENT).unwrap_err().0;
954 assert!(
955 e.contains("did not answer the frame shape that was sent"),
956 "{e}"
957 );
958 assert!(e.contains("{type:\"request\", id, method, params}"), "{e}");
959 assert!(e.contains("legacy_status_frames"), "{e}");
960 assert!(e.contains("`scan`"), "{e}");
961 assert_eq!(wire.sent(), vec![jobs_frame(1)]);
962 }
963
964 #[test]
965 fn ambiguous_hello_with_a_frame_for_the_id_keeps_the_plain_timeout() {
966 let event = json!({"type": "event", "id": 1, "event": "progress"});
967 let (mut s, _wire, _tx) = sidecar(vec![bare_hello(), event]);
968 let e = s.command("scan", input(), SILENT).unwrap_err().0;
969 assert!(e.starts_with("sidecar timeout"), "{e}");
970 assert!(!e.contains("did not answer"), "{e}");
971 }
972
973 #[test]
974 fn explicit_shape_and_silence_keep_the_plain_timeout() {
975 let (mut s, _wire, _tx) = sidecar(vec![bare_hello()]);
976 let e = s
977 .command_with("scan", input(), SILENT, Some(false))
978 .unwrap_err()
979 .0;
980 assert!(e.starts_with("sidecar timeout"), "{e}");
981 assert!(!e.contains("did not answer"), "{e}");
982 }
983
984 fn events(count: usize) -> Vec<Value> {
986 (0..count)
987 .map(|n| json!({"type": "event", "id": 0, "n": n}))
988 .collect()
989 }
990
991 #[test]
992 fn a_full_ring_keeps_the_newest_events_and_the_waiter_still_gets_its_answer() {
993 let mut frames = vec![bare_hello()];
994 frames.extend(events(10_000));
995 frames.push(jobs_ok(1));
996 let (mut s, _wire, _tx) = sidecar(frames);
997 let result = s.command("scan", input(), WAIT).unwrap();
998 assert_eq!(result, json!({"n": 2}));
999 let status = s.status();
1000 assert_eq!(s.events.len() as u64 + status.events_dropped, 10_000);
1002 assert!(
1003 (DEFAULT_EVENT_LIMIT..2 * DEFAULT_EVENT_LIMIT).contains(&s.events.len()),
1004 "held {}",
1005 s.events.len()
1006 );
1007 assert_eq!(s.events.last().unwrap()["n"], json!(9_999));
1008 assert_eq!(status.events_buffered, s.events.len());
1009 }
1010
1011 #[test]
1012 fn the_event_limit_is_configurable_and_trims_at_once() {
1013 let mut frames = vec![bare_hello()];
1014 frames.extend(events(50));
1015 let (mut s, _wire, _tx) = sidecar(frames);
1016 assert!(s.command("scan", input(), SILENT).is_err());
1017 assert_eq!(s.events.len(), 50);
1018 s.set_event_limit(5);
1019 assert_eq!(s.events.len(), 5);
1020 assert_eq!(s.events[0]["n"], json!(45));
1021 assert_eq!(s.status().events_dropped, 45);
1022 }
1023
1024 #[test]
1025 fn an_answer_seen_before_the_ring_trimmed_it_is_still_reported_as_answered() {
1026 let early = json!({"type": "event", "id": 1, "event": "progress"});
1027 let mut frames = vec![bare_hello(), early];
1028 frames.extend(events(3 * DEFAULT_EVENT_LIMIT));
1029 let (mut s, _wire, _tx) = sidecar(frames);
1030 let brief = Duration::from_millis(500);
1031 let e = s.command("scan", input(), brief).unwrap_err().0;
1032 assert!(e.starts_with("sidecar timeout"), "{e}");
1033 assert!(!e.contains("did not answer"), "{e}");
1034 assert!(s.status().events_dropped > 0);
1035 assert!(s.events.iter().all(|f| f["id"] != json!(1)));
1036 }
1037
1038 #[test]
1039 fn stderr_keeps_its_last_bytes_on_a_line_boundary_and_says_what_it_dropped() {
1040 let mut buf = StderrBuf::new(21);
1041 buf.push(&format!("{}\n{}", "a".repeat(10), "b".repeat(20)));
1042 assert_eq!(buf.retained(), "b".repeat(20));
1044 assert_eq!(buf.dropped, 11);
1045 assert_eq!(buf.bytes(), 20);
1046 assert!(buf
1047 .tail(1500)
1048 .starts_with("[11 bytes of earlier stderr dropped]\n"));
1049 }
1050
1051 #[test]
1052 fn stderr_stays_within_its_limit_however_much_is_written() {
1053 const LIMIT: usize = 4096;
1054 let mut buf = StderrBuf::new(LIMIT);
1055 let mut written = 0u64;
1056 for n in 0..20_000 {
1057 let line = format!("progress {n:08}\n");
1058 written += line.len() as u64;
1059 buf.push(&line);
1060 assert!(buf.bytes() <= LIMIT);
1061 assert!(buf.text.len() < 2 * LIMIT, "dead prefix was not compacted");
1062 }
1063 assert_eq!(buf.dropped + buf.bytes() as u64, written);
1064 assert!(buf.retained().starts_with("progress "));
1065 assert!(buf.retained().ends_with("progress 00019999\n"));
1066 }
1067
1068 #[test]
1069 fn stderr_cuts_never_split_a_character() {
1070 let mut buf = StderrBuf::new(7);
1071 buf.push(&"é".repeat(40));
1072 assert!(buf.retained().chars().all(|c| c == 'é'));
1073 assert!(buf.bytes() <= 7);
1074 }
1075}
1076
1077#[cfg(test)]
1078mod launch_guard_tests {
1079 use super::*;
1080 use crate::userdata::Rule;
1081
1082 fn target() -> EngineTarget {
1083 EngineTarget::new("/nonexistent/engine")
1084 .with_data_dir_env("APP_DATA")
1085 .forbid_args(&["--use-user-library"])
1086 }
1087
1088 fn refusal(r: Result<()>) -> String {
1089 r.unwrap_err().0
1090 }
1091
1092 #[test]
1093 fn a_launch_without_a_data_dir_is_refused_for_cli_and_sidecar_alike() {
1094 let t = target();
1095 let words = vec!["doctor".to_string()];
1096 let e = refusal(t.check_launch(&words, &CliOptions::default()));
1097 assert!(e.contains(&Rule::NoIsolatedDataDir.to_string()), "{e}");
1098 assert!(e.contains("/nonexistent/engine"), "{e}");
1099 let e = refusal(t.check_launch(&["--stdio".to_string()], &CliOptions::default()));
1100 assert!(e.contains(&Rule::NoIsolatedDataDir.to_string()), "{e}");
1101 }
1102
1103 #[test]
1104 fn a_data_dir_reaches_the_engine_only_through_a_declared_variable() {
1105 let dir = crate::storage::app_temp_dir().join("rkqa-engine-guard-never-created");
1106 let opts = CliOptions {
1107 data_dir: Some(dir),
1108 ..Default::default()
1109 };
1110 let words = vec!["doctor".to_string()];
1111 target().check_launch(&words, &opts).unwrap();
1112 let mut undeclared = target();
1113 undeclared.data_dir_env = None;
1114 let e = refusal(undeclared.check_launch(&words, &opts));
1115 assert!(e.contains(&Rule::NoIsolatedDataDir.to_string()), "{e}");
1116 }
1117
1118 #[test]
1119 fn a_forbidden_flag_is_refused_whatever_the_data_dir() {
1120 let dir = crate::storage::app_temp_dir().join("rkqa-engine-guard-never-created");
1121 let opts = CliOptions {
1122 data_dir: Some(dir),
1123 ..Default::default()
1124 };
1125 let words = vec!["doctor".to_string(), "--use-user-library".to_string()];
1126 let e = refusal(target().check_launch(&words, &opts));
1127 assert!(e.contains(&Rule::ForbiddenArgument.to_string()), "{e}");
1128 let mut bare = target();
1130 bare.forbidden_args.clear();
1131 bare.check_launch(&words, &opts).unwrap();
1132 }
1133
1134 #[test]
1135 fn the_probe_label_alone_never_opens_a_launch() {
1136 let t = target().allow_no_data_dir_for_refusal_probe();
1137 assert!(t.allow_no_data_dir_for_refusal_probe);
1138 let e = refusal(t.check_launch(&["doctor".to_string()], &CliOptions::default()));
1140 assert!(e.contains(&Rule::ProbeUnavailable.to_string()), "{e}");
1141 }
1142}