pub mod actions;
pub use actions::StompClientProtocol;
use anyhow::{anyhow, bail, Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, error, info, trace, warn};
use crate::client::llm_budget::call_llm_for_client;
use crate::client::stomp::actions::{
DISCONNECT_RECEIPT_ID, STOMP_ACCEPT_VERSION, STOMP_CLIENT_CONNECTED_EVENT,
STOMP_CLIENT_ERROR_RECEIVED_EVENT, STOMP_CLIENT_MESSAGE_RECEIVED_EVENT,
STOMP_CLIENT_RECEIPT_RECEIVED_EVENT, STOMP_HEARTBEAT,
};
use crate::llm::actions::client_trait::Client;
use crate::llm::ollama_client::OllamaClient;
use crate::protocol::{Event, StartupParams};
use crate::server::stomp::frame::{self, ParseOutcome, StompFrame};
use crate::state::app_state::AppState;
use crate::state::{ClientId, ClientStatus};
const DEFAULT_HANDSHAKE_TIMEOUT_SECS: u64 = 20;
const DISCONNECT_GRACE: Duration = Duration::from_secs(10);
pub struct StompClient;
enum Flow {
Continue,
DisconnectSent,
Close,
}
impl StompClient {
pub async fn connect_with_llm_actions(
remote_addr: String,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
startup_params: Option<StartupParams>,
) -> Result<SocketAddr> {
let (host_param, login, passcode, use_stomp_command, handshake_timeout_secs) =
match &startup_params {
Some(params) => (
params.get_optional_string("host")?,
params.get_optional_string("login")?,
params.get_optional_string("passcode")?,
params
.get_optional_bool("use_stomp_command")?
.unwrap_or(false),
params
.get_optional_u64("handshake_timeout_secs")?
.unwrap_or(DEFAULT_HANDSHAKE_TIMEOUT_SECS),
),
None => (None, None, None, false, DEFAULT_HANDSHAKE_TIMEOUT_SECS),
};
let stream = TcpStream::connect(&remote_addr)
.await
.with_context(|| format!("Failed to connect to STOMP broker at {remote_addr}"))?;
let local_addr = stream.local_addr()?;
let peer_addr = stream.peer_addr()?;
let (mut read_half, write_half) = tokio::io::split(stream);
let write_half = Arc::new(Mutex::new(write_half));
let host = host_param.unwrap_or_else(|| host_of(&remote_addr));
for (field, value) in [
("host", Some(host.as_str())),
("login", login.as_deref()),
("passcode", passcode.as_deref()),
] {
if let Some(value) = value {
if !crate::server::stomp::frame::is_safe_unescaped_header(value) {
anyhow::bail!(
"STOMP startup parameter '{field}' may not contain a newline, carriage \
return, colon or NUL: CONNECT headers are exempt from 1.2 escaping, so \
such a value would forge a header rather than appear in this one"
);
}
}
}
let connect_frame = build_connect_frame(
use_stomp_command,
&host,
login.as_deref(),
passcode.as_deref(),
);
{
let mut guard = write_half.lock().await;
guard
.write_all(&connect_frame)
.await
.context("Failed to write the STOMP CONNECT frame")?;
guard.flush().await.context("Failed to flush CONNECT")?;
}
debug!(
"STOMP client {} sent {} to {} (host={}, accept-version={}, heart-beat={})",
client_id,
if use_stomp_command {
"STOMP"
} else {
"CONNECT"
},
peer_addr,
host,
STOMP_ACCEPT_VERSION,
STOMP_HEARTBEAT
);
let mut pending: Vec<u8> = Vec::new();
let reply = read_one_frame(
&mut read_half,
&mut pending,
Duration::from_secs(handshake_timeout_secs),
)
.await
.context("STOMP handshake failed")?;
let (version, session, server) = validate_connected(&reply)?;
info!(
"STOMP client {} connected to {} (version={}, session={}, server={})",
client_id,
peer_addr,
version,
if session.is_empty() {
"-"
} else {
session.as_str()
},
if server.is_empty() {
"-"
} else {
server.as_str()
}
);
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] STOMP client {client_id} connected to {peer_addr} (session {})",
if session.is_empty() {
"-"
} else {
session.as_str()
}
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
let mut command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let protocol = StompClientProtocol::new();
let memory = Arc::new(Mutex::new(
app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default(),
));
let mut disconnect_deadline: Option<tokio::time::Instant> = None;
let connected_event = Event::new(
&STOMP_CLIENT_CONNECTED_EVENT,
serde_json::json!({
"version": version,
"session": session,
"server": server,
}),
);
match report_event(
connected_event,
&protocol,
&write_half,
&llm_client,
&app_state,
&status_tx,
client_id,
&memory,
)
.await
{
Flow::Close => {
let _ = write_half.lock().await.shutdown().await;
app_state.remove_client_handle(client_id).await;
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
return Ok(local_addr);
}
Flow::DisconnectSent => {
disconnect_deadline = Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
}
Flow::Continue => {}
}
let task_registrar = app_state.clone();
let task_handle = tokio::spawn(async move {
run_session(
read_half,
write_half,
pending,
&mut command_rx,
disconnect_deadline,
protocol,
llm_client,
app_state.clone(),
status_tx.clone(),
client_id,
memory,
)
.await;
app_state.remove_client_handle(client_id).await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
});
task_registrar
.register_client_task(client_id, task_handle)
.await;
Ok(local_addr)
}
}
#[allow(clippy::too_many_arguments)]
async fn run_session(
mut read_half: tokio::io::ReadHalf<TcpStream>,
write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
mut pending: Vec<u8>,
command_rx: &mut mpsc::Receiver<crate::state::client_handles::ClientCommand>,
mut disconnect_deadline: Option<tokio::time::Instant>,
protocol: StompClientProtocol,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
memory: Arc<Mutex<String>>,
) {
let mut read_buf = vec![0u8; 8192];
'session: loop {
loop {
let outcome = match frame::parse_frame(&pending) {
Ok(outcome) => outcome,
Err(e) => {
error!("STOMP client {client_id} could not parse the broker's frame: {e}");
let _ = status_tx.send(format!(
"[CLIENT] ✖ STOMP client {client_id} framing error: {e}"
));
break 'session;
}
};
match outcome {
ParseOutcome::Incomplete => break,
ParseOutcome::Heartbeat { consumed } => {
trace!("STOMP client {client_id} drained {consumed} inter-frame EOL byte(s)");
pending.drain(..consumed);
}
ParseOutcome::Frame { frame, consumed } => {
pending.drain(..consumed);
match handle_frame(
frame,
&protocol,
&write_half,
&llm_client,
&app_state,
&status_tx,
client_id,
&memory,
)
.await
{
Flow::Continue => {}
Flow::DisconnectSent => {
disconnect_deadline =
Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
}
Flow::Close => break 'session,
}
}
}
}
let grace = async {
match disconnect_deadline {
Some(deadline) => tokio::time::sleep_until(deadline).await,
None => std::future::pending::<()>().await,
}
};
tokio::select! {
read = read_half.read(&mut read_buf) => match read {
Ok(0) => {
info!("STOMP client {client_id} — broker closed the connection");
break 'session;
}
Ok(n) => {
trace!("STOMP client {client_id} read {n} byte(s)");
pending.extend_from_slice(&read_buf[..n]);
}
Err(e) => {
error!("STOMP client {client_id} read error: {e}");
app_state
.update_client_status(client_id, ClientStatus::Error(e.to_string()))
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
return;
}
},
Some(command) = command_rx.recv() => {
let is_disconnect = command
.action
.get("type")
.and_then(|v| v.as_str())
== Some("disconnect");
let should_break = crate::client::command_support::handle_stream_client_command(
&protocol,
&write_half,
command,
client_id,
&app_state,
&status_tx,
)
.await;
if is_disconnect {
disconnect_deadline = Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
}
if should_break {
break 'session;
}
}
_ = grace => {
warn!(
"STOMP client {client_id} closing without the RECEIPT for its DISCONNECT \
({}s grace elapsed) — the broker acknowledged nothing",
DISCONNECT_GRACE.as_secs()
);
break 'session;
}
}
}
let _ = write_half.lock().await.shutdown().await;
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send(format!("[CLIENT] STOMP client {client_id} disconnected"));
}
#[allow(clippy::too_many_arguments)]
async fn handle_frame(
frame: StompFrame,
protocol: &StompClientProtocol,
write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
client_id: ClientId,
memory: &Arc<Mutex<String>>,
) -> Flow {
match frame.command.as_str() {
"MESSAGE" => {
let (body, body_encoding) = body_for_event(&frame.body);
let event = Event::new(
&STOMP_CLIENT_MESSAGE_RECEIVED_EVENT,
serde_json::json!({
"destination": frame.header("destination").unwrap_or_default(),
"message_id": frame.header("message-id").unwrap_or_default(),
"subscription": frame.header("subscription").unwrap_or_default(),
"headers": frame.headers_except(&["destination", "message-id", "subscription"]),
"body": body,
"body_encoding": body_encoding,
}),
);
debug!(
"STOMP client {client_id} received MESSAGE on {} (subscription {})",
frame.header("destination").unwrap_or("-"),
frame.header("subscription").unwrap_or("-")
);
report_event(
event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
)
.await
}
"RECEIPT" => {
let receipt_id = frame.header("receipt-id").unwrap_or_default().to_string();
if receipt_id == DISCONNECT_RECEIPT_ID {
info!(
"STOMP client {client_id} — broker acknowledged DISCONNECT, closing \
gracefully"
);
return Flow::Close;
}
debug!("STOMP client {client_id} received RECEIPT {receipt_id}");
let event = Event::new(
&STOMP_CLIENT_RECEIPT_RECEIVED_EVENT,
serde_json::json!({ "receipt_id": receipt_id }),
);
report_event(
event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
)
.await
}
"ERROR" => {
let message = frame.header("message").unwrap_or_default().to_string();
let (body, body_encoding) = body_for_event(&frame.body);
warn!("STOMP client {client_id} received ERROR from the broker: {message}");
let _ = status_tx.send(format!(
"[CLIENT] ⚠ STOMP client {client_id} got an ERROR frame: {message}"
));
let event = Event::new(
&STOMP_CLIENT_ERROR_RECEIVED_EVENT,
serde_json::json!({
"message": message,
"body": body,
"body_encoding": body_encoding,
}),
);
let _ = report_event(
event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
)
.await;
Flow::Close
}
"CONNECTED" => {
warn!("STOMP client {client_id} ignoring an unexpected second CONNECTED frame");
Flow::Continue
}
other => {
warn!(
"STOMP client {client_id} ignoring a frame with unexpected command {other:?} — \
a broker sends only MESSAGE, RECEIPT, ERROR and CONNECTED"
);
Flow::Continue
}
}
}
#[allow(clippy::too_many_arguments)]
async fn report_event(
event: Event,
protocol: &StompClientProtocol,
write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
client_id: ClientId,
memory: &Arc<Mutex<String>>,
) -> Flow {
let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
debug!(
"STOMP client {client_id} has no instruction; {} is not reported to the model",
event.event_type.id
);
return Flow::Continue;
};
let memory_snapshot = memory.lock().await.clone();
match call_llm_for_client(
llm_client,
app_state,
client_id.to_string(),
&instruction,
&memory_snapshot,
Some(&event),
protocol,
status_tx,
)
.await
{
Ok(result) => {
if let Some(updated) = result.memory_updates {
*memory.lock().await = updated.clone();
app_state.set_memory_for_client(client_id, updated).await;
}
run_actions(
result.actions,
protocol,
write_half,
app_state,
status_tx,
client_id,
)
.await
}
Err(e) => {
error!("STOMP client {client_id} LLM error on {}: {e}", event.id());
let _ = status_tx.send(format!(
"[CLIENT] ✖ STOMP client {client_id} could not answer {}: {e}",
event.id()
));
Flow::Continue
}
}
}
async fn run_actions(
actions: Vec<serde_json::Value>,
protocol: &StompClientProtocol,
write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
client_id: ClientId,
) -> Flow {
let mut flow = Flow::Continue;
for action in actions {
let is_disconnect = action.get("type").and_then(|v| v.as_str()) == Some("disconnect");
let result = match protocol.execute_action(action.clone()) {
Ok(result) => result,
Err(e) => {
warn!("STOMP client {client_id} rejected an action from the model: {e}");
let _ = status_tx.send(format!(
"[CLIENT] ⚠ STOMP client {client_id} rejected an action: {e}"
));
continue;
}
};
for bytes in result.get_all_data() {
let mut guard = write_half.lock().await;
if let Err(e) = guard.write_all(&bytes).await {
error!("STOMP client {client_id} write failed: {e}");
return Flow::Close;
}
if let Err(e) = guard.flush().await {
error!("STOMP client {client_id} flush failed: {e}");
return Flow::Close;
}
drop(guard);
trace!("STOMP client {client_id} wrote {} byte(s)", bytes.len());
}
if result.disconnects() {
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
return Flow::Close;
}
if is_disconnect {
flow = Flow::DisconnectSent;
}
}
flow
}
fn build_connect_frame(
use_stomp_command: bool,
host: &str,
login: Option<&str>,
passcode: Option<&str>,
) -> Vec<u8> {
let mut headers = vec![
(
"accept-version".to_string(),
STOMP_ACCEPT_VERSION.to_string(),
),
("host".to_string(), host.to_string()),
("heart-beat".to_string(), STOMP_HEARTBEAT.to_string()),
];
if let Some(login) = login {
headers.push(("login".to_string(), login.to_string()));
}
if let Some(passcode) = passcode {
headers.push(("passcode".to_string(), passcode.to_string()));
}
let command = if use_stomp_command {
"STOMP"
} else {
"CONNECT"
};
StompFrame::new(command, headers, Vec::new()).encode()
}
fn validate_connected(reply: &StompFrame) -> Result<(String, String, String)> {
match reply.command.as_str() {
"CONNECTED" => {}
"ERROR" => {
let message = reply.header("message").unwrap_or("(no message header)");
bail!(
"the broker refused the session with an ERROR frame: {message}. This client \
offers accept-version {STOMP_ACCEPT_VERSION} and heart-beat \
{STOMP_HEARTBEAT}."
);
}
other => bail!(
"expected CONNECTED in reply to CONNECT, got a {other:?} frame. The session is not \
open and no frame will be sent on it."
),
}
let Some(version) = reply.header("version") else {
bail!(
"the broker's CONNECTED frame carries no 'version' header, which STOMP 1.2 \
requires. Without it there is no way to know which dialect the following frames \
are in, so the session is refused rather than guessed at."
);
};
if version != STOMP_ACCEPT_VERSION {
bail!(
"the broker chose STOMP version {version:?}, which this client did not offer — its \
accept-version was {STOMP_ACCEPT_VERSION:?} and it implements nothing else. \
Speaking a dialect we do not implement would corrupt the session rather than \
degrade it."
);
}
Ok((
version.to_string(),
reply.header("session").unwrap_or_default().to_string(),
reply.header("server").unwrap_or_default().to_string(),
))
}
async fn read_one_frame<R>(
reader: &mut R,
pending: &mut Vec<u8>,
timeout: Duration,
) -> Result<StompFrame>
where
R: tokio::io::AsyncRead + Unpin,
{
let deadline = tokio::time::Instant::now() + timeout;
let mut buf = vec![0u8; 4096];
loop {
match frame::parse_frame(pending).map_err(|e| anyhow!("{e}"))? {
ParseOutcome::Frame { frame, consumed } => {
pending.drain(..consumed);
return Ok(frame);
}
ParseOutcome::Heartbeat { consumed } => {
pending.drain(..consumed);
continue;
}
ParseOutcome::Incomplete => {}
}
let n = tokio::time::timeout_at(deadline, reader.read(&mut buf))
.await
.map_err(|_| {
anyhow!(
"the broker sent no complete frame within {}s ({} byte(s) buffered)",
timeout.as_secs(),
pending.len()
)
})?
.context("read failed while waiting for the broker's reply")?;
if n == 0 {
bail!(
"the broker closed the connection without replying ({} byte(s) buffered)",
pending.len()
);
}
pending.extend_from_slice(&buf[..n]);
}
}
fn host_of(remote_addr: &str) -> String {
let trimmed = remote_addr.trim();
if let Some(rest) = trimmed.strip_prefix('[') {
if let Some(end) = rest.find(']') {
return rest[..end].to_string();
}
}
match trimmed.rsplit_once(':') {
Some((host, _port)) if !host.is_empty() => host.to_string(),
_ => trimmed.to_string(),
}
}
fn body_for_event(body: &[u8]) -> (String, &'static str) {
if body
.iter()
.all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace())
{
(String::from_utf8_lossy(body).to_string(), "utf8")
} else {
(hex::encode(body), "hex")
}
}