use std::time::Duration;
use serde_json::Value;
use tokio::io::{AsyncWriteExt, BufReader, Lines};
use crate::app_server::AppServerError;
#[derive(Debug, thiserror::Error)]
pub enum AppServerTransportError {
#[error("{context}: {source}")]
Io {
context: String,
#[source]
source: std::io::Error,
},
#[error("App-server terminated before sending expected response")]
ProcessTerminated,
#[error(
"Timed out waiting for app-server response `{response_id}` after {timeout_seconds} seconds"
)]
Timeout {
response_id: String,
timeout_seconds: u64,
},
}
pub const STARTUP_TIMEOUT: Duration = Duration::from_mins(5);
pub const TURN_TIMEOUT: Duration = Duration::from_hours(4);
pub async fn write_json_line(
stdin: &mut tokio::process::ChildStdin,
payload: &Value,
) -> Result<(), AppServerTransportError> {
let serialized_payload = payload.to_string();
stdin
.write_all(serialized_payload.as_bytes())
.await
.map_err(|source| AppServerTransportError::Io {
context: "Failed writing to app-server stdin".to_string(),
source,
})?;
stdin
.write_all(b"\n")
.await
.map_err(|source| AppServerTransportError::Io {
context: "Failed writing newline to app-server stdin".to_string(),
source,
})?;
stdin
.flush()
.await
.map_err(|source| AppServerTransportError::Io {
context: "Failed flushing app-server stdin".to_string(),
source,
})
}
pub async fn wait_for_response_line<R>(
stdout_lines: &mut Lines<BufReader<R>>,
response_id: &str,
) -> Result<String, AppServerTransportError>
where
R: tokio::io::AsyncRead + Unpin,
{
tokio::time::timeout(STARTUP_TIMEOUT, async {
loop {
let stdout_line = stdout_lines
.next_line()
.await
.map_err(|source| AppServerTransportError::Io {
context: "Failed reading app-server stdout".to_string(),
source,
})?
.ok_or(AppServerTransportError::ProcessTerminated)?;
let Ok(response_value) = serde_json::from_str::<Value>(&stdout_line) else {
continue;
};
if response_id_matches(&response_value, response_id) {
return Ok(stdout_line);
}
}
})
.await
.map_err(|_| AppServerTransportError::Timeout {
response_id: response_id.to_string(),
timeout_seconds: STARTUP_TIMEOUT.as_secs(),
})?
}
pub fn response_id_matches(response_value: &Value, response_id: &str) -> bool {
response_value
.get("id")
.and_then(Value::as_str)
.is_some_and(|line_id| line_id == response_id)
}
pub fn extract_json_error_message(response_value: &Value) -> Option<String> {
response_value
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str)
.map(ToString::to_string)
}
pub async fn shutdown_child(child: &mut tokio::process::Child) {
drop(child.stdin.take());
if tokio::time::timeout(Duration::from_secs(1), child.wait())
.await
.is_err()
{
let _ = child.kill().await;
let _ = child.wait().await;
}
}
pub fn spawn_runtime_command(
command: std::process::Command,
runtime_name: &str,
) -> Result<
(
tokio::process::Child,
tokio::process::ChildStdin,
tokio::process::ChildStdout,
),
AppServerError,
> {
let mut command = tokio::process::Command::from(command);
command
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
let mut child = command.spawn().map_err(|error| {
AppServerError::Provider(format!("Failed to spawn `{runtime_name}`: {error}"))
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| AppServerError::Provider(format!("{runtime_name} stdin is unavailable")))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| AppServerError::Provider(format!("{runtime_name} stdout is unavailable")))?;
Ok((child, stdin, stdout))
}
#[cfg(test)]
mod tests {
use std::process::Stdio;
use tokio::io::AsyncBufReadExt;
use super::*;
fn spawn_cat_process() -> tokio::process::Child {
let mut command = tokio::process::Command::new("cat");
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
command.spawn().expect("failed to spawn `cat`")
}
#[test]
fn response_id_matches_returns_true_for_matching_string_id() {
let response_value = serde_json::json!({"id": "init-123", "result": {}});
assert!(response_id_matches(&response_value, "init-123"));
}
#[test]
fn response_id_matches_returns_false_for_different_id() {
let response_value = serde_json::json!({"id": "init-123", "result": {}});
assert!(!response_id_matches(&response_value, "init-456"));
}
#[test]
fn response_id_matches_returns_false_when_id_is_missing() {
let response_value = serde_json::json!({"method": "session/update", "params": {}});
assert!(!response_id_matches(&response_value, "init-123"));
}
#[test]
fn response_id_matches_returns_false_for_integer_id() {
let response_value = serde_json::json!({"id": 1, "result": {}});
assert!(!response_id_matches(&response_value, "1"));
}
#[test]
fn extract_json_error_message_returns_message_string() {
let response_value = serde_json::json!({
"id": "req-1",
"error": {"code": -32600, "message": "Invalid request"}
});
let message = extract_json_error_message(&response_value);
assert_eq!(message, Some("Invalid request".to_string()));
}
#[test]
fn extract_json_error_message_returns_none_without_error() {
let response_value = serde_json::json!({"id": "req-1", "result": {}});
let message = extract_json_error_message(&response_value);
assert_eq!(message, None);
}
#[test]
fn extract_json_error_message_returns_none_without_message_field() {
let response_value = serde_json::json!({
"id": "req-1",
"error": {"code": -32600}
});
let message = extract_json_error_message(&response_value);
assert_eq!(message, None);
}
#[tokio::test]
async fn write_json_line_writes_serialized_payload_with_newline() {
let mut child = spawn_cat_process();
let mut stdin = child.stdin.take().expect("`cat` stdin should be piped");
let stdout = child.stdout.take().expect("`cat` stdout should be piped");
let payload = serde_json::json!({
"id": "req-1",
"method": "initialize",
"params": {"value": 1}
});
write_json_line(&mut stdin, &payload)
.await
.expect("write should succeed");
drop(stdin);
let echoed_line = BufReader::new(stdout)
.lines()
.next_line()
.await
.expect("stdout read should succeed")
.expect("echoed payload line should exist");
assert_eq!(echoed_line, payload.to_string());
shutdown_child(&mut child).await;
}
#[tokio::test]
async fn wait_for_response_line_skips_invalid_and_non_matching_lines() {
let (reader, mut writer) = tokio::io::duplex(512);
let writer_task = tokio::spawn(async move {
writer
.write_all(
b"not-json\n{\"id\":\"other\",\"result\":{}}\n{\"id\":\"req-1\",\"result\":{\"ok\":true}}\n",
)
.await
.expect("test writer should succeed");
});
let mut stdout_lines = BufReader::new(reader).lines();
let response_line = wait_for_response_line(&mut stdout_lines, "req-1")
.await
.expect("matching response should be returned");
assert_eq!(response_line, "{\"id\":\"req-1\",\"result\":{\"ok\":true}}");
writer_task.await.expect("writer task should finish");
}
#[tokio::test]
async fn wait_for_response_line_returns_error_when_stream_ends() {
let (reader, mut writer) = tokio::io::duplex(256);
let writer_task = tokio::spawn(async move {
writer
.write_all(b"{\"id\":\"other\",\"result\":{}}\n")
.await
.expect("test writer should succeed");
drop(writer);
});
let mut stdout_lines = BufReader::new(reader).lines();
let response_result = wait_for_response_line(&mut stdout_lines, "req-1").await;
assert!(
matches!(
response_result,
Err(AppServerTransportError::ProcessTerminated)
),
"expected ProcessTerminated, got: {response_result:?}"
);
writer_task.await.expect("writer task should finish");
}
#[test]
fn io_error_display_includes_context_and_source() {
let error = AppServerTransportError::Io {
context: "Failed writing to app-server stdin".to_string(),
source: std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe closed"),
};
let display = error.to_string();
assert_eq!(display, "Failed writing to app-server stdin: pipe closed");
}
#[test]
fn process_terminated_display_message() {
let error = AppServerTransportError::ProcessTerminated;
assert_eq!(
error.to_string(),
"App-server terminated before sending expected response"
);
}
#[test]
fn timeout_display_includes_response_id_and_seconds() {
let error = AppServerTransportError::Timeout {
response_id: "init-123".to_string(),
timeout_seconds: 300,
};
assert_eq!(
error.to_string(),
"Timed out waiting for app-server response `init-123` after 300 seconds"
);
}
#[tokio::test]
async fn shutdown_child_exits_cleanly_after_closing_stdin() {
let mut child = spawn_cat_process();
shutdown_child(&mut child).await;
let exit_status = child.wait().await.expect("child wait should succeed");
assert!(exit_status.success());
}
}