Skip to main content

shuyi_cli/mcp/
child.rs

1//! The game's tool program as the server's host: `cargo run` of the
2//! program `shuyi.ron` names, as `shuyi mcp-host`, with pipes to its
3//! stdin and stdout. What it writes to stderr (the compiler's errors, a
4//! panic) goes on to the server's stderr, and its last lines go into the
5//! error when it stops before it answers.
6
7use std::fmt::Write as _;
8use std::io::{BufRead, BufReader, Read, Write};
9use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
10use std::sync::mpsc::{Receiver, channel};
11use std::sync::{Arc, Mutex};
12use std::time::Duration;
13
14use super::host::{REPLY, Reply, Request};
15use super::server::{Launch, Link};
16use crate::contract::codes::{self, Code};
17use crate::project::{FILE, Project};
18
19/// How much of the program's last output an error keeps, in bytes.
20const TAIL: usize = 6000;
21
22/// Launches the game's tool program with cargo (`$CARGO`, or `cargo`).
23#[derive(Debug, Clone, Copy, Default)]
24pub struct Cargo;
25
26impl<'a> Launch<'a> for Cargo {
27    fn launch(&self, project: &Project) -> Result<Box<dyn Link + 'a>, (Code, String)> {
28        let Some(program) = &project.config.game else {
29            return Err((
30                codes::PROJECT_FILE,
31                format!("{FILE} names no game tool program (`game`), and the tool runs the game"),
32            ));
33        };
34        let cargo = crate::cargo();
35        let mut child = Command::new(cargo)
36            .args(program.cargo_args("mcp-host", &[]))
37            .current_dir(&project.root)
38            .stdin(Stdio::piped())
39            .stdout(Stdio::piped())
40            .stderr(Stdio::piped())
41            .spawn()
42            .map_err(|e| (codes::TOOLCHAIN, format!("cannot run cargo: {e}")))?;
43        let (Some(stdin), Some(stdout), Some(stderr)) =
44            (child.stdin.take(), child.stdout.take(), child.stderr.take())
45        else {
46            let _ = child.kill();
47            return Err((
48                codes::TOOLCHAIN,
49                "cargo started without its pipes".to_owned(),
50            ));
51        };
52        let tail = Arc::new(Mutex::new(Vec::new()));
53        let kept = Arc::clone(&tail);
54        let (ended, copied) = channel();
55        std::thread::spawn(move || {
56            keep_tail(stderr, &kept);
57            let _ = ended.send(());
58        });
59        Ok(Box::new(Program {
60            child,
61            stdin: Some(stdin),
62            stdout: BufReader::new(stdout),
63            tail,
64            copied,
65        }))
66    }
67}
68
69/// Copies `from` to this process's stderr, keeping its last [`TAIL`]
70/// bytes in `tail`.
71pub(super) fn keep_tail(mut from: impl Read, tail: &Mutex<Vec<u8>>) {
72    let mut buf = [0u8; 4096];
73    while let Ok(n) = from.read(&mut buf) {
74        if n == 0 {
75            break;
76        }
77        let _ = std::io::stderr().write_all(&buf[..n]);
78        if let Ok(mut tail) = tail.lock() {
79            tail.extend_from_slice(&buf[..n]);
80            let over = tail.len().saturating_sub(TAIL);
81            tail.drain(..over);
82        }
83    }
84}
85
86/// A running game tool program.
87struct Program {
88    child: Child,
89    stdin: Option<ChildStdin>,
90    stdout: BufReader<ChildStdout>,
91    tail: Arc<Mutex<Vec<u8>>>,
92    /// Hears when its stderr has been copied to the end.
93    copied: Receiver<()>,
94}
95
96impl Program {
97    /// Why it answered nothing: it stopped, with its exit code and the
98    /// last of its output.
99    fn stopped(&mut self) -> String {
100        self.stdin = None;
101        let status = self.child.wait().ok().and_then(|status| status.code());
102        // Its stderr closes as it ends: let the copy catch up, briefly.
103        let _ = self.copied.recv_timeout(Duration::from_secs(1));
104        let tail = self
105            .tail
106            .lock()
107            .map(|tail| String::from_utf8_lossy(&tail).trim().to_owned())
108            .unwrap_or_default();
109        let mut out = "the game's tool program stopped before it answered".to_owned();
110        if let Some(code) = status {
111            let _ = write!(out, " (exit code {code})");
112        }
113        if !tail.is_empty() {
114            out.push_str(": ");
115            out.push_str(&tail);
116        }
117        out
118    }
119}
120
121impl Link for Program {
122    fn call(&mut self, request: &Request) -> Result<Reply, String> {
123        let line = serde_json::to_string(request).map_err(|e| e.to_string())?;
124        let sent = self
125            .stdin
126            .as_mut()
127            .map(|stdin| writeln!(stdin, "{line}").and_then(|()| stdin.flush()));
128        if !matches!(sent, Some(Ok(()))) {
129            return Err(self.stopped());
130        }
131        let mut line = String::new();
132        loop {
133            line.clear();
134            match self.stdout.read_line(&mut line) {
135                Ok(0) | Err(_) => return Err(self.stopped()),
136                Ok(_) => {}
137            }
138            // A line without the mark is the game's own printing.
139            if let Some(reply) = line.trim_end().strip_prefix(REPLY) {
140                return serde_json::from_str(reply)
141                    .map_err(|e| format!("the game's tool program sent a broken reply ({e})"));
142            }
143        }
144    }
145}
146
147impl Drop for Program {
148    /// Closes its stdin and ends it: nothing it would still say is
149    /// wanted.
150    fn drop(&mut self) {
151        self.stdin = None;
152        let _ = self.child.kill();
153        let _ = self.child.wait();
154    }
155}
156
157#[cfg(test)]
158mod tests;