use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use super::model::{ProcKind, ProcStatus};
use super::paths::daemon_port;
use crate::json::quote;
const LINE_BATCH_INTERVAL: Duration = Duration::from_millis(500);
const LINE_BATCH_MAX_BYTES: usize = 512 * 1024;
enum PostJob {
ProcLine { proc: usize, at: f64, line: String },
Send { path: String, body: String },
Flush { done: mpsc::Sender<()> },
}
struct ClientInner {
port: u16,
session_id: String,
post_tx: Mutex<Option<Sender<PostJob>>>,
poster: Mutex<Option<thread::JoinHandle<()>>>,
}
pub struct Client {
inner: Arc<ClientInner>,
}
impl Client {
pub fn new(session_id: String) -> Client {
let port = daemon_port();
let (post_tx, post_rx) = mpsc::channel::<PostJob>();
let session_for_poster = session_id.clone();
let poster = thread::spawn(move || poster_loop(port, session_for_poster, post_rx));
Client {
inner: Arc::new(ClientInner {
port,
session_id,
post_tx: Mutex::new(Some(post_tx)),
poster: Mutex::new(Some(poster)),
}),
}
}
pub fn session_url(&self) -> String {
super::paths::session_url(self.inner.port, &self.inner.session_id)
}
pub fn register_session(&self, repo: &str, branch: &str, profile: Option<&str>, skills: &[(&str, &str)]) -> bool {
let skill_parts: Vec<String> = skills
.iter()
.map(|(name, harness)| format!("{{ \"name\": {}, \"harness\": {} }}", quote(name), quote(harness)))
.collect();
let profile_json = match profile {
Some(p) => quote(p),
None => "null".to_string(),
};
let body = format!(
"{{ \"session\": {}, \"repo\": {}, \"branch\": {}, \"profile\": {}, \"skills\": [{}] }}",
quote(&self.inner.session_id),
quote(repo),
quote(branch),
profile_json,
skill_parts.join(", ")
);
let start_ok = self.post_sync_after_flush("/api/v1/session/start", &body);
let register_ok =
self.post_sync_after_flush("/api/v1/register", &format!("{{ \"session\": {} }}", quote(&self.inner.session_id)));
start_ok && register_ok
}
pub fn deregister(&self) {
self.post_sync_after_flush("/api/v1/deregister", &format!("{{ \"session\": {} }}", quote(&self.inner.session_id)));
}
pub fn finish_session(&self) {
self.deregister();
self.close_poster();
}
pub fn close_poster(&self) {
let mut post_tx = self.inner.post_tx.lock().unwrap_or_else(|e| e.into_inner());
*post_tx = None;
let mut poster = self.inner.poster.lock().unwrap_or_else(|e| e.into_inner());
if let Some(handle) = poster.take() {
let (done_tx, done_rx) = mpsc::channel();
thread::spawn(move || {
let _ = handle.join();
let _ = done_tx.send(());
});
let _ = done_rx.recv_timeout(Duration::from_secs(3));
}
}
pub fn flush(&self) {
self.flush_poster();
self.close_poster();
}
pub fn proc_add(
&self, proc_index: usize, label: &str, kind: ProcKind, skill_name: Option<&str>, harness: Option<&str>,
model: Option<&str>,
) {
let mut extras = Vec::new();
if let Some(s) = skill_name {
extras.push(format!("\"skill_name\": {}", quote(s)));
}
if let Some(h) = harness {
extras.push(format!("\"harness\": {}", quote(h)));
}
if let Some(m) = model {
extras.push(format!("\"model\": {}", quote(m)));
}
let tail = if extras.is_empty() { String::new() } else { format!(", {}", extras.join(", ")) };
let body = format!(
"{{ \"session\": {}, \"proc\": {}, \"label\": {}, \"kind\": {}{tail} }}",
quote(&self.inner.session_id),
proc_index,
quote(label),
quote(kind.as_str()),
);
let _ = self.post("/api/v1/proc/add", &body);
}
pub fn proc_start(&self, proc_index: usize) {
let body = format!("{{ \"session\": {}, \"proc\": {} }}", quote(&self.inner.session_id), proc_index);
let _ = self.post("/api/v1/proc/start", &body);
}
pub fn proc_note(&self, proc_index: usize, note: &str) {
let body = format!(
"{{ \"session\": {}, \"proc\": {}, \"note\": {} }}",
quote(&self.inner.session_id),
proc_index,
quote(note)
);
let _ = self.post("/api/v1/proc/note", &body);
}
pub fn proc_line(&self, proc_index: usize, at: f64, line: &str) {
if let Some(tx) = lock_post_tx(&self.inner) {
let _ = tx.send(PostJob::ProcLine { proc: proc_index, at, line: line.to_string() });
}
}
pub fn proc_finish(&self, proc_index: usize, status: ProcStatus, detail: Option<&str>, elapsed: f64) {
let detail_json = match detail {
Some(d) => quote(d),
None => "null".to_string(),
};
let body = format!(
"{{ \"session\": {}, \"proc\": {}, \"status\": {}, \"detail\": {}, \"elapsed\": {} }}",
quote(&self.inner.session_id),
proc_index,
quote(status.as_str()),
detail_json,
elapsed
);
let _ = self.post("/api/v1/proc/finish", &body);
}
pub fn container_event(&self, proc_index: usize, action: &str, name: &str) {
let body = format!(
"{{ \"session\": {}, \"proc\": {}, \"action\": {}, \"name\": {} }}",
quote(&self.inner.session_id),
proc_index,
quote(action),
quote(name)
);
let _ = self.post("/api/v1/container", &body);
}
pub fn ping(&self) {
let _ = self.post("/api/v1/ping", &format!("{{ \"session\": {} }}", quote(&self.inner.session_id)));
}
pub fn daemon_alive() -> bool {
super::paths::daemon_api_responds(daemon_port())
}
fn post(&self, path: &str, body: &str) {
if let Some(tx) = lock_post_tx(&self.inner) {
let _ = tx.send(PostJob::Send { path: path.to_string(), body: body.to_string() });
}
}
fn post_sync(&self, path: &str, body: &str) -> bool {
send_post(self.inner.port, path, body)
}
fn post_sync_after_flush(&self, path: &str, body: &str) -> bool {
self.flush_poster();
self.post_sync(path, body)
}
fn flush_poster(&self) {
if let Some(tx) = lock_post_tx(&self.inner) {
let (done_tx, done_rx) = mpsc::channel();
if tx.send(PostJob::Flush { done: done_tx }).is_ok() {
let _ = done_rx.recv_timeout(Duration::from_secs(3));
}
}
}
}
fn lock_post_tx(inner: &ClientInner) -> Option<Sender<PostJob>> {
inner.post_tx.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
fn poster_loop(port: u16, session_id: String, rx: mpsc::Receiver<PostJob>) {
let mut line_buf: Vec<(usize, f64, String)> = Vec::new();
let mut line_bytes = 0usize;
loop {
match rx.recv_timeout(LINE_BATCH_INTERVAL) {
Ok(PostJob::ProcLine { proc, at, line }) => {
line_bytes += line.len();
line_buf.push((proc, at, line));
if line_bytes >= LINE_BATCH_MAX_BYTES {
flush_lines(port, &session_id, &mut line_buf, &mut line_bytes);
}
}
Ok(PostJob::Send { path, body }) => {
flush_lines(port, &session_id, &mut line_buf, &mut line_bytes);
let _ = send_post(port, &path, &body);
}
Ok(PostJob::Flush { done }) => {
flush_lines(port, &session_id, &mut line_buf, &mut line_bytes);
let _ = done.send(());
}
Err(RecvTimeoutError::Timeout) => {
flush_lines(port, &session_id, &mut line_buf, &mut line_bytes);
}
Err(RecvTimeoutError::Disconnected) => {
flush_lines(port, &session_id, &mut line_buf, &mut line_bytes);
break;
}
}
}
}
fn group_line_buf(buf: &[(usize, f64, String)]) -> Vec<(usize, Vec<(f64, String)>)> {
let mut groups = Vec::new();
let mut i = 0;
while i < buf.len() {
let proc = buf[i].0;
let mut j = i + 1;
while j < buf.len() && buf[j].0 == proc {
j += 1;
}
let chunk: Vec<(f64, String)> = buf[i..j].iter().map(|(_, at, line)| (*at, line.clone())).collect();
groups.push((proc, chunk));
i = j;
}
groups
}
fn flush_lines(port: u16, session_id: &str, buf: &mut Vec<(usize, f64, String)>, bytes: &mut usize) {
if buf.is_empty() {
return;
}
for (proc, chunk) in group_line_buf(buf) {
let _ = send_lines_bulk(port, session_id, proc, &chunk);
}
buf.clear();
*bytes = 0;
}
fn send_lines_bulk(port: u16, session_id: &str, proc_index: usize, lines: &[(f64, String)]) -> bool {
if lines.is_empty() {
return true;
}
let entries: Vec<String> =
lines.iter().map(|(at, text)| format!("{{ \"at\": {at}, \"line\": {} }}", quote(text))).collect();
let body =
format!("{{ \"session\": {}, \"proc\": {}, \"lines\": [{}] }}", quote(session_id), proc_index, entries.join(", "));
send_post(port, "/api/v1/proc/lines", &body)
}
fn send_post(port: u16, path: &str, body: &str) -> bool {
let Ok(mut stream) =
TcpStream::connect_timeout(&format!("127.0.0.1:{port}").parse().unwrap(), Duration::from_millis(500))
else {
return false;
};
stream.set_read_timeout(Some(Duration::from_secs(2))).ok();
stream.set_write_timeout(Some(Duration::from_secs(2))).ok();
let req = format!(
"POST {path} HTTP/1.1\r\n\
Host: 127.0.0.1\r\n\
Content-Type: application/json\r\n\
Content-Length: {len}\r\n\
Connection: close\r\n\r\n\
{body}",
len = body.len()
);
if stream.write_all(req.as_bytes()).is_err() {
return false;
}
let mut resp = String::new();
if stream.read_to_string(&mut resp).is_err() {
return false;
}
resp.starts_with("HTTP/1.1 200") || resp.starts_with("HTTP/1.0 200")
}
fn scsh_executable() -> std::io::Result<std::path::PathBuf> {
if let Ok(path) = std::env::var("SCSH_BIN") {
return Ok(std::path::PathBuf::from(path));
}
let exe = std::env::current_exe()?;
let lossy = exe.to_string_lossy();
if lossy.contains("/deps/scsh-") {
let mut candidate = exe.clone();
if candidate.pop() && candidate.pop() {
candidate.push("scsh");
if candidate.is_file() {
return Ok(candidate);
}
}
}
Ok(exe)
}
#[cfg(not(unix))]
pub fn spawn_daemon(mode: super::model::DaemonMode) -> std::io::Result<()> {
let exe = scsh_executable()?;
let port = daemon_port();
let mut cmd = std::process::Command::new(exe);
cmd.args(["__daemon-serve", "--mode", mode.as_str(), "--port", &port.to_string()]);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
cmd.spawn()?;
wait_for_daemon(Duration::from_secs(5))
}
#[cfg(unix)]
pub fn spawn_daemon(mode: super::model::DaemonMode) -> std::io::Result<()> {
let exe = scsh_executable()?;
let port = daemon_port();
let mut cmd = std::process::Command::new(exe);
cmd.args(["__daemon-serve", "--mode", mode.as_str(), "--port", &port.to_string()]);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| super::paths::daemon_detach_child());
}
}
cmd.spawn()?;
std::thread::sleep(Duration::from_millis(50));
wait_for_daemon(Duration::from_secs(5))
}
fn wait_for_daemon(timeout: Duration) -> std::io::Result<()> {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if Client::daemon_alive() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(50));
}
Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "daemon did not start"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
#[test]
fn client_session_url_uses_port() {
struct PinDaemonPort {
previous: Option<String>,
}
impl PinDaemonPort {
fn set() -> (u16, Self) {
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let previous = std::env::var("SCSH_DAEMON_PORT").ok();
std::env::set_var("SCSH_DAEMON_PORT", port.to_string());
(port, Self { previous })
}
}
impl Drop for PinDaemonPort {
fn drop(&mut self) {
match &self.previous {
Some(v) => std::env::set_var("SCSH_DAEMON_PORT", v),
None => std::env::remove_var("SCSH_DAEMON_PORT"),
}
}
}
let (port, _pin) = PinDaemonPort::set();
let c = Client::new("abcdef".into());
assert!(c.session_url().contains("abcdef"));
assert!(c.session_url().contains(&port.to_string()));
}
#[test]
fn poster_batches_lines_by_proc() {
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || poster_loop(1, "sess01".into(), rx));
tx.send(PostJob::ProcLine { proc: 1, at: 1.0, line: "b".into() }).unwrap();
tx.send(PostJob::ProcLine { proc: 0, at: 0.5, line: "a".into() }).unwrap();
drop(tx);
let _ = handle.join();
}
#[test]
fn group_line_buf_preserves_insertion_order() {
let buf =
vec![(1, 1.0, "first".into()), (2, 2.0, "other".into()), (1, 3.0, "second".into()), (1, 4.0, "third".into())];
let groups = group_line_buf(&buf);
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].0, 1);
assert_eq!(groups[0].1.len(), 1);
assert_eq!(groups[0].1[0].1, "first");
assert_eq!(groups[1].0, 2);
assert_eq!(groups[2].0, 1);
assert_eq!(groups[2].1.len(), 2);
assert_eq!(groups[2].1[0].1, "second");
assert_eq!(groups[2].1[1].1, "third");
}
}