use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
use super::AntigravityError;
use super::handshake::{InputConfig, OutputConfig};
use super::session::WireContext;
use crate::wire::WireEvent;
pub(crate) const HARNESS_PATH_ENV: &str = "ANTIGRAVITY_HARNESS_PATH";
const SITE_PACKAGES_SUFFIX: &str = "google/antigravity/bin/localharness";
const STDERR_RING_CAPACITY: usize = 200;
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_HANDSHAKE_FRAME_LEN: usize = 4 * 1024 * 1024;
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
const KILL_GRACE: Duration = Duration::from_secs(1);
pub(crate) fn discover_harness(explicit: Option<&Path>) -> Result<PathBuf, AntigravityError> {
let env_path = std::env::var_os(HARNESS_PATH_ENV).map(PathBuf::from);
let site_dirs = python_site_dirs();
let path_var = std::env::var_os("PATH");
discover_in(
explicit,
env_path.as_deref(),
&site_dirs,
path_var.as_deref(),
)
}
fn discover_in(
explicit: Option<&Path>,
env_path: Option<&Path>,
site_dirs: &[PathBuf],
path_var: Option<&std::ffi::OsStr>,
) -> Result<PathBuf, AntigravityError> {
let mut searched = Vec::new();
if let Some(path) = explicit {
if path.is_file() {
return Ok(path.to_path_buf());
}
searched.push(format!("explicit path {}", path.display()));
}
if let Some(path) = env_path {
if path.is_file() {
return Ok(path.to_path_buf());
}
searched.push(format!("{HARNESS_PATH_ENV}={}", path.display()));
} else if explicit.is_none() {
searched.push(format!("{HARNESS_PATH_ENV} (unset)"));
}
for dir in site_dirs {
let candidate = dir.join(SITE_PACKAGES_SUFFIX);
if candidate.is_file() {
return Ok(candidate);
}
searched.push(candidate.display().to_string());
}
if site_dirs.is_empty() {
searched.push("python3 site-packages (python3 not found or no site dirs)".to_string());
}
if let Some(path_var) = path_var {
for dir in std::env::split_paths(path_var) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join("localharness");
if candidate.is_file() {
return Ok(candidate);
}
}
searched.push("localharness on PATH".to_string());
}
Err(AntigravityError::HarnessNotFound { searched })
}
fn python_site_dirs() -> Vec<PathBuf> {
let output = std::process::Command::new("python3")
.arg("-c")
.arg(
"import site, sys\n\
paths = list(getattr(site, 'getsitepackages', lambda: [])())\n\
usersite = getattr(site, 'getusersitepackages', lambda: None)()\n\
if usersite: paths.append(usersite)\n\
print('\\n'.join(paths))",
)
.output();
match output {
Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(PathBuf::from)
.collect(),
_ => Vec::new(),
}
}
pub(crate) struct HarnessProcess {
child: Child,
stdin: Option<ChildStdin>,
stderr_lines: Arc<Mutex<VecDeque<String>>>,
}
impl std::fmt::Debug for HarnessProcess {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HarnessProcess")
.field("pid", &self.child.id())
.finish_non_exhaustive()
}
}
async fn read_handshake_frame<R>(reader: &mut R) -> std::io::Result<Vec<u8>>
where
R: tokio::io::AsyncRead + Unpin,
{
let mut len_bytes = [0u8; 4];
reader.read_exact(&mut len_bytes).await?;
let len = u32::from_le_bytes(len_bytes) as usize;
if len > MAX_HANDSHAKE_FRAME_LEN {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"handshake frame declares {len} bytes, exceeding the \
{MAX_HANDSHAKE_FRAME_LEN}-byte cap; the binary is likely not a localharness"
),
));
}
let mut payload = vec![0u8; len];
reader.read_exact(&mut payload).await?;
Ok(payload)
}
async fn drain_stderr<R>(stderr: R, ring: Arc<Mutex<VecDeque<String>>>, wire: WireContext)
where
R: tokio::io::AsyncRead + Unpin,
{
let mut reader = BufReader::new(stderr);
let mut buf = Vec::new();
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf).await {
Ok(0) => return, Ok(_) => {
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
}
let line = String::from_utf8_lossy(&buf).into_owned();
tracing::debug!("harness stderr: {line}");
wire.emit(|| WireEvent::HarnessStderr {
id: wire.id(),
line: line.clone(),
});
let mut ring = ring.lock().expect("stderr ring lock");
if ring.len() == STDERR_RING_CAPACITY {
ring.pop_front();
}
ring.push_back(line);
}
Err(e) => {
tracing::debug!("harness stderr drain ended on read error: {e}");
return;
}
}
}
}
impl HarnessProcess {
pub(crate) async fn spawn(
binary: &Path,
input_config: &InputConfig,
wire: &WireContext,
) -> Result<(Self, OutputConfig), AntigravityError> {
let mut child = Command::new(binary)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| AntigravityError::HandshakeFailed {
message: format!("failed to spawn harness at {}: {e}", binary.display()),
stderr: String::new(),
})?;
wire.emit(|| WireEvent::HarnessSpawn {
id: wire.id(),
path: binary.display().to_string(),
pid: child.id(),
});
let mut stdin = child.stdin.take().expect("stdin was piped");
let mut stdout = child.stdout.take().expect("stdout was piped");
let stderr = child.stderr.take().expect("stderr was piped");
let stderr_lines = Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_RING_CAPACITY)));
tokio::spawn(drain_stderr(
stderr,
Arc::clone(&stderr_lines),
wire.clone(),
));
let mut process = Self {
child,
stdin: None,
stderr_lines,
};
let handshake = async {
stdin.write_all(&input_config.encode_frame()).await?;
stdin.flush().await?;
read_handshake_frame(&mut stdout).await
};
let payload = match tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake).await {
Ok(Ok(payload)) => payload,
Ok(Err(e)) => {
let stderr = process.stderr_tail().await;
let _ = process.child.start_kill();
return Err(AntigravityError::HandshakeFailed {
message: format!("stdio handshake I/O failed: {e}"),
stderr,
});
}
Err(_) => {
let stderr = process.stderr_tail().await;
let _ = process.child.start_kill();
return Err(AntigravityError::HandshakeFailed {
message: format!("no handshake reply within {HANDSHAKE_TIMEOUT:?}"),
stderr,
});
}
};
let output_config = match OutputConfig::decode(&payload) {
Ok(config) => config,
Err(e) => {
let stderr = process.stderr_tail().await;
let _ = process.child.start_kill();
return Err(AntigravityError::HandshakeFailed {
message: format!("invalid OutputConfig: {e}"),
stderr,
});
}
};
process.stdin = Some(stdin);
Ok((process, output_config))
}
pub(crate) async fn stderr_tail(&self) -> String {
tokio::time::sleep(Duration::from_millis(50)).await;
let ring = self.stderr_lines.lock().expect("stderr ring lock");
ring.iter().cloned().collect::<Vec<_>>().join("\n")
}
pub(crate) async fn kill(&mut self) {
let _ = self.child.start_kill();
let _ = self.child.wait().await;
}
pub(crate) async fn shutdown(mut self) -> Result<(), AntigravityError> {
drop(self.stdin.take());
if tokio::time::timeout(SHUTDOWN_GRACE, self.child.wait())
.await
.is_ok()
{
return Ok(());
}
tracing::warn!("Harness did not exit after stdin EOF; sending SIGTERM.");
self.terminate();
if tokio::time::timeout(KILL_GRACE, self.child.wait())
.await
.is_ok()
{
return Ok(());
}
tracing::warn!("Harness ignored SIGTERM; sending SIGKILL.");
let _ = self.child.start_kill();
let _ = tokio::time::timeout(KILL_GRACE, self.child.wait()).await;
Ok(())
}
#[cfg(unix)]
fn terminate(&self) {
if let Some(pid) = self.child.id() {
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
}
}
#[cfg(not(unix))]
fn terminate(&self) {
}
}
#[cfg(test)]
mod tests {
use super::*;
fn touch_executable(path: &Path) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, b"#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[test]
fn test_discovery_prefers_explicit_path() {
let dir = tempfile::tempdir().unwrap();
let explicit = dir.path().join("explicit/localharness");
let env = dir.path().join("env/localharness");
touch_executable(&explicit);
touch_executable(&env);
let found = discover_in(Some(&explicit), Some(&env), &[], None).unwrap();
assert_eq!(found, explicit);
}
#[test]
fn test_discovery_env_var_beats_site_packages() {
let dir = tempfile::tempdir().unwrap();
let env = dir.path().join("env/localharness");
let site = dir.path().join("site");
touch_executable(&env);
touch_executable(&site.join(SITE_PACKAGES_SUFFIX));
let found = discover_in(None, Some(&env), std::slice::from_ref(&site), None).unwrap();
assert_eq!(found, env);
}
#[test]
fn test_discovery_site_packages_beats_path() {
let dir = tempfile::tempdir().unwrap();
let site = dir.path().join("site");
let path_dir = dir.path().join("bin");
touch_executable(&site.join(SITE_PACKAGES_SUFFIX));
touch_executable(&path_dir.join("localharness"));
let found = discover_in(
None,
None,
std::slice::from_ref(&site),
Some(path_dir.as_os_str()),
)
.unwrap();
assert_eq!(found, site.join(SITE_PACKAGES_SUFFIX));
}
#[test]
fn test_discovery_falls_back_to_path() {
let dir = tempfile::tempdir().unwrap();
let path_dir = dir.path().join("bin");
touch_executable(&path_dir.join("localharness"));
let found = discover_in(None, None, &[], Some(path_dir.as_os_str())).unwrap();
assert_eq!(found, path_dir.join("localharness"));
}
#[test]
fn test_discovery_missing_explicit_falls_through() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope/localharness");
let path_dir = dir.path().join("bin");
touch_executable(&path_dir.join("localharness"));
let found = discover_in(Some(&missing), None, &[], Some(path_dir.as_os_str())).unwrap();
assert_eq!(found, path_dir.join("localharness"));
}
#[tokio::test]
async fn test_handshake_frame_rejects_oversized_length_before_allocating() {
let data = u32::MAX.to_le_bytes();
let err = read_handshake_frame(&mut &data[..]).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("exceeding"), "got: {err}");
}
#[tokio::test]
async fn test_handshake_frame_rejects_just_over_cap() {
let data = ((MAX_HANDSHAKE_FRAME_LEN as u32) + 1).to_le_bytes();
let err = read_handshake_frame(&mut &data[..]).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn test_handshake_frame_reads_valid_frame() {
let mut data = 5u32.to_le_bytes().to_vec();
data.extend_from_slice(b"hello");
let payload = read_handshake_frame(&mut &data[..]).await.unwrap();
assert_eq!(payload, b"hello");
}
#[tokio::test]
async fn test_handshake_frame_truncated_payload_is_io_error() {
let mut data = 10u32.to_le_bytes().to_vec();
data.extend_from_slice(b"short");
let err = read_handshake_frame(&mut &data[..]).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
#[tokio::test]
async fn test_stderr_drain_survives_invalid_utf8() {
let data: &[u8] = b"first line\n\xff\xfe broken\nafter\r\nlast without newline";
let ring = Arc::new(Mutex::new(VecDeque::new()));
drain_stderr(data, Arc::clone(&ring), WireContext::new(Vec::new())).await;
let lines: Vec<String> = ring.lock().unwrap().iter().cloned().collect();
assert_eq!(lines.len(), 4, "got: {lines:?}");
assert_eq!(lines[0], "first line");
assert!(
lines[1].contains('\u{FFFD}') && lines[1].ends_with(" broken"),
"invalid bytes must be replaced lossily, got: {:?}",
lines[1]
);
assert_eq!(lines[2], "after", "CRLF must be stripped");
assert_eq!(lines[3], "last without newline");
}
#[tokio::test]
async fn test_stderr_drain_ring_is_bounded() {
let mut data = Vec::new();
for i in 0..(STDERR_RING_CAPACITY + 10) {
data.extend_from_slice(format!("line {i}\n").as_bytes());
}
let ring = Arc::new(Mutex::new(VecDeque::new()));
drain_stderr(&data[..], Arc::clone(&ring), WireContext::new(Vec::new())).await;
let ring = ring.lock().unwrap();
assert_eq!(ring.len(), STDERR_RING_CAPACITY);
assert_eq!(
ring.back().unwrap(),
&format!("line {}", STDERR_RING_CAPACITY + 9)
);
}
#[test]
fn test_discovery_error_lists_searched_locations() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope/localharness");
let site = dir.path().join("site");
std::fs::create_dir_all(&site).unwrap();
let err = discover_in(
Some(&missing),
None,
std::slice::from_ref(&site),
Some(std::ffi::OsStr::new("/definitely/not/a/dir")),
)
.unwrap_err();
let AntigravityError::HarnessNotFound { searched } = &err else {
panic!("expected HarnessNotFound, got {err:?}");
};
assert!(searched.iter().any(|s| s.contains("explicit path")));
assert!(searched.iter().any(|s| s.contains(SITE_PACKAGES_SUFFIX)));
assert!(searched.iter().any(|s| s.contains("PATH")));
let message = err.to_string();
assert!(message.contains("pip install google-antigravity"));
assert!(message.contains(HARNESS_PATH_ENV));
}
}