Skip to main content

netget/server/stomp/
mod.rs

1//! STOMP 1.2 server implementation.
2//!
3//! # Where the work is divided
4//!
5//! Everything mechanical is done here in Rust and never asked of the model: frame boundaries,
6//! header escaping, the `content-length`/NUL body rule, the `receipt` handshake, heart-beat
7//! negotiation, and refusing a frame that violates the spec. The model decides *content* —
8//! admit this CONNECT or not, what a MESSAGE carries, whether to refuse.
9//!
10//! That split is why `receipt` is handled generically: it is a property of the frame, not of
11//! what the frame means, and asking a model to remember it on every reply is asking it to get
12//! it wrong eventually. A client frame carrying `receipt` gets a `RECEIPT` back once the frame
13//! has been processed — after the model's own output, as the spec requires.
14//!
15//! # No per-connection state machine, deliberately
16//!
17//! `src/server/tcp/mod.rs` carries an Idle/Processing/Accumulating machine because raw TCP has
18//! no frame boundaries: the reader task cannot know whether the bytes it just read complete a
19//! request, so a second read arriving mid-LLM-call has to be queued. STOMP *does* have frame
20//! boundaries, so this connection reads, parses out whole frames, and handles them one at a
21//! time in the same task. Bytes arriving during an LLM call sit in the socket buffer — TCP's
22//! own backpressure — and are read on the next pass. There is no window in which two LLM calls
23//! could run for one connection, so there is no state to track.
24//!
25//! # Failure behaviour
26//!
27//! STOMP has an `ERROR` frame, so this server is never silent on failure. The peer is told a
28//! *category* (`crate::utils::WireFailure`) and the connection is closed, as the spec requires
29//! after an ERROR. The error itself goes to the log and the status stream only — never onto
30//! the wire.
31//!
32//! `stomp_connect` is the one event where "the model said nothing" is not an acceptable
33//! answer: STOMP defines exactly two replies to CONNECT, and a client that receives neither
34//! blocks forever. So a connect event that produces no bytes is answered with an ERROR and
35//! closed, logged as `decision=fail_closed_no_answer`. For every other event silence is a real
36//! answer — a broker that accepts a SEND and says nothing is behaving normally.
37
38pub mod actions;
39pub mod frame;
40
41use crate::llm::action_helper::call_llm;
42use crate::llm::actions::protocol_trait::ActionResult;
43use crate::llm::ollama_client::OllamaClient;
44use crate::logging::emit::Log;
45use crate::protocol::Event;
46use crate::server::connection::ConnectionId;
47use crate::state::app_state::AppState;
48use actions::{
49    StompProtocol, STOMP_ACK_EVENT, STOMP_CONNECT_EVENT, STOMP_DISCONNECT_EVENT, STOMP_NACK_EVENT,
50    STOMP_SEND_EVENT, STOMP_SUBSCRIBE_EVENT, STOMP_UNSUBSCRIBE_EVENT, STOMP_VERSION,
51};
52use anyhow::Result;
53use frame::{error_frame, receipt_frame, ParseOutcome, StompFrame};
54use std::net::SocketAddr;
55use std::sync::Arc;
56use tokio::io::{AsyncReadExt, AsyncWriteExt};
57use tokio::sync::{mpsc, Mutex};
58
59/// How long to wait for a peer's `CONNECT` after it opens the socket.
60///
61/// STOMP is client-speaks-first and the spec makes `CONNECT`/`STOMP` the *only* legal first
62/// frame, so a peer that has connected and sent nothing has begun no session at all — which is
63/// the state an unauthenticated flood lives in. The deadline wraps the `read()` and nothing
64/// else, so a model round-trip, or a `manual` rule parking the CONNECT for a human, sits outside
65/// it by construction.
66const FIRST_FRAME_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
67
68/// How long a connected peer may send nothing further.
69///
70/// **This is the one bound here that can close a well-behaved client, and the reason is worth
71/// stating rather than hiding.** A STOMP subscriber sends `SUBSCRIBE` once and then only
72/// receives, and this server negotiates heart-beating **off unconditionally**
73/// (`actions::STOMP_HEARTBEAT` is `"0,0"`, because there is no heart-beat timer here and
74/// promising one would be a lie), so there is no keepalive interval to sit above and no traffic
75/// from an idle subscriber at all. Some finite bound is still required — a socket, a task and an
76/// `AppState` row per silent peer, pre-authentication, is the denial of service this exists to
77/// close.
78///
79/// Thirty minutes is chosen against what a real broker does: ActiveMQ and RabbitMQ negotiate
80/// heart-beats in the 10–60 second range and cut a peer at twice the negotiated interval, so
81/// this is between thirty and a hundred and eighty times more patient than either. A client that
82/// wants to stay silent longer has a cheap way to say so: a bare EOL is a STOMP heart-beat, the
83/// parser drains it as one, and it counts as inbound activity here.
84const IDLE_BETWEEN_FRAMES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1800);
85
86/// Concurrent connections this server admits.
87///
88/// Each admitted connection holds a task, a frame buffer and an `AppState` row before any
89/// `CONNECT` is decided, so the cap is what turns those per-connection bounds into total ones.
90const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
91
92/// The `message` header of the `ERROR` frame a peer over [`MAX_CONNECTIONS`] is sent.
93///
94/// STOMP has an `ERROR` frame and the spec has the server close after one, which is exactly the
95/// shape of a refusal — so the peer is told, in the protocol's own vocabulary, rather than
96/// dropped. The frame is built through `frame::error_frame` rather than written as a byte
97/// literal so that the escaping and the `content-length`/NUL rules stay the encoder's job.
98const CONNECTION_CAP_REFUSAL_MESSAGE: &str = "too many connections";
99
100/// The body of that `ERROR` frame. A fixed string: nothing derived from an internal error may
101/// reach the wire (`crate::utils::wire_failure`), and there is nothing internal here anyway —
102/// the server is simply full.
103const CONNECTION_CAP_REFUSAL_BODY: &str =
104    "The server has reached its connection limit. Retry later.";
105
106pub struct StompServer;
107
108impl StompServer {
109    /// Bind and start accepting STOMP connections.
110    ///
111    /// Returns `Err` if the socket cannot be bound, so `server_startup` records
112    /// `ServerStatus::Error` rather than a server that claims to be up and is not.
113    pub async fn spawn_with_llm_actions(
114        listen_addr: SocketAddr,
115        llm_client: OllamaClient,
116        app_state: Arc<AppState>,
117        status_tx: mpsc::UnboundedSender<String>,
118        server_id: crate::state::ServerId,
119    ) -> Result<SocketAddr> {
120        let listener = crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr)
121            .await
122            .map_err(|e| anyhow::anyhow!("STOMP could not bind {listen_addr}: {e}"))?;
123        let local_addr = listener.local_addr()?;
124
125        Log::new(Some(&status_tx)).info(format!("STOMP server listening on {}", local_addr));
126
127        let protocol = Arc::new(StompProtocol::new());
128        let task_registrar = app_state.clone();
129
130        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
131        // Encoded once rather than per refusal: the bytes never change, and going through the
132        // protocol's own encoder keeps header escaping and the NUL terminator its job.
133        let cap_refusal = error_frame(CONNECTION_CAP_REFUSAL_MESSAGE, CONNECTION_CAP_REFUSAL_BODY);
134
135        let accept_handle = tokio::spawn(async move {
136            loop {
137                match crate::server::accept_bounded::accept_bounded(
138                    &listener,
139                    &limiter,
140                    &cap_refusal,
141                    "STOMP",
142                    Some(&status_tx),
143                )
144                .await
145                {
146                    Ok((socket, peer_addr, permit)) => {
147                        let connection_id =
148                            ConnectionId::new(app_state.get_next_unified_id().await);
149                        let local_addr_conn = socket.local_addr().unwrap_or(local_addr);
150
151                        use crate::state::server::{
152                            ConnectionState as ServerConnectionState, ConnectionStatus,
153                            ProtocolConnectionInfo,
154                        };
155                        let now = crate::utils::clock::Instant::now();
156                        app_state
157                            .add_connection_to_server(
158                                server_id,
159                                ServerConnectionState {
160                                    id: connection_id,
161                                    remote_addr: peer_addr,
162                                    local_addr: local_addr_conn,
163                                    bytes_sent: 0,
164                                    bytes_received: 0,
165                                    packets_sent: 0,
166                                    packets_received: 0,
167                                    last_activity: now,
168                                    status: ConnectionStatus::Active,
169                                    status_changed_at: now,
170                                    protocol_info: ProtocolConnectionInfo::new(
171                                        serde_json::json!({"session": "pre-connect"}),
172                                    ),
173                                },
174                            )
175                            .await;
176                        let _ = status_tx.send("__UPDATE_UI__".to_string());
177
178                        Log::new(Some(&status_tx))
179                            .info(format!("STOMP client connected from {}", peer_addr));
180
181                        let llm_clone = llm_client.clone();
182                        let state_clone = app_state.clone();
183                        let status_clone = status_tx.clone();
184                        let protocol_clone = protocol.clone();
185
186                        // Every task this protocol spawns is registered, not just the accept
187                        // loop: aborting a parent does not abort what it spawned, so an
188                        // unregistered connection task keeps its socket alive after
189                        // stop_server has released the listener.
190                        let conn_handle = tokio::spawn(async move {
191                            // Held for the life of the connection: dropping it early releases
192                            // the slot while the peer is still here, which silently un-caps
193                            // the server.
194                            let _permit = permit;
195                            handle_stomp_connection(
196                                socket,
197                                peer_addr,
198                                llm_clone,
199                                state_clone,
200                                status_clone,
201                                server_id,
202                                protocol_clone,
203                                connection_id,
204                            )
205                            .await
206                        });
207                        app_state.register_server_task(server_id, conn_handle).await;
208                    }
209                    Err(e) => {
210                        Log::new(Some(&status_tx)).error(format!("STOMP accept error: {}", e));
211                        break;
212                    }
213                }
214            }
215        });
216
217        task_registrar
218            .register_server_task(server_id, accept_handle)
219            .await;
220
221        Ok(local_addr)
222    }
223}
224
225/// Write bytes to the peer and count them.
226///
227/// The write guard is dropped before the stats update, so nothing awaits an `AppState` lock
228/// while holding the socket.
229async fn write_counted<W>(
230    write_half: &Arc<Mutex<W>>,
231    data: &[u8],
232    app_state: &AppState,
233    server_id: crate::state::ServerId,
234    connection_id: ConnectionId,
235) -> std::io::Result<()>
236where
237    W: tokio::io::AsyncWrite + Unpin,
238{
239    {
240        let mut writer = write_half.lock().await;
241        writer.write_all(data).await?;
242        writer.flush().await?;
243    }
244    app_state
245        .update_connection_stats(
246            server_id,
247            connection_id,
248            None,
249            Some(data.len() as u64),
250            None,
251            Some(1),
252        )
253        .await;
254    Ok(())
255}
256
257#[allow(clippy::too_many_arguments)]
258async fn handle_stomp_connection(
259    socket: tokio::net::TcpStream,
260    peer_addr: SocketAddr,
261    llm_client: OllamaClient,
262    app_state: Arc<AppState>,
263    status_tx: mpsc::UnboundedSender<String>,
264    server_id: crate::state::ServerId,
265    protocol: Arc<StompProtocol>,
266    connection_id: ConnectionId,
267) {
268    let (reader, write_half) = tokio::io::split(socket);
269    let write_half = Arc::new(Mutex::new(write_half));
270
271    // Registered before the first read: a STOMP server says nothing until the client sends
272    // CONNECT, and a manual `*` routing rule can park that frame for minutes, so the operator
273    // has to be able to reach the connection while it waits.
274    let peer_rx = crate::server::peer_support::register_peer_channel(
275        &app_state,
276        server_id,
277        connection_id.as_u32(),
278    )
279    .await;
280    crate::server::peer_support::spawn_peer_command_task(
281        peer_rx,
282        protocol.clone(),
283        app_state.clone(),
284        server_id,
285        connection_id.as_u32(),
286        write_half.clone(),
287        status_tx.clone(),
288    );
289
290    run_stomp_session(
291        reader,
292        &write_half,
293        peer_addr,
294        &llm_client,
295        &app_state,
296        &status_tx,
297        server_id,
298        &protocol,
299        connection_id,
300    )
301    .await;
302
303    // Every exit path lands here. Dropping the peer handle ends the peer command task, which
304    // releases its clone of the write half; the explicit shutdown makes the FIN immediate.
305    app_state
306        .remove_peer_handle(server_id, connection_id.as_u32())
307        .await;
308    let _ = write_half.lock().await.shutdown().await;
309
310    use crate::state::server::ConnectionStatus;
311    app_state
312        .update_connection_status(server_id, connection_id, ConnectionStatus::Closed)
313        .await;
314    let _ = status_tx.send("__UPDATE_UI__".to_string());
315}
316
317/// What to do after a frame has been handled.
318enum Flow {
319    /// Keep reading frames on this connection.
320    Continue,
321    /// Stop: the session is over (DISCONNECT, ERROR, or the model hung up).
322    Close,
323}
324
325#[allow(clippy::too_many_arguments)]
326async fn run_stomp_session<R, W>(
327    mut reader: R,
328    write_half: &Arc<Mutex<W>>,
329    peer_addr: SocketAddr,
330    llm_client: &OllamaClient,
331    app_state: &Arc<AppState>,
332    status_tx: &mpsc::UnboundedSender<String>,
333    server_id: crate::state::ServerId,
334    protocol: &Arc<StompProtocol>,
335    connection_id: ConnectionId,
336) where
337    R: tokio::io::AsyncRead + Unpin,
338    W: tokio::io::AsyncWrite + Unpin,
339{
340    let log = Log::new(Some(status_tx));
341    let mut pending: Vec<u8> = Vec::new();
342    let mut read_buf = vec![0u8; 8192];
343    let mut connected = false;
344
345    loop {
346        // Drain every complete frame already buffered before asking the socket for more.
347        loop {
348            match frame::parse_frame(&pending) {
349                Ok(ParseOutcome::Incomplete) => break,
350                Ok(ParseOutcome::Heartbeat { consumed }) => {
351                    // A bare EOL is a STOMP heart-beat, and clients also append trailing
352                    // newlines after a frame's NUL. Neither is a frame.
353                    log.trace(format!(
354                        "STOMP drained {consumed} inter-frame EOL byte(s) from {peer_addr}"
355                    ));
356                    pending.drain(..consumed);
357                }
358                Ok(ParseOutcome::Frame { frame, consumed }) => {
359                    pending.drain(..consumed);
360                    let flow = handle_frame(
361                        frame,
362                        &mut connected,
363                        write_half,
364                        peer_addr,
365                        llm_client,
366                        app_state,
367                        status_tx,
368                        server_id,
369                        protocol,
370                        connection_id,
371                    )
372                    .await;
373                    if matches!(flow, Flow::Close) {
374                        return;
375                    }
376                }
377                Err(e) => {
378                    // Framing is lost; STOMP has no way to resynchronise. The peer is told
379                    // what it did wrong - this is its own malformed input, not anything
380                    // internal to netget - and the connection closes as the spec requires
381                    // after an ERROR.
382                    log.warn(format!("STOMP framing error from {peer_addr}: {e}"));
383                    let _ = write_counted(
384                        write_half,
385                        &error_frame("malformed frame", &e.to_string()),
386                        app_state,
387                        server_id,
388                        connection_id,
389                    )
390                    .await;
391                    return;
392                }
393            }
394        }
395
396        // The deadline wraps this read and nothing else. `handle_frame` above is awaited in the
397        // same task, so while the model is answering — or a `manual` rule is parked for a
398        // human — nothing is being read and no clock is running: what this measures is the
399        // peer's own silence, which is what it should measure.
400        //
401        // Which bound applies is the protocol's own distinction: `connected` is set by the
402        // CONNECT/STOMP frame the spec requires first, so "has begun no session" and "is
403        // mid-session" are exactly the two states.
404        let read_deadline = if connected {
405            IDLE_BETWEEN_FRAMES_TIMEOUT
406        } else {
407            FIRST_FRAME_READ_TIMEOUT
408        };
409        let read_result =
410            match tokio::time::timeout(read_deadline, reader.read(&mut read_buf)).await {
411                Ok(v) => v,
412                Err(_) => {
413                    log.info(format!(
414                        "STOMP client {} sent nothing for {}s; closing \
415                     decision=fail_closed_idle_timeout",
416                        peer_addr,
417                        read_deadline.as_secs()
418                    ));
419                    // ERROR then close, as the spec requires after an ERROR. A fixed string: the
420                    // peer is being told the server's own policy, not anything internal.
421                    let _ = write_counted(
422                        write_half,
423                        &error_frame("idle timeout", "No frame received within the idle timeout."),
424                        app_state,
425                        server_id,
426                        connection_id,
427                    )
428                    .await;
429                    return;
430                }
431            };
432
433        match read_result {
434            Ok(0) => {
435                log.info(format!("STOMP client {} disconnected", peer_addr));
436                return;
437            }
438            Ok(n) => {
439                app_state
440                    .update_connection_stats(
441                        server_id,
442                        connection_id,
443                        Some(n as u64),
444                        None,
445                        Some(1),
446                        None,
447                    )
448                    .await;
449                log.debug(format!("STOMP received {} bytes from {}", n, peer_addr));
450                pending.extend_from_slice(&read_buf[..n]);
451            }
452            Err(e) => {
453                log.error(format!("STOMP read error from {}: {}", peer_addr, e));
454                return;
455            }
456        }
457    }
458}
459
460/// Refuse a frame the peer got wrong, and end the session.
461///
462/// `message` and `body` describe the *peer's* mistake — a missing header, an unknown command —
463/// so they are safe to send. Nothing derived from an internal error is ever routed through
464/// here; that path is `WireFailure` in [`handle_frame`].
465async fn refuse_frame<W>(
466    write_half: &Arc<Mutex<W>>,
467    app_state: &AppState,
468    server_id: crate::state::ServerId,
469    connection_id: ConnectionId,
470    message: &str,
471    body: &str,
472) -> Flow
473where
474    W: tokio::io::AsyncWrite + Unpin,
475{
476    let _ = write_counted(
477        write_half,
478        &error_frame(message, body),
479        app_state,
480        server_id,
481        connection_id,
482    )
483    .await;
484    Flow::Close
485}
486
487/// Handle one complete frame.
488#[allow(clippy::too_many_arguments)]
489async fn handle_frame<W>(
490    frame: StompFrame,
491    connected: &mut bool,
492    write_half: &Arc<Mutex<W>>,
493    peer_addr: SocketAddr,
494    llm_client: &OllamaClient,
495    app_state: &Arc<AppState>,
496    status_tx: &mpsc::UnboundedSender<String>,
497    server_id: crate::state::ServerId,
498    protocol: &Arc<StompProtocol>,
499    connection_id: ConnectionId,
500) -> Flow
501where
502    W: tokio::io::AsyncWrite + Unpin,
503{
504    let log = Log::new(Some(status_tx));
505    let command = frame.command.clone();
506    log.debug(format!("STOMP {} frame from {}", command, peer_addr));
507
508    // Nothing but a handshake is legal before one has happened.
509    if !*connected && !matches!(command.as_str(), "CONNECT" | "STOMP") {
510        log.warn(format!(
511            "STOMP {command} from {peer_addr} before CONNECT: decision=protocol_error"
512        ));
513        return refuse_frame(
514            write_half,
515            app_state,
516            server_id,
517            connection_id,
518            "expected CONNECT",
519            &format!("The first frame of a STOMP session must be CONNECT or STOMP, not {command}."),
520        )
521        .await;
522    }
523
524    let receipt_id = frame.header("receipt").map(str::to_string);
525
526    // Build the event, or refuse the frame outright. Every refusal here is a spec violation by
527    // the peer, decided deterministically - the model is never asked to police framing.
528    let event = match command.as_str() {
529        "CONNECT" | "STOMP" => {
530            let accept_version = frame.header("accept-version").unwrap_or("");
531            if !accept_version.split(',').any(|v| v.trim() == STOMP_VERSION) {
532                log.warn(format!(
533                    "STOMP CONNECT from {peer_addr} wants accept-version={accept_version:?}: \
534                     decision=version_refused"
535                ));
536                // The spec asks the ERROR to name the versions the server does support.
537                let body = format!(
538                    "This server implements STOMP {STOMP_VERSION} only. Send \
539                     accept-version:{STOMP_VERSION}."
540                );
541                let refusal = StompFrame::new(
542                    "ERROR",
543                    vec![
544                        ("version".to_string(), STOMP_VERSION.to_string()),
545                        ("message".to_string(), "unsupported version".to_string()),
546                        ("content-type".to_string(), "text/plain".to_string()),
547                    ],
548                    body.into_bytes(),
549                )
550                .encode();
551                let _ =
552                    write_counted(write_half, &refusal, app_state, server_id, connection_id).await;
553                return Flow::Close;
554            }
555            Event::new(
556                &STOMP_CONNECT_EVENT,
557                serde_json::json!({
558                    "accept_version": accept_version,
559                    "host": frame.header("host").unwrap_or(""),
560                    "login": frame.header("login").unwrap_or(""),
561                    "passcode": frame.header("passcode").unwrap_or(""),
562                    "heart_beat": frame.header("heart-beat").unwrap_or("0,0"),
563                }),
564            )
565        }
566        "SEND" => {
567            let Some(destination) = frame.header("destination") else {
568                return refuse_frame(
569                    write_half,
570                    app_state,
571                    server_id,
572                    connection_id,
573                    "missing destination",
574                    "A SEND frame must carry a destination header.",
575                )
576                .await;
577            };
578            let (body, body_encoding) = body_for_event(&frame.body);
579            Event::new(
580                &STOMP_SEND_EVENT,
581                serde_json::json!({
582                    "destination": destination,
583                    "body": body,
584                    "body_encoding": body_encoding,
585                    "headers": frame.headers_except(&["destination", "content-length"]),
586                }),
587            )
588        }
589        "SUBSCRIBE" => {
590            let (Some(destination), Some(id)) = (frame.header("destination"), frame.header("id"))
591            else {
592                return refuse_frame(
593                    write_half,
594                    app_state,
595                    server_id,
596                    connection_id,
597                    "missing subscription headers",
598                    "A SUBSCRIBE frame must carry both destination and id headers.",
599                )
600                .await;
601            };
602            Event::new(
603                &STOMP_SUBSCRIBE_EVENT,
604                serde_json::json!({
605                    "destination": destination,
606                    "id": id,
607                    "ack_mode": frame.header("ack").unwrap_or("auto"),
608                }),
609            )
610        }
611        "UNSUBSCRIBE" => {
612            let Some(id) = frame.header("id") else {
613                return refuse_frame(
614                    write_half,
615                    app_state,
616                    server_id,
617                    connection_id,
618                    "missing id",
619                    "An UNSUBSCRIBE frame must carry an id header.",
620                )
621                .await;
622            };
623            Event::new(&STOMP_UNSUBSCRIBE_EVENT, serde_json::json!({ "id": id }))
624        }
625        "ACK" | "NACK" => {
626            let Some(id) = frame.header("id") else {
627                return refuse_frame(
628                    write_half,
629                    app_state,
630                    server_id,
631                    connection_id,
632                    "missing id",
633                    &format!("A {command} frame must carry an id header."),
634                )
635                .await;
636            };
637            let event_type = if command == "ACK" {
638                &*STOMP_ACK_EVENT
639            } else {
640                &*STOMP_NACK_EVENT
641            };
642            Event::new(event_type, serde_json::json!({ "id": id }))
643        }
644        "DISCONNECT" => Event::new(
645            &STOMP_DISCONNECT_EVENT,
646            serde_json::json!({ "receipt": receipt_id.clone().unwrap_or_default() }),
647        ),
648        // Transactions are acknowledged and otherwise ignored. Holding a SEND until COMMIT
649        // would mean queueing messages inside the protocol, which is storage - see the
650        // "protocols must not implement storage" rule. The transaction header still reaches
651        // the model on the SEND event, so a handler can implement whatever it likes.
652        "BEGIN" | "COMMIT" | "ABORT" => {
653            log.info(format!(
654                "STOMP {command} from {peer_addr} acknowledged; transactions are not implemented"
655            ));
656            if let Some(id) = &receipt_id {
657                if write_counted(
658                    write_half,
659                    &receipt_frame(id),
660                    app_state,
661                    server_id,
662                    connection_id,
663                )
664                .await
665                .is_err()
666                {
667                    return Flow::Close;
668                }
669            }
670            return Flow::Continue;
671        }
672        other => {
673            log.warn(format!(
674                "STOMP unknown command {other:?} from {peer_addr}: decision=protocol_error"
675            ));
676            return refuse_frame(
677                write_half,
678                app_state,
679                server_id,
680                connection_id,
681                "unknown command",
682                &format!("{other} is not a STOMP 1.2 client command."),
683            )
684            .await;
685        }
686    };
687
688    let is_connect = matches!(command.as_str(), "CONNECT" | "STOMP");
689    let is_disconnect = command == "DISCONNECT";
690
691    let execution = match call_llm(
692        llm_client,
693        app_state,
694        server_id,
695        Some(connection_id),
696        &event,
697        protocol.as_ref(),
698    )
699    .await
700    {
701        Ok(result) => result,
702        Err(e) => {
703            // The peer gets a category; the log gets the error. Never the other way round.
704            let failure = crate::utils::WireFailure::classify(&e);
705            let class = if failure.is_overloaded() {
706                "overloaded"
707            } else {
708                "unavailable"
709            };
710            log.warn(format!(
711                "STOMP {command} on {connection_id} could not be answered: \
712                 decision=fail_closed_llm_error class={class} error={e}"
713            ));
714            let _ = write_counted(
715                write_half,
716                &error_frame(failure.text(), failure.prefixed_text()),
717                app_state,
718                server_id,
719                connection_id,
720            )
721            .await;
722            return Flow::Close;
723        }
724    };
725
726    for message in &execution.messages {
727        log.info(message);
728    }
729
730    let mut wrote_output = false;
731    let mut wrote_receipt = false;
732    let mut wrote_error = false;
733    let mut model_closed = false;
734
735    for result in execution.protocol_results {
736        match result {
737            ActionResult::Output(bytes) => {
738                if write_counted(write_half, &bytes, app_state, server_id, connection_id)
739                    .await
740                    .is_err()
741                {
742                    return Flow::Close;
743                }
744                wrote_output = true;
745                // Only a RECEIPT carrying the id the client actually asked for cancels the
746                // automatic one. Matching on the command alone meant *any* RECEIPT suppressed
747                // it, so a handler answering `UNSUBSCRIBE id:s0 receipt:r-9` with a receipt
748                // for some other id sent that one and the client's `r-9` never arrived - and
749                // a spec-compliant client blocks on it forever, with no read timeout to reap
750                // the connection. The declared examples for stomp_unsubscribe, stomp_ack and
751                // stomp_nack used to be exactly that mistake, so this was reachable by
752                // following the documentation.
753                wrote_receipt |= receipt_id
754                    .as_deref()
755                    .is_some_and(|id| is_receipt_for(&bytes, id));
756                wrote_error |= bytes.starts_with(b"ERROR\n");
757                log.debug(format!("STOMP sent {} bytes to {}", bytes.len(), peer_addr));
758                log.trace(format!(
759                    "STOMP sent: {}",
760                    String::from_utf8_lossy(&bytes).replace('\0', "\\0")
761                ));
762            }
763            ActionResult::CloseConnection => model_closed = true,
764            _ => {}
765        }
766    }
767
768    if is_connect {
769        if wrote_error {
770            log.info(format!(
771                "STOMP CONNECT from {peer_addr} refused: decision=model_reject"
772            ));
773            return Flow::Close;
774        }
775        if !wrote_output {
776            // STOMP defines exactly two answers to CONNECT. A client that gets neither blocks
777            // forever, so silence cannot be passed through as "say nothing" the way it can on
778            // a SEND.
779            log.warn(format!(
780                "STOMP CONNECT from {peer_addr} produced no frame ({} failed action(s)): \
781                 decision=fail_closed_no_answer",
782                execution.failures.len()
783            ));
784            let _ = write_counted(
785                write_half,
786                &error_frame(
787                    "connection refused",
788                    "The server did not accept this connection.",
789                ),
790                app_state,
791                server_id,
792                connection_id,
793            )
794            .await;
795            return Flow::Close;
796        }
797        *connected = true;
798        log.info(format!("STOMP session opened for {peer_addr}"));
799    } else if wrote_error {
800        // The spec requires the connection to close after an ERROR, whoever produced it.
801        log.info(format!(
802            "STOMP closing {connection_id} after ERROR: decision=model_reject"
803        ));
804        return Flow::Close;
805    }
806
807    // The receipt is sent after the frame has been processed, which is what the spec says and
808    // is also the only order that lets a handler answer before the acknowledgement. CONNECT is
809    // exempt: CONNECTED is its acknowledgement and a receipt on it is not defined.
810    let owes_receipt = receipt_id.is_some() && !is_connect && !wrote_receipt;
811    if let (true, Some(id)) = (owes_receipt, &receipt_id) {
812        if write_counted(
813            write_half,
814            &receipt_frame(id),
815            app_state,
816            server_id,
817            connection_id,
818        )
819        .await
820        .is_err()
821        {
822            return Flow::Close;
823        }
824    }
825
826    if model_closed {
827        log.info(format!(
828            "STOMP closing {connection_id}: decision=model_close"
829        ));
830        return Flow::Close;
831    }
832    if is_disconnect {
833        log.info(format!("STOMP session closed by {peer_addr}"));
834        return Flow::Close;
835    }
836    if !wrote_output {
837        log.debug(format!(
838            "STOMP {command} on {connection_id} answered with no frame: \
839             decision=model_no_actions"
840        ));
841    }
842    Flow::Continue
843}
844
845/// Whether `bytes` is a `RECEIPT` frame acknowledging `receipt_id`.
846///
847/// The comparison is against the bytes `receipt_frame` itself would produce, so the escaping
848/// rules cannot drift between the two: whatever `RECEIPT` this server builds for an id, a
849/// model-authored one for the same id is byte-identical.
850fn is_receipt_for(bytes: &[u8], receipt_id: &str) -> bool {
851    bytes.starts_with(b"RECEIPT\n") && bytes == frame::receipt_frame(receipt_id)
852}
853
854/// Render a received body for an event as text plus an explicit encoding.
855///
856/// Printable bodies go through as text so a model can read them; anything else is hex, and the
857/// `body_encoding` field says which. There is no sniffing on the way back out — the model
858/// passes the same pair to `send_stomp_message`, which is what makes an echo exact.
859fn body_for_event(body: &[u8]) -> (String, &'static str) {
860    if body
861        .iter()
862        .all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace())
863    {
864        (String::from_utf8_lossy(body).to_string(), "utf8")
865    } else {
866        (hex::encode(body), "hex")
867    }
868}