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 LONG_CALL_TIMEOUT: Duration = Duration::from_secs(3600);
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
const RESTART_WAIT: Duration = Duration::from_secs(20);
pub struct Upstream {
pub port: u16,
current: std::sync::Mutex<(u16, 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,
current: std::sync::Mutex::new((discovery.port, 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> {
let deadline = std::time::Instant::now() + RESTART_WAIT;
loop {
match self.request("POST", "/api/mcp", Some(request), CALL_TIMEOUT) {
Ok(answer) => return Ok(answer),
Err(e) => {
if std::time::Instant::now() >= deadline || !self.rediscover() {
return Err(e);
}
std::thread::sleep(Duration::from_secs(1));
}
}
}
}
fn rediscover(&self) -> bool {
let Some(found) = Discovery::read(&self.via) else {
return false;
};
*self.current.lock().unwrap_or_else(|e| e.into_inner()) = (found.port, found.token);
true
}
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 get(&self, path: &str) -> Result<String> {
self.request("GET", path, None, CALL_TIMEOUT)
}
pub fn post(&self, path: &str, body: &serde_json::Value) -> Result<String> {
self.request("POST", path, Some(&body.to_string()), CALL_TIMEOUT)
}
pub fn post_long(&self, path: &str, body: &serde_json::Value) -> Result<String> {
self.request("POST", path, Some(&body.to_string()), LONG_CALL_TIMEOUT)
}
pub fn accel(&self) -> Result<serde_json::Value> {
let text = self.request("GET", "/api/accel", None, CALL_TIMEOUT)?;
Ok(serde_json::from_str(&text)?)
}
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 (port, token) = self
.current
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let address = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&address, timeout)
.with_context(|| format!("connecting to the daemon on 127.0.0.1:{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\
{}: {}\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\r\n",
port,
crate::http::TOKEN_HEADER,
token,
std::process::id(),
crate::clientid::HEADER,
crate::clientid::host().unwrap_or("-"),
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 const CLOSED: &str = "notifications/semlith/closed";
pub const ALIVE: &str = "notifications/semlith/alive";
const HEARTBEAT: Duration = Duration::from_secs(30);
pub fn serve(upstream: &Upstream, input: impl BufRead, mut output: impl Write) -> Result<()> {
let client_info = std::sync::Mutex::new(serde_json::Value::Null);
let (done, stopped) = std::sync::mpsc::channel::<()>();
let result = std::thread::scope(|scope| {
let client_info = &client_info;
scope.spawn(move || {
while let Err(std::sync::mpsc::RecvTimeoutError::Timeout) =
stopped.recv_timeout(HEARTBEAT)
{
let info = client_info
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let beat = serde_json::json!({
"jsonrpc": "2.0",
"method": ALIVE,
"params": { "clientInfo": info },
});
let _ = upstream.call(&beat.to_string());
}
});
let result = forward(upstream, input, &mut output, client_info);
drop(done);
result
});
let _ = upstream.call(&format!(r#"{{"jsonrpc":"2.0","method":"{CLOSED}"}}"#));
result
}
fn forward(
upstream: &Upstream,
input: impl BufRead,
output: &mut impl Write,
client_info: &std::sync::Mutex<serde_json::Value>,
) -> Result<()> {
for line in input.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(message) = serde_json::from_str::<serde_json::Value>(&line)
&& message.get("method").and_then(serde_json::Value::as_str) == Some("initialize")
&& let Some(info) = message.pointer("/params/clientInfo")
{
*client_info.lock().unwrap_or_else(|e| e.into_inner()) = info.clone();
}
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"
);
}
}