Skip to main content

monoloop_testkit/
grok_serve.rs

1//! Owned local `grok agent serve` process for live test-kit runs.
2//!
3//! **Test kit only.** Product crates never spawn host agent processes.
4//! The handle owns the child: ready-wait is bounded, drop/stop always kill.
5
6use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::time::Duration;
10use tokio::net::TcpStream;
11use tokio::process::{Child, Command};
12use tokio::time::{sleep, timeout};
13
14/// How to launch a local Grok Build agent server.
15#[derive(Clone, Debug)]
16pub struct GrokServeOptions {
17    /// Loopback bind port. When `None`, an ephemeral free port is chosen.
18    pub port: Option<u16>,
19    /// WebSocket server secret (also used as `server-key` query param).
20    pub secret: String,
21    /// Binary name or path (`grok` on PATH by default).
22    pub grok_bin: PathBuf,
23    /// Max time to wait for the listener after spawn.
24    pub ready_timeout: Duration,
25    /// Optional log file for child stdout/stderr (parent dirs created).
26    pub log_path: Option<PathBuf>,
27}
28
29impl Default for GrokServeOptions {
30    fn default() -> Self {
31        Self {
32            port: Some(2419),
33            secret: "monoloop-live-test".into(),
34            grok_bin: PathBuf::from("grok"),
35            ready_timeout: Duration::from_secs(15),
36            log_path: None,
37        }
38    }
39}
40
41/// Owned Grok serve child process.
42///
43/// Dropping the handle (or calling [`ManagedGrokServe::stop`]) terminates the
44/// child. No fire-and-forget: the driver always owns cleanup.
45#[derive(Debug)]
46pub struct ManagedGrokServe {
47    child: Child,
48    port: u16,
49    secret: String,
50    log_path: Option<PathBuf>,
51}
52
53impl ManagedGrokServe {
54    /// Spawn `grok agent --always-approve serve` and wait until the port listens.
55    pub async fn start(opts: GrokServeOptions) -> Result<Self, String> {
56        let port = match opts.port {
57            Some(p) => p,
58            None => free_loopback_port()?,
59        };
60        if port_is_listening(port).await {
61            return Err(format!(
62                "port {port} already in use — stop the other listener or choose another port"
63            ));
64        }
65
66        if let Some(ref log) = opts.log_path {
67            if let Some(parent) = log.parent() {
68                if !parent.as_os_str().is_empty() {
69                    std::fs::create_dir_all(parent)
70                        .map_err(|e| format!("create log dir {}: {e}", parent.display()))?;
71                }
72            }
73        }
74
75        let bind = format!("127.0.0.1:{port}");
76        let mut cmd = Command::new(&opts.grok_bin);
77        cmd.arg("agent")
78            .arg("--always-approve")
79            .arg("serve")
80            .arg("--bind")
81            .arg(&bind)
82            .arg("--secret")
83            .arg(&opts.secret)
84            .kill_on_drop(true)
85            .stdin(Stdio::null());
86
87        if let Some(ref log) = opts.log_path {
88            let f = std::fs::File::create(log)
89                .map_err(|e| format!("open log {}: {e}", log.display()))?;
90            let f2 = f
91                .try_clone()
92                .map_err(|e| format!("clone log handle: {e}"))?;
93            cmd.stdout(Stdio::from(f)).stderr(Stdio::from(f2));
94        } else {
95            cmd.stdout(Stdio::null()).stderr(Stdio::null());
96        }
97
98        let child = cmd.spawn().map_err(|e| {
99            format!(
100                "failed to spawn `{} agent serve`: {e} (is grok on PATH?)",
101                opts.grok_bin.display()
102            )
103        })?;
104
105        let serve = Self {
106            child,
107            port,
108            secret: opts.secret,
109            log_path: opts.log_path,
110        };
111
112        match timeout(opts.ready_timeout, wait_until_listening(port)).await {
113            Ok(Ok(())) => Ok(serve),
114            Ok(Err(e)) => {
115                let _ = serve.stop().await;
116                Err(e)
117            }
118            Err(_) => {
119                let _ = serve.stop().await;
120                Err(format!(
121                    "grok serve not listening on 127.0.0.1:{port} within {:?}",
122                    opts.ready_timeout
123                ))
124            }
125        }
126    }
127
128    /// Loopback port the server bound.
129    pub fn port(&self) -> u16 {
130        self.port
131    }
132
133    /// Server secret.
134    pub fn secret(&self) -> &str {
135        &self.secret
136    }
137
138    /// Optional path to the child log file.
139    pub fn log_path(&self) -> Option<&Path> {
140        self.log_path.as_deref()
141    }
142
143    /// OS pid of the child, when available.
144    pub fn pid(&self) -> Option<u32> {
145        self.child.id()
146    }
147
148    /// Gracefully kill and wait for the child (idempotent).
149    pub async fn stop(mut self) -> Result<(), String> {
150        self.kill_inner().await
151    }
152
153    async fn kill_inner(&mut self) -> Result<(), String> {
154        // Try polite kill first; escalate if needed.
155        let _ = self.child.start_kill();
156        match timeout(Duration::from_secs(3), self.child.wait()).await {
157            Ok(Ok(status)) => {
158                if !status.success() {
159                    // Non-zero exit is fine on forced stop.
160                }
161                Ok(())
162            }
163            Ok(Err(e)) => Err(format!("wait for grok serve child: {e}")),
164            Err(_) => {
165                let _ = self.child.start_kill();
166                let _ = timeout(Duration::from_secs(2), self.child.wait()).await;
167                Ok(())
168            }
169        }
170    }
171}
172
173impl Drop for ManagedGrokServe {
174    fn drop(&mut self) {
175        // Best-effort sync kill if caller forgot stop().
176        let _ = self.child.start_kill();
177    }
178}
179
180async fn wait_until_listening(port: u16) -> Result<(), String> {
181    loop {
182        if port_is_listening(port).await {
183            return Ok(());
184        }
185        sleep(Duration::from_millis(50)).await;
186    }
187}
188
189async fn port_is_listening(port: u16) -> bool {
190    let addr = SocketAddr::from(([127, 0, 0, 1], port));
191    timeout(Duration::from_millis(100), TcpStream::connect(addr))
192        .await
193        .map(|r| r.is_ok())
194        .unwrap_or(false)
195}
196
197fn free_loopback_port() -> Result<u16, String> {
198    let listener = std::net::TcpListener::bind("127.0.0.1:0")
199        .map_err(|e| format!("bind ephemeral port: {e}"))?;
200    let port = listener
201        .local_addr()
202        .map_err(|e| format!("local_addr: {e}"))?
203        .port();
204    drop(listener);
205    Ok(port)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn default_options_use_loopback_secret() {
214        let o = GrokServeOptions::default();
215        assert_eq!(o.port, Some(2419));
216        assert_eq!(o.secret, "monoloop-live-test");
217    }
218
219    #[test]
220    fn free_port_is_nonzero() {
221        let p = free_loopback_port().expect("port");
222        assert!(p > 0);
223    }
224}