use crate::daemon::Discovery;
use anyhow::{Context, Result, bail};
use std::io::{BufRead, Read, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
const CALL_TIMEOUT: Duration = Duration::from_secs(90);
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
pub struct Upstream {
pub port: u16,
token: String,
pub via: PathBuf,
}
pub fn find(dirs: &[PathBuf]) -> Option<Upstream> {
for dir in dirs {
let Some(discovery) = Discovery::read(dir) else {
continue;
};
let upstream = Upstream {
port: discovery.port,
token: discovery.token,
via: dir.clone(),
};
if upstream.alive() {
return Some(upstream);
}
eprintln!(
"semlith: {} names a daemon on port {} that is not answering; \
opening the store directly",
dir.join(crate::daemon::DISCOVERY_FILE).display(),
upstream.port,
);
}
None
}
impl Upstream {
fn alive(&self) -> bool {
self.request("GET", "/api/about", None, PROBE_TIMEOUT)
.is_ok_and(|body| body.contains("\"version\""))
}
pub fn call(&self, request: &str) -> Result<String> {
self.request("POST", "/api/mcp", Some(request), CALL_TIMEOUT)
}
pub fn adopt_key(&self, key: &str, now: bool) -> Result<String> {
let body = serde_json::json!({ "key": key, "now": now }).to_string();
self.request("POST", "/api/key", Some(&body), CALL_TIMEOUT)
}
pub fn raw_read(
&self,
path: &str,
client: &str,
session: &str,
timeout: Duration,
) -> Result<String> {
let body =
serde_json::json!({ "path": path, "client": client, "session": session }).to_string();
self.request("POST", "/api/ledger/raw-read", Some(&body), timeout)
}
pub fn delete_store(&self, name: &str) -> Result<String> {
let body = serde_json::json!({ "store": name }).to_string();
self.request("POST", "/api/store/delete", Some(&body), CALL_TIMEOUT)
}
fn request(
&self,
method: &str,
path: &str,
body: Option<&str>,
timeout: Duration,
) -> Result<String> {
let address = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, self.port));
let mut stream = TcpStream::connect_timeout(&address, timeout)
.with_context(|| format!("connecting to the daemon on 127.0.0.1:{}", self.port))?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let body = body.unwrap_or("");
let head = format!(
"{method} {path} HTTP/1.1\r\n\
Host: 127.0.0.1:{}\r\n\
{}: {}\r\n\
Semlith-Proxy: {}\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\r\n",
self.port,
crate::http::TOKEN_HEADER,
self.token,
std::process::id(),
body.len(),
);
stream.write_all(head.as_bytes())?;
stream.write_all(body.as_bytes())?;
stream.flush()?;
const MAX_ANSWER: u64 = 16 * 1024 * 1024;
let mut raw = Vec::new();
stream.take(MAX_ANSWER + 1).read_to_end(&mut raw)?;
if raw.len() as u64 > MAX_ANSWER {
bail!("the daemon's answer was larger than semlith will read from it");
}
let text = String::from_utf8_lossy(&raw).into_owned();
let (headers, payload) = text
.split_once("\r\n\r\n")
.context("the daemon sent no complete response")?;
let status = headers
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("");
if status != "200" {
bail!("the daemon answered {status}");
}
Ok(payload.to_string())
}
}
pub fn serve(upstream: &Upstream, input: impl BufRead, mut output: impl Write) -> Result<()> {
for line in input.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let id = serde_json::from_str::<serde_json::Value>(&line)
.ok()
.and_then(|v| v.get("id").cloned());
match upstream.call(&line) {
Ok(answer) => {
let answer = answer.trim();
if !answer.is_empty() {
writeln!(output, "{answer}")?;
output.flush()?;
}
}
Err(e) => {
let Some(id) = id else { continue };
let error = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32603,
"message": format!(
"the semlith daemon on 127.0.0.1:{} stopped answering: {e}",
upstream.port
),
},
});
writeln!(output, "{error}")?;
output.flush()?;
}
}
}
Ok(())
}
pub fn candidates(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter()
.filter(|d| d.join("store.db").exists())
.cloned()
.collect()
}
pub fn proxy_pid(header: Option<&str>) -> Option<u32> {
header?.trim().parse().ok()
}
pub fn daemon_recorded(dir: &Path) -> bool {
Discovery::read(dir).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_proxy_announces_a_parseable_pid() {
assert_eq!(proxy_pid(Some("4213")), Some(4213));
assert_eq!(proxy_pid(Some(" 4213 ")), Some(4213));
assert_eq!(proxy_pid(Some("not-a-pid")), None);
assert_eq!(proxy_pid(None), None);
}
#[test]
fn only_real_store_directories_are_candidates() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("a");
let empty = dir.path().join("b");
std::fs::create_dir_all(&store).unwrap();
std::fs::create_dir_all(&empty).unwrap();
std::fs::write(store.join("store.db"), b"").unwrap();
assert_eq!(
candidates(&[store.clone(), empty]),
vec![store],
"a directory with no store.db is not a candidate"
);
}
}