Major feature addition. For full details read `./docs/development_history/DISTRIBUTION.md`
259 lines
8.4 KiB
Rust
259 lines
8.4 KiB
Rust
use std::fs::OpenOptions;
|
|
use std::io::{self, BufRead, BufReader, Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::sync::mpsc;
|
|
use std::thread::{self, JoinHandle};
|
|
|
|
use swactor::stats::RuntimeStats;
|
|
|
|
use super::event::AppEvent;
|
|
use super::types::RuntimeEndpoint;
|
|
|
|
/// Debug log file — visible after TUI exits (stderr is swallowed by raw mode).
|
|
const DEBUG_LOG: &str = "/tmp/swactor_sse_debug.log";
|
|
|
|
fn debug_log(msg: &str) {
|
|
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(DEBUG_LOG) {
|
|
let _ = writeln!(f, "[sse] {}", msg);
|
|
}
|
|
}
|
|
|
|
/// Spawn a thread that connects to a runtime's `/events` SSE endpoint
|
|
/// and sends parsed `RuntimeStats` through the provided channel.
|
|
///
|
|
/// Only handles `http://` endpoints. Returns an error if the endpoint
|
|
/// cannot be parsed or the initial TCP connection fails.
|
|
pub fn spawn_sse_reader(
|
|
endpoint: RuntimeEndpoint,
|
|
tx: mpsc::Sender<AppEvent>,
|
|
) -> io::Result<JoinHandle<()>> {
|
|
let (host, port) = endpoint.parse_http().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!(
|
|
"unsupported endpoint (expected http://host:port): {}",
|
|
endpoint.endpoint
|
|
),
|
|
)
|
|
})?;
|
|
|
|
let addr = format!("{}:{}", host, port);
|
|
let stream = TcpStream::connect(&addr)?;
|
|
let host_header = addr.clone();
|
|
|
|
Ok(thread::spawn(move || {
|
|
debug_log(&format!("connecting to {}", endpoint));
|
|
if let Err(e) = sse_read_loop(stream, &host_header, &endpoint, &tx) {
|
|
debug_log(&format!("error for {}: {}", endpoint, e));
|
|
eprintln!("SSE reader error for {}: {}", endpoint, e);
|
|
}
|
|
}))
|
|
}
|
|
|
|
fn sse_read_loop(
|
|
mut stream: TcpStream,
|
|
host: &str,
|
|
endpoint: &RuntimeEndpoint,
|
|
tx: &mpsc::Sender<AppEvent>,
|
|
) -> io::Result<()> {
|
|
// HTTP/1.1 so tiny_http will use chunked transfer encoding for streaming.
|
|
// (HTTP/1.0 causes tiny_http to buffer the entire response.)
|
|
let request = format!(
|
|
"GET /events HTTP/1.1\r\nHost: {}\r\nAccept: text/event-stream\r\n\r\n",
|
|
host
|
|
);
|
|
stream.write_all(request.as_bytes())?;
|
|
|
|
let mut reader = BufReader::new(stream);
|
|
|
|
// Parse response headers — detect chunked encoding
|
|
let mut chunked = false;
|
|
let mut header_line = String::new();
|
|
loop {
|
|
header_line.clear();
|
|
let n = reader.read_line(&mut header_line)?;
|
|
if n == 0 {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::UnexpectedEof,
|
|
"connection closed during headers",
|
|
));
|
|
}
|
|
let trimmed = header_line.trim();
|
|
if trimmed.is_empty() {
|
|
break;
|
|
}
|
|
debug_log(&format!("header: {}", trimmed));
|
|
if trimmed.to_ascii_lowercase().starts_with("transfer-encoding:")
|
|
&& trimmed.to_ascii_lowercase().contains("chunked")
|
|
{
|
|
chunked = true;
|
|
}
|
|
}
|
|
|
|
debug_log(&format!("headers done, chunked={}", chunked));
|
|
|
|
if chunked {
|
|
let dechunked = DechunkedReader::new(reader);
|
|
let line_reader = BufReader::new(dechunked);
|
|
parse_sse_events(line_reader, endpoint, tx)
|
|
} else {
|
|
parse_sse_events(reader, endpoint, tx)
|
|
}
|
|
}
|
|
|
|
fn parse_sse_events<R: BufRead>(
|
|
mut reader: R,
|
|
endpoint: &RuntimeEndpoint,
|
|
tx: &mpsc::Sender<AppEvent>,
|
|
) -> io::Result<()> {
|
|
let mut current_event = String::new();
|
|
let mut data_buf = String::new();
|
|
let mut line = String::new();
|
|
let mut event_count: u64 = 0;
|
|
|
|
loop {
|
|
line.clear();
|
|
let n = reader.read_line(&mut line)?;
|
|
if n == 0 {
|
|
debug_log("EOF");
|
|
break;
|
|
}
|
|
|
|
let trimmed = line.trim_end();
|
|
|
|
if trimmed.is_empty() {
|
|
// Empty line = end of SSE event, dispatch
|
|
if current_event == "stats" && !data_buf.is_empty() {
|
|
match serde_json::from_str::<RuntimeStats>(&data_buf) {
|
|
Ok(stats) => {
|
|
event_count += 1;
|
|
if event_count <= 3 || event_count % 100 == 0 {
|
|
debug_log(&format!("stats event #{}", event_count));
|
|
}
|
|
let event = AppEvent::StatsUpdate {
|
|
source: endpoint.clone(),
|
|
stats: Box::new(stats),
|
|
};
|
|
if tx.send(event).is_err() {
|
|
debug_log("channel closed, exiting");
|
|
return Ok(());
|
|
}
|
|
}
|
|
Err(e) => {
|
|
debug_log(&format!(
|
|
"JSON parse error: {} data={}",
|
|
e,
|
|
&data_buf[..data_buf.len().min(200)]
|
|
));
|
|
}
|
|
}
|
|
} else if current_event == "done" {
|
|
debug_log("received done event");
|
|
return Ok(());
|
|
}
|
|
#[cfg(feature = "distribution")]
|
|
if current_event == "distribution" && !data_buf.is_empty() {
|
|
match serde_json::from_str::<distribution::snapshot::DistributionNodeSnapshot>(&data_buf) {
|
|
Ok(snapshot) => {
|
|
let event = AppEvent::DistributionUpdate {
|
|
snapshot: Box::new(snapshot),
|
|
};
|
|
if tx.send(event).is_err() {
|
|
debug_log("channel closed, exiting");
|
|
return Ok(());
|
|
}
|
|
}
|
|
Err(e) => {
|
|
debug_log(&format!(
|
|
"distribution JSON parse error: {} data={}",
|
|
e,
|
|
&data_buf[..data_buf.len().min(200)]
|
|
));
|
|
}
|
|
}
|
|
}
|
|
current_event.clear();
|
|
data_buf.clear();
|
|
} else if let Some(event_type) = trimmed.strip_prefix("event: ") {
|
|
current_event = event_type.to_string();
|
|
} else if let Some(data) = trimmed.strip_prefix("data: ") {
|
|
data_buf.push_str(data);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── Chunked transfer encoding decoder ────────────────────────────────
|
|
|
|
/// Transparently decodes HTTP chunked transfer encoding.
|
|
///
|
|
/// Wraps a `BufReader<R>` (which already consumed the response headers)
|
|
/// and exposes a plain `Read` that yields the dechunked body bytes.
|
|
struct DechunkedReader<R> {
|
|
inner: BufReader<R>,
|
|
remaining: usize,
|
|
finished: bool,
|
|
}
|
|
|
|
impl<R: Read> DechunkedReader<R> {
|
|
fn new(inner: BufReader<R>) -> Self {
|
|
Self {
|
|
inner,
|
|
remaining: 0,
|
|
finished: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<R: Read> Read for DechunkedReader<R> {
|
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
|
if self.finished {
|
|
return Ok(0);
|
|
}
|
|
|
|
// Start of a new chunk — read the hex size line
|
|
if self.remaining == 0 {
|
|
let mut size_line = String::new();
|
|
let n = self.inner.read_line(&mut size_line)?;
|
|
if n == 0 {
|
|
self.finished = true;
|
|
return Ok(0);
|
|
}
|
|
let hex = size_line.trim();
|
|
if hex.is_empty() {
|
|
self.finished = true;
|
|
return Ok(0);
|
|
}
|
|
let size = usize::from_str_radix(hex, 16).map_err(|_| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("invalid chunk size: {:?}", hex),
|
|
)
|
|
})?;
|
|
if size == 0 {
|
|
// Terminal chunk
|
|
self.finished = true;
|
|
return Ok(0);
|
|
}
|
|
self.remaining = size;
|
|
}
|
|
|
|
// Read up to `remaining` bytes from the current chunk
|
|
let to_read = buf.len().min(self.remaining);
|
|
let n = self.inner.read(&mut buf[..to_read])?;
|
|
if n == 0 {
|
|
self.finished = true;
|
|
return Ok(0);
|
|
}
|
|
self.remaining -= n;
|
|
|
|
// After consuming all bytes in a chunk, skip the trailing \r\n
|
|
if self.remaining == 0 {
|
|
let mut crlf = [0u8; 2];
|
|
self.inner.read_exact(&mut crlf)?;
|
|
}
|
|
|
|
Ok(n)
|
|
}
|
|
}
|