use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use std::time::Instant;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::Command;
use crate::error::Error;
use crate::error::Result;
use crate::http::DEFAULT_USERNAME;
const LISTENING_MARKER: &str = "listening on ";
const KILL_GRACE: Duration = Duration::from_millis(250);
#[cfg(unix)]
const SIGTERM: i32 = 15;
#[cfg(unix)]
const SIGKILL: i32 = 9;
#[cfg(unix)]
#[allow(unsafe_code)] fn signal_process_group(pgid: i32, sig: i32) {
extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
unsafe {
kill(-pgid, sig);
}
}
#[derive(Debug, Clone)]
pub struct ManagedServerBuilder {
binary: PathBuf,
hostname: String,
port: Option<u16>,
working_dir: Option<PathBuf>,
password: Option<String>,
envs: HashMap<String, String>,
startup_timeout: Duration,
}
impl Default for ManagedServerBuilder {
fn default() -> Self {
Self {
binary: PathBuf::from("opencode"),
hostname: "127.0.0.1".to_string(),
port: None,
working_dir: None,
password: None,
envs: HashMap::new(),
startup_timeout: Duration::from_secs(10),
}
}
}
impl ManagedServerBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn binary(mut self, binary: impl Into<PathBuf>) -> Self {
self.binary = binary.into();
self
}
#[must_use]
pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
self.hostname = hostname.into();
self
}
#[must_use]
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
#[must_use]
pub fn working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.working_dir = Some(dir.into());
self
}
#[must_use]
pub fn password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
#[must_use]
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.envs.insert(key.into(), value.into());
self
}
#[must_use]
pub fn startup_timeout(mut self, timeout: Duration) -> Self {
self.startup_timeout = timeout;
self
}
pub async fn spawn(self) -> Result<ManagedServer> {
ManagedServer::spawn(self).await
}
}
#[derive(Debug)]
pub struct ManagedServer {
child: Option<Child>,
base_url: String,
port: u16,
password: Option<String>,
pgid: Option<i32>,
}
impl ManagedServer {
#[must_use]
pub fn builder() -> ManagedServerBuilder {
ManagedServerBuilder::new()
}
pub async fn spawn(builder: ManagedServerBuilder) -> Result<Self> {
let port = match builder.port {
Some(p) => p,
None => portpicker::pick_unused_port()
.ok_or_else(|| Error::Server("no free TCP port available".to_string()))?,
};
let mut cmd = Command::new(&builder.binary);
cmd.arg("serve")
.arg("--hostname")
.arg(&builder.hostname)
.arg("--port")
.arg(port.to_string())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true);
#[cfg(unix)]
cmd.process_group(0);
if let Some(dir) = &builder.working_dir {
cmd.current_dir(dir);
}
for (key, value) in &builder.envs {
cmd.env(key, value);
}
if let Some(password) = &builder.password {
cmd.env("OPENCODE_SERVER_PASSWORD", password);
}
let mut child = cmd.spawn().map_err(|e| {
Error::Server(format!(
"failed to spawn `{}`: {e}",
builder.binary.display()
))
})?;
let pgid = child.id().map(|id| id as i32);
let stdout = child.stdout.take().ok_or_else(|| {
Error::Server("managed server child produced no stdout handle".to_string())
})?;
let deadline = Instant::now() + builder.startup_timeout;
let result = Self::await_listening(
stdout,
&builder.hostname,
port,
deadline,
builder.startup_timeout,
)
.await;
let (base_url, reader) = match result {
Ok(pair) => pair,
Err(e) => {
signal_group_and_kill(pgid, &mut child);
let _ = child.wait().await;
return Err(e);
}
};
tokio::spawn(async move {
let mut reader = reader;
while let Ok(Some(_)) = reader.next_line().await {}
});
let server = Self {
child: Some(child),
base_url,
port,
password: builder.password,
pgid,
};
server.await_healthy(deadline).await?;
Ok(server)
}
async fn await_listening(
stdout: tokio::process::ChildStdout,
hostname: &str,
port: u16,
deadline: Instant,
startup_timeout: Duration,
) -> Result<(
String,
tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
)> {
let mut reader = BufReader::new(stdout).lines();
loop {
let now = Instant::now();
if now >= deadline {
return Err(Error::Server(format!(
"opencode serve did not report a listening address within {startup_timeout:?}"
)));
}
let slice = (deadline - now).min(Duration::from_millis(200));
match tokio::time::timeout(slice, reader.next_line()).await {
Ok(Ok(Some(line))) => {
if let Some(url) = parse_listening_url(&line, hostname, port) {
return Ok((url, reader));
}
}
Ok(Ok(None)) => {
return Err(Error::Server(
"opencode serve exited before reporting a listening address".to_string(),
));
}
Ok(Err(e)) => {
return Err(Error::Server(format!(
"error reading opencode serve stdout: {e}"
)));
}
Err(_) => {}
}
}
}
async fn await_healthy(&self, deadline: Instant) -> Result<()> {
let client = reqwest::Client::new();
let health_url = format!("{}/global/health", self.base_url);
loop {
let request = {
let req = client.get(&health_url).timeout(Duration::from_millis(500));
match &self.password {
Some(pw) => req.basic_auth(DEFAULT_USERNAME, Some(pw)),
None => req,
}
};
if let Ok(resp) = request.send().await {
if resp.status().is_success() {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(Error::Server(format!(
"opencode serve at {} never became healthy within the startup timeout",
self.base_url
)));
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[must_use]
pub fn url(&self) -> &str {
&self.base_url
}
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
pub fn is_running(&mut self) -> bool {
self.child
.as_mut()
.is_some_and(|child| matches!(child.try_wait(), Ok(None)))
}
pub async fn stop(mut self) -> Result<()> {
#[cfg(unix)]
if let Some(pgid) = self.pgid.take() {
signal_process_group(pgid, SIGTERM);
tokio::time::sleep(KILL_GRACE).await;
signal_process_group(pgid, SIGKILL);
}
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
child
.wait()
.await
.map_err(|e| Error::Server(format!("awaiting managed server exit failed: {e}")))?;
}
Ok(())
}
}
impl Drop for ManagedServer {
fn drop(&mut self) {
let child = self.child.take();
#[cfg(unix)]
if let Some(pgid) = self.pgid.take() {
signal_process_group(pgid, SIGTERM);
std::thread::spawn(move || {
let _child = child;
std::thread::sleep(KILL_GRACE);
signal_process_group(pgid, SIGKILL);
});
return;
}
if let Some(mut child) = child {
let _ = child.start_kill();
}
}
}
fn signal_group_and_kill(pgid: Option<i32>, child: &mut Child) {
#[cfg(unix)]
if let Some(pgid) = pgid {
signal_process_group(pgid, SIGTERM);
signal_process_group(pgid, SIGKILL);
}
#[cfg(not(unix))]
let _ = pgid;
let _ = child.start_kill();
}
fn parse_listening_url(line: &str, hostname: &str, port: u16) -> Option<String> {
let idx = line.find(LISTENING_MARKER)?;
let rest = line[idx + LISTENING_MARKER.len()..].trim();
if let Some(start) = rest.find("http") {
let url = rest[start..].trim().trim_end_matches('/');
if !url.is_empty() {
return Some(url.to_string());
}
}
Some(format!("http://{hostname}:{port}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_defaults() {
let b = ManagedServerBuilder::new();
assert_eq!(b.binary, PathBuf::from("opencode"));
assert_eq!(b.hostname, "127.0.0.1");
assert!(b.port.is_none());
assert!(b.working_dir.is_none());
assert!(b.password.is_none());
assert!(b.envs.is_empty());
assert_eq!(b.startup_timeout, Duration::from_secs(10));
}
#[test]
fn builder_setters() {
let b = ManagedServer::builder()
.binary("/opt/opencode")
.hostname("0.0.0.0")
.port(1234)
.working_dir("/tmp/work")
.password("hunter2")
.env("FOO", "bar")
.env("BAZ", "qux")
.startup_timeout(Duration::from_secs(3));
assert_eq!(b.binary, PathBuf::from("/opt/opencode"));
assert_eq!(b.hostname, "0.0.0.0");
assert_eq!(b.port, Some(1234));
assert_eq!(b.working_dir, Some(PathBuf::from("/tmp/work")));
assert_eq!(b.password.as_deref(), Some("hunter2"));
assert_eq!(b.envs.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(b.envs.get("BAZ").map(String::as_str), Some("qux"));
assert_eq!(b.startup_timeout, Duration::from_secs(3));
}
#[test]
fn parse_listening_url_extracts_printed_url() {
let line = "opencode server listening on http://127.0.0.1:52439";
assert_eq!(
parse_listening_url(line, "127.0.0.1", 4096).as_deref(),
Some("http://127.0.0.1:52439")
);
}
#[test]
fn parse_listening_url_strips_trailing_slash() {
let line = "opencode server listening on http://127.0.0.1:52439/";
assert_eq!(
parse_listening_url(line, "127.0.0.1", 4096).as_deref(),
Some("http://127.0.0.1:52439")
);
}
#[test]
fn parse_listening_url_falls_back_when_no_url() {
let line = "opencode server listening on <unknown>";
assert_eq!(
parse_listening_url(line, "localhost", 9000).as_deref(),
Some("http://localhost:9000")
);
}
#[test]
fn parse_listening_url_rejects_unrelated_lines() {
assert!(parse_listening_url("Warning: password not set", "127.0.0.1", 4096).is_none());
}
#[cfg(feature = "integration-tests")]
#[tokio::test]
async fn managed_server_reaches_health() {
let binary = std::env::var("OPENCODE_BIN").unwrap_or_else(|_| {
let home = std::env::var("HOME").expect("HOME must be set");
format!("{home}/.local/opencode-npm/node_modules/.bin/opencode")
});
let mut server = ManagedServer::builder()
.binary(binary)
.startup_timeout(Duration::from_secs(30))
.spawn()
.await
.expect("managed server should start");
assert!(server.is_running());
assert!(server.url().starts_with("http://127.0.0.1:"));
let health = format!("{}/global/health", server.url());
let body: serde_json::Value = reqwest::Client::new()
.get(&health)
.send()
.await
.expect("health request should succeed")
.json()
.await
.expect("health body should be JSON");
assert_eq!(body["healthy"], serde_json::Value::Bool(true));
server.stop().await.expect("server should stop cleanly");
}
}