Skip to main content

netget/client/stomp/
mod.rs

1//! STOMP 1.2 client implementation.
2//!
3//! # The handshake is a gate, not a formality
4//!
5//! `connect()` does not return until the broker has answered `CONNECT` with a well-formed
6//! `CONNECTED` carrying a `version` header naming 1.2. Anything else — an `ERROR`, a
7//! `CONNECTED` with no version, a version we did not offer, some other command, silence until
8//! the timeout, or a closed socket — is an `Err`, so `client_startup` records
9//! [`ClientStatus::Error`] and no `stomp_connected` event is ever raised. This is exactly the
10//! strictness `async-stomp` applies to *our* server (its `Connector::connect()` refuses to
11//! hand back a transport otherwise), and it is the difference between a client that reports a
12//! session and a client that has one.
13//!
14//! # No per-connection state machine, deliberately
15//!
16//! `src/server/tcp/mod.rs` carries Idle/Processing/Accumulating because raw TCP has no frame
17//! boundaries, so a second read arriving mid-LLM-call has to be queued somewhere. STOMP *has*
18//! frame boundaries: this loop reads, parses out whole frames, and handles them one at a time
19//! in the same task. Bytes arriving during a model call sit in the socket buffer — TCP's own
20//! backpressure — and are parsed on the next pass. There is no window in which two model calls
21//! could overlap on this connection, so there is nothing to track.
22//!
23//! # Why there is no boxed follow-up recursion here
24//!
25//! The root `CLAUDE.md` requires a depth-bounded, boxed recursive call for clients whose
26//! action → event → action cycle is self-referential in-process. This client's cycle is not:
27//! every action it can take puts a frame on the wire, and the answer comes back as another
28//! frame that the same read loop parses and reports. The chain continues by itself, through
29//! the socket, exactly as `datalink`'s does through its pcap queue — so the model can
30//! subscribe, be told about the `MESSAGE` that arrives, and publish in response, without any
31//! function here calling itself. The only bound needed is the one every client already has,
32//! `client/llm_budget.rs`.
33//!
34//! # Graceful shutdown
35//!
36//! The `disconnect` action does not hang up. It writes `DISCONNECT` with a fixed `receipt`
37//! ([`actions::DISCONNECT_RECEIPT_ID`]) and this loop closes when the matching `RECEIPT`
38//! arrives — the specification's shutdown, and the only thing that tells a publisher the
39//! broker durably took what it sent. A broker that never answers is bounded by
40//! [`DISCONNECT_GRACE`] rather than leaving the loop parked forever.
41
42pub mod actions;
43
44pub use actions::StompClientProtocol;
45
46use anyhow::{anyhow, bail, Context, Result};
47use std::net::SocketAddr;
48use std::sync::Arc;
49use std::time::Duration;
50use tokio::io::{AsyncReadExt, AsyncWriteExt};
51use tokio::net::TcpStream;
52use tokio::sync::{mpsc, Mutex};
53use tracing::{debug, error, info, trace, warn};
54
55use crate::client::llm_budget::call_llm_for_client;
56use crate::client::stomp::actions::{
57    DISCONNECT_RECEIPT_ID, STOMP_ACCEPT_VERSION, STOMP_CLIENT_CONNECTED_EVENT,
58    STOMP_CLIENT_ERROR_RECEIVED_EVENT, STOMP_CLIENT_MESSAGE_RECEIVED_EVENT,
59    STOMP_CLIENT_RECEIPT_RECEIVED_EVENT, STOMP_HEARTBEAT,
60};
61use crate::llm::actions::client_trait::Client;
62use crate::llm::ollama_client::OllamaClient;
63use crate::protocol::{Event, StartupParams};
64use crate::server::stomp::frame::{self, ParseOutcome, StompFrame};
65use crate::state::app_state::AppState;
66use crate::state::{ClientId, ClientStatus};
67
68/// How long to wait for the broker's `CONNECTED` when the caller declares no preference.
69const DEFAULT_HANDSHAKE_TIMEOUT_SECS: u64 = 20;
70
71/// How long to wait for the `RECEIPT` acknowledging our own `DISCONNECT` before closing anyway.
72///
73/// A broker is entitled to close the socket the moment it sees `DISCONNECT` without ever
74/// sending the receipt, and some do. Without this bound the loop would sit on a connection
75/// nobody is going to speak on again.
76const DISCONNECT_GRACE: Duration = Duration::from_secs(10);
77
78/// STOMP 1.2 client.
79pub struct StompClient;
80
81/// What the loop should do after handling a frame or a batch of actions.
82enum Flow {
83    /// Keep reading.
84    Continue,
85    /// A `DISCONNECT` was written; close once its `RECEIPT` arrives or the grace period lapses.
86    DisconnectSent,
87    /// The session is over now.
88    Close,
89}
90
91impl StompClient {
92    /// Open a STOMP session and drive it from the model.
93    ///
94    /// Returns the local socket address once the handshake has completed and been validated.
95    pub async fn connect_with_llm_actions(
96        remote_addr: String,
97        llm_client: OllamaClient,
98        app_state: Arc<AppState>,
99        status_tx: mpsc::UnboundedSender<String>,
100        client_id: ClientId,
101        startup_params: Option<StartupParams>,
102    ) -> Result<SocketAddr> {
103        // Every accessor is fallible and every failure names the offending key: these values
104        // come from the model or an MCP caller, so `?` rather than `unwrap()`.
105        let (host_param, login, passcode, use_stomp_command, handshake_timeout_secs) =
106            match &startup_params {
107                Some(params) => (
108                    params.get_optional_string("host")?,
109                    params.get_optional_string("login")?,
110                    params.get_optional_string("passcode")?,
111                    params
112                        .get_optional_bool("use_stomp_command")?
113                        .unwrap_or(false),
114                    params
115                        .get_optional_u64("handshake_timeout_secs")?
116                        .unwrap_or(DEFAULT_HANDSHAKE_TIMEOUT_SECS),
117                ),
118                None => (None, None, None, false, DEFAULT_HANDSHAKE_TIMEOUT_SECS),
119            };
120
121        let stream = TcpStream::connect(&remote_addr)
122            .await
123            .with_context(|| format!("Failed to connect to STOMP broker at {remote_addr}"))?;
124        let local_addr = stream.local_addr()?;
125        let peer_addr = stream.peer_addr()?;
126
127        let (mut read_half, write_half) = tokio::io::split(stream);
128        let write_half = Arc::new(Mutex::new(write_half));
129
130        // The specification says a client sends the host name it established the socket to
131        // when it has nothing better; the virtual host is the "better" a broker configuration
132        // supplies.
133        let host = host_param.unwrap_or_else(|| host_of(&remote_addr));
134
135        // CONNECT is exempt from STOMP 1.2 header escaping (see `frame::should_escape`), so
136        // these three values reach the wire raw and nothing downstream can neutralise a
137        // newline in one. A `login` containing `\n` would forge an extra header on the
138        // handshake frame, and `\n\n` would end the header block. They come from startup
139        // parameters, which is the model or an MCP caller, so they are checked rather than
140        // trusted. Rejecting rather than escaping: a 1.0/1.1 broker would read an escape
141        // sequence literally, which is the whole reason the command is exempt.
142        for (field, value) in [
143            ("host", Some(host.as_str())),
144            ("login", login.as_deref()),
145            ("passcode", passcode.as_deref()),
146        ] {
147            if let Some(value) = value {
148                if !crate::server::stomp::frame::is_safe_unescaped_header(value) {
149                    anyhow::bail!(
150                        "STOMP startup parameter '{field}' may not contain a newline, carriage \
151                         return, colon or NUL: CONNECT headers are exempt from 1.2 escaping, so \
152                         such a value would forge a header rather than appear in this one"
153                    );
154                }
155            }
156        }
157
158        let connect_frame = build_connect_frame(
159            use_stomp_command,
160            &host,
161            login.as_deref(),
162            passcode.as_deref(),
163        );
164        {
165            let mut guard = write_half.lock().await;
166            guard
167                .write_all(&connect_frame)
168                .await
169                .context("Failed to write the STOMP CONNECT frame")?;
170            guard.flush().await.context("Failed to flush CONNECT")?;
171        }
172        debug!(
173            "STOMP client {} sent {} to {} (host={}, accept-version={}, heart-beat={})",
174            client_id,
175            if use_stomp_command {
176                "STOMP"
177            } else {
178                "CONNECT"
179            },
180            peer_addr,
181            host,
182            STOMP_ACCEPT_VERSION,
183            STOMP_HEARTBEAT
184        );
185
186        // Anything left in the buffer after the handshake belongs to the session — a broker is
187        // free to pipeline a MESSAGE immediately behind CONNECTED, and dropping the remainder
188        // would lose it.
189        let mut pending: Vec<u8> = Vec::new();
190        let reply = read_one_frame(
191            &mut read_half,
192            &mut pending,
193            Duration::from_secs(handshake_timeout_secs),
194        )
195        .await
196        .context("STOMP handshake failed")?;
197
198        let (version, session, server) = validate_connected(&reply)?;
199
200        info!(
201            "STOMP client {} connected to {} (version={}, session={}, server={})",
202            client_id,
203            peer_addr,
204            version,
205            if session.is_empty() {
206                "-"
207            } else {
208                session.as_str()
209            },
210            if server.is_empty() {
211                "-"
212            } else {
213                server.as_str()
214            }
215        );
216        app_state
217            .update_client_status(client_id, ClientStatus::Connected)
218            .await;
219        let _ = status_tx.send(format!(
220            "[CLIENT] STOMP client {client_id} connected to {peer_addr} (session {})",
221            if session.is_empty() {
222                "-"
223            } else {
224                session.as_str()
225            }
226        ));
227        let _ = status_tx.send("__UPDATE_UI__".to_string());
228
229        // Registered BEFORE the connected event is handled: a `manual` routing rule can park
230        // that event at the dashboard for minutes, and until registration the UI reports "no
231        // command channel" — which reads as a protocol limitation when it is only a queue.
232        let mut command_rx =
233            crate::client::command_support::register_command_channel(&app_state, client_id).await;
234
235        let protocol = StompClientProtocol::new();
236        let memory = Arc::new(Mutex::new(
237            app_state
238                .get_memory_for_client(client_id)
239                .await
240                .unwrap_or_default(),
241        ));
242
243        let mut disconnect_deadline: Option<tokio::time::Instant> = None;
244
245        let connected_event = Event::new(
246            &STOMP_CLIENT_CONNECTED_EVENT,
247            serde_json::json!({
248                "version": version,
249                "session": session,
250                "server": server,
251            }),
252        );
253        match report_event(
254            connected_event,
255            &protocol,
256            &write_half,
257            &llm_client,
258            &app_state,
259            &status_tx,
260            client_id,
261            &memory,
262        )
263        .await
264        {
265            Flow::Close => {
266                // Nothing to unwind: the loop has not started, so close here and report the
267                // session as over rather than spawning a task with nothing to do.
268                let _ = write_half.lock().await.shutdown().await;
269                app_state.remove_client_handle(client_id).await;
270                app_state
271                    .update_client_status(client_id, ClientStatus::Disconnected)
272                    .await;
273                let _ = status_tx.send("__UPDATE_UI__".to_string());
274                return Ok(local_addr);
275            }
276            Flow::DisconnectSent => {
277                disconnect_deadline = Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
278            }
279            Flow::Continue => {}
280        }
281
282        // Registered with AppState so stop_client can abort it — dropping a JoinHandle only
283        // detaches the task in Tokio, and an un-aborted read loop keeps the socket alive after
284        // the client has been removed.
285        let task_registrar = app_state.clone();
286        let task_handle = tokio::spawn(async move {
287            run_session(
288                read_half,
289                write_half,
290                pending,
291                &mut command_rx,
292                disconnect_deadline,
293                protocol,
294                llm_client,
295                app_state.clone(),
296                status_tx.clone(),
297                client_id,
298                memory,
299            )
300            .await;
301
302            // Every exit lands here. The loop owns the only receiver, so dropping the
303            // registered handle makes a later send_to_client fail fast instead of timing out.
304            app_state.remove_client_handle(client_id).await;
305            let _ = status_tx.send("__UPDATE_UI__".to_string());
306        });
307        task_registrar
308            .register_client_task(client_id, task_handle)
309            .await;
310
311        Ok(local_addr)
312    }
313}
314
315/// The read loop: parse frames, report each to the model, write whatever it answers with.
316#[allow(clippy::too_many_arguments)]
317async fn run_session(
318    mut read_half: tokio::io::ReadHalf<TcpStream>,
319    write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
320    mut pending: Vec<u8>,
321    command_rx: &mut mpsc::Receiver<crate::state::client_handles::ClientCommand>,
322    mut disconnect_deadline: Option<tokio::time::Instant>,
323    protocol: StompClientProtocol,
324    llm_client: OllamaClient,
325    app_state: Arc<AppState>,
326    status_tx: mpsc::UnboundedSender<String>,
327    client_id: ClientId,
328    memory: Arc<Mutex<String>>,
329) {
330    let mut read_buf = vec![0u8; 8192];
331
332    'session: loop {
333        // Drain every complete frame already buffered before asking the socket for more: a
334        // single read routinely carries several, and a broker that pipelines must not have its
335        // later frames wait for its next write.
336        loop {
337            let outcome = match frame::parse_frame(&pending) {
338                Ok(outcome) => outcome,
339                Err(e) => {
340                    // STOMP cannot resynchronise a stream once framing is lost, so this is
341                    // fatal for the connection rather than something to skip past.
342                    error!("STOMP client {client_id} could not parse the broker's frame: {e}");
343                    let _ = status_tx.send(format!(
344                        "[CLIENT] ✖ STOMP client {client_id} framing error: {e}"
345                    ));
346                    break 'session;
347                }
348            };
349            match outcome {
350                ParseOutcome::Incomplete => break,
351                ParseOutcome::Heartbeat { consumed } => {
352                    trace!("STOMP client {client_id} drained {consumed} inter-frame EOL byte(s)");
353                    pending.drain(..consumed);
354                }
355                ParseOutcome::Frame { frame, consumed } => {
356                    pending.drain(..consumed);
357                    match handle_frame(
358                        frame,
359                        &protocol,
360                        &write_half,
361                        &llm_client,
362                        &app_state,
363                        &status_tx,
364                        client_id,
365                        &memory,
366                    )
367                    .await
368                    {
369                        Flow::Continue => {}
370                        Flow::DisconnectSent => {
371                            disconnect_deadline =
372                                Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
373                        }
374                        Flow::Close => break 'session,
375                    }
376                }
377            }
378        }
379
380        let grace = async {
381            match disconnect_deadline {
382                Some(deadline) => tokio::time::sleep_until(deadline).await,
383                None => std::future::pending::<()>().await,
384            }
385        };
386
387        tokio::select! {
388            // `AsyncReadExt::read` is cancellation-safe, so losing this branch to another arm
389            // does not drop bytes.
390            read = read_half.read(&mut read_buf) => match read {
391                Ok(0) => {
392                    info!("STOMP client {client_id} — broker closed the connection");
393                    break 'session;
394                }
395                Ok(n) => {
396                    trace!("STOMP client {client_id} read {n} byte(s)");
397                    pending.extend_from_slice(&read_buf[..n]);
398                }
399                Err(e) => {
400                    error!("STOMP client {client_id} read error: {e}");
401                    app_state
402                        .update_client_status(client_id, ClientStatus::Error(e.to_string()))
403                        .await;
404                    let _ = status_tx.send("__UPDATE_UI__".to_string());
405                    return;
406                }
407            },
408
409            Some(command) = command_rx.recv() => {
410                // Peeked before the command is moved into the generic arm, which writes the
411                // frame but cannot know what it means. `disconnect` produces a DISCONNECT
412                // frame rather than a socket close, so the loop has to start the grace timer
413                // itself — for an injected action exactly as for a model-produced one.
414                let is_disconnect = command
415                    .action
416                    .get("type")
417                    .and_then(|v| v.as_str())
418                    == Some("disconnect");
419
420                let should_break = crate::client::command_support::handle_stream_client_command(
421                    &protocol,
422                    &write_half,
423                    command,
424                    client_id,
425                    &app_state,
426                    &status_tx,
427                )
428                .await;
429
430                if is_disconnect {
431                    disconnect_deadline = Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
432                }
433                if should_break {
434                    break 'session;
435                }
436            }
437
438            _ = grace => {
439                warn!(
440                    "STOMP client {client_id} closing without the RECEIPT for its DISCONNECT \
441                     ({}s grace elapsed) — the broker acknowledged nothing",
442                    DISCONNECT_GRACE.as_secs()
443                );
444                break 'session;
445            }
446        }
447    }
448
449    let _ = write_half.lock().await.shutdown().await;
450    app_state
451        .update_client_status(client_id, ClientStatus::Disconnected)
452        .await;
453    let _ = status_tx.send(format!("[CLIENT] STOMP client {client_id} disconnected"));
454}
455
456/// Turn one inbound frame into an event, ask the model, and run its answer.
457#[allow(clippy::too_many_arguments)]
458async fn handle_frame(
459    frame: StompFrame,
460    protocol: &StompClientProtocol,
461    write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
462    llm_client: &OllamaClient,
463    app_state: &Arc<AppState>,
464    status_tx: &mpsc::UnboundedSender<String>,
465    client_id: ClientId,
466    memory: &Arc<Mutex<String>>,
467) -> Flow {
468    match frame.command.as_str() {
469        "MESSAGE" => {
470            let (body, body_encoding) = body_for_event(&frame.body);
471            let event = Event::new(
472                &STOMP_CLIENT_MESSAGE_RECEIVED_EVENT,
473                serde_json::json!({
474                    "destination": frame.header("destination").unwrap_or_default(),
475                    "message_id": frame.header("message-id").unwrap_or_default(),
476                    "subscription": frame.header("subscription").unwrap_or_default(),
477                    "headers": frame.headers_except(&["destination", "message-id", "subscription"]),
478                    "body": body,
479                    "body_encoding": body_encoding,
480                }),
481            );
482            debug!(
483                "STOMP client {client_id} received MESSAGE on {} (subscription {})",
484                frame.header("destination").unwrap_or("-"),
485                frame.header("subscription").unwrap_or("-")
486            );
487            report_event(
488                event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
489            )
490            .await
491        }
492
493        "RECEIPT" => {
494            let receipt_id = frame.header("receipt-id").unwrap_or_default().to_string();
495
496            // The acknowledgement of our own DISCONNECT ends the session. Reporting it to the
497            // model would spend a call on a connection whose answer could not be written
498            // anywhere; the log records it instead.
499            if receipt_id == DISCONNECT_RECEIPT_ID {
500                info!(
501                    "STOMP client {client_id} — broker acknowledged DISCONNECT, closing \
502                     gracefully"
503                );
504                return Flow::Close;
505            }
506
507            debug!("STOMP client {client_id} received RECEIPT {receipt_id}");
508            let event = Event::new(
509                &STOMP_CLIENT_RECEIPT_RECEIVED_EVENT,
510                serde_json::json!({ "receipt_id": receipt_id }),
511            );
512            report_event(
513                event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
514            )
515            .await
516        }
517
518        "ERROR" => {
519            let message = frame.header("message").unwrap_or_default().to_string();
520            let (body, body_encoding) = body_for_event(&frame.body);
521            warn!("STOMP client {client_id} received ERROR from the broker: {message}");
522            let _ = status_tx.send(format!(
523                "[CLIENT] ⚠ STOMP client {client_id} got an ERROR frame: {message}"
524            ));
525            let event = Event::new(
526                &STOMP_CLIENT_ERROR_RECEIVED_EVENT,
527                serde_json::json!({
528                    "message": message,
529                    "body": body,
530                    "body_encoding": body_encoding,
531                }),
532            );
533            // Reported so the model learns what happened and can record it, then closed
534            // regardless of what it answers: the specification has the broker close the
535            // connection immediately after an ERROR, so nothing written here would arrive.
536            let _ = report_event(
537                event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
538            )
539            .await;
540            Flow::Close
541        }
542
543        "CONNECTED" => {
544            // A second CONNECTED mid-session is not defined. Ignoring it is safer than
545            // re-running the handshake, and it is not fatal the way a framing error is.
546            warn!("STOMP client {client_id} ignoring an unexpected second CONNECTED frame");
547            Flow::Continue
548        }
549
550        other => {
551            warn!(
552                "STOMP client {client_id} ignoring a frame with unexpected command {other:?} — \
553                 a broker sends only MESSAGE, RECEIPT, ERROR and CONNECTED"
554            );
555            Flow::Continue
556        }
557    }
558}
559
560/// Ask the model about `event` and execute whatever it answers with.
561#[allow(clippy::too_many_arguments)]
562async fn report_event(
563    event: Event,
564    protocol: &StompClientProtocol,
565    write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
566    llm_client: &OllamaClient,
567    app_state: &Arc<AppState>,
568    status_tx: &mpsc::UnboundedSender<String>,
569    client_id: ClientId,
570    memory: &Arc<Mutex<String>>,
571) -> Flow {
572    let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
573        debug!(
574            "STOMP client {client_id} has no instruction; {} is not reported to the model",
575            event.event_type.id
576        );
577        return Flow::Continue;
578    };
579
580    // Copied out and the guard dropped before the call: never hold a lock across an await that
581    // performs I/O or an LLM call.
582    let memory_snapshot = memory.lock().await.clone();
583
584    match call_llm_for_client(
585        llm_client,
586        app_state,
587        client_id.to_string(),
588        &instruction,
589        &memory_snapshot,
590        Some(&event),
591        protocol,
592        status_tx,
593    )
594    .await
595    {
596        Ok(result) => {
597            if let Some(updated) = result.memory_updates {
598                *memory.lock().await = updated.clone();
599                app_state.set_memory_for_client(client_id, updated).await;
600            }
601            run_actions(
602                result.actions,
603                protocol,
604                write_half,
605                app_state,
606                status_tx,
607                client_id,
608            )
609            .await
610        }
611        Err(e) => {
612            // A STOMP client has no error frame to send: ERROR is a server frame, and inventing
613            // wire traffic because our own backend failed would tell the broker something
614            // untrue. The session stays open — the next delivery gets another chance — and the
615            // failure is recorded in both logs.
616            error!("STOMP client {client_id} LLM error on {}: {e}", event.id());
617            let _ = status_tx.send(format!(
618                "[CLIENT] ✖ STOMP client {client_id} could not answer {}: {e}",
619                event.id()
620            ));
621            Flow::Continue
622        }
623    }
624}
625
626/// Execute the model's actions in order, writing every frame they produce.
627async fn run_actions(
628    actions: Vec<serde_json::Value>,
629    protocol: &StompClientProtocol,
630    write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
631    app_state: &Arc<AppState>,
632    status_tx: &mpsc::UnboundedSender<String>,
633    client_id: ClientId,
634) -> Flow {
635    let mut flow = Flow::Continue;
636
637    for action in actions {
638        // `disconnect` writes a DISCONNECT frame rather than closing, so the loop learns from
639        // the action's own name that a graceful shutdown is in progress.
640        let is_disconnect = action.get("type").and_then(|v| v.as_str()) == Some("disconnect");
641
642        let result = match protocol.execute_action(action.clone()) {
643            Ok(result) => result,
644            Err(e) => {
645                warn!("STOMP client {client_id} rejected an action from the model: {e}");
646                let _ = status_tx.send(format!(
647                    "[CLIENT] ⚠ STOMP client {client_id} rejected an action: {e}"
648                ));
649                continue;
650            }
651        };
652
653        for bytes in result.get_all_data() {
654            let mut guard = write_half.lock().await;
655            if let Err(e) = guard.write_all(&bytes).await {
656                error!("STOMP client {client_id} write failed: {e}");
657                return Flow::Close;
658            }
659            if let Err(e) = guard.flush().await {
660                error!("STOMP client {client_id} flush failed: {e}");
661                return Flow::Close;
662            }
663            drop(guard);
664            trace!("STOMP client {client_id} wrote {} byte(s)", bytes.len());
665        }
666
667        if result.disconnects() {
668            // Nothing in this protocol's vocabulary produces this today; it is here so a
669            // future action that hangs up is honoured rather than silently ignored.
670            app_state
671                .update_client_status(client_id, ClientStatus::Disconnected)
672                .await;
673            return Flow::Close;
674        }
675        if is_disconnect {
676            flow = Flow::DisconnectSent;
677        }
678    }
679
680    flow
681}
682
683/// Build the opening `CONNECT` (or `STOMP`) frame.
684///
685/// `heart-beat` is always `0,0`: this client runs no heart-beat timer, and a broker told
686/// otherwise tears the connection down when the promised bare EOLs do not arrive.
687fn build_connect_frame(
688    use_stomp_command: bool,
689    host: &str,
690    login: Option<&str>,
691    passcode: Option<&str>,
692) -> Vec<u8> {
693    let mut headers = vec![
694        (
695            "accept-version".to_string(),
696            STOMP_ACCEPT_VERSION.to_string(),
697        ),
698        ("host".to_string(), host.to_string()),
699        ("heart-beat".to_string(), STOMP_HEARTBEAT.to_string()),
700    ];
701    if let Some(login) = login {
702        headers.push(("login".to_string(), login.to_string()));
703    }
704    if let Some(passcode) = passcode {
705        headers.push(("passcode".to_string(), passcode.to_string()));
706    }
707    let command = if use_stomp_command {
708        "STOMP"
709    } else {
710        "CONNECT"
711    };
712    StompFrame::new(command, headers, Vec::new()).encode()
713}
714
715/// Check the broker's reply to `CONNECT`, returning `(version, session, server)`.
716///
717/// Every failure here is a refusal to proceed. A client that treats a missing `version` as
718/// "probably fine" has no idea which dialect the following frames are in, and STOMP 1.1 and
719/// 1.2 disagree about the very headers this client's `ACK` uses.
720fn validate_connected(reply: &StompFrame) -> Result<(String, String, String)> {
721    match reply.command.as_str() {
722        "CONNECTED" => {}
723        "ERROR" => {
724            let message = reply.header("message").unwrap_or("(no message header)");
725            bail!(
726                "the broker refused the session with an ERROR frame: {message}. This client \
727                 offers accept-version {STOMP_ACCEPT_VERSION} and heart-beat \
728                 {STOMP_HEARTBEAT}."
729            );
730        }
731        other => bail!(
732            "expected CONNECTED in reply to CONNECT, got a {other:?} frame. The session is not \
733             open and no frame will be sent on it."
734        ),
735    }
736
737    let Some(version) = reply.header("version") else {
738        bail!(
739            "the broker's CONNECTED frame carries no 'version' header, which STOMP 1.2 \
740             requires. Without it there is no way to know which dialect the following frames \
741             are in, so the session is refused rather than guessed at."
742        );
743    };
744    if version != STOMP_ACCEPT_VERSION {
745        bail!(
746            "the broker chose STOMP version {version:?}, which this client did not offer — its \
747             accept-version was {STOMP_ACCEPT_VERSION:?} and it implements nothing else. \
748             Speaking a dialect we do not implement would corrupt the session rather than \
749             degrade it."
750        );
751    }
752
753    Ok((
754        version.to_string(),
755        reply.header("session").unwrap_or_default().to_string(),
756        reply.header("server").unwrap_or_default().to_string(),
757    ))
758}
759
760/// Read frames into `pending` until one parses, or the deadline passes.
761///
762/// Used for the handshake only. Bytes past the frame stay in `pending` — a broker may pipeline
763/// a `MESSAGE` immediately behind `CONNECTED`, and discarding the remainder would lose it.
764async fn read_one_frame<R>(
765    reader: &mut R,
766    pending: &mut Vec<u8>,
767    timeout: Duration,
768) -> Result<StompFrame>
769where
770    R: tokio::io::AsyncRead + Unpin,
771{
772    let deadline = tokio::time::Instant::now() + timeout;
773    let mut buf = vec![0u8; 4096];
774
775    loop {
776        match frame::parse_frame(pending).map_err(|e| anyhow!("{e}"))? {
777            ParseOutcome::Frame { frame, consumed } => {
778                pending.drain(..consumed);
779                return Ok(frame);
780            }
781            ParseOutcome::Heartbeat { consumed } => {
782                pending.drain(..consumed);
783                continue;
784            }
785            ParseOutcome::Incomplete => {}
786        }
787
788        let n = tokio::time::timeout_at(deadline, reader.read(&mut buf))
789            .await
790            .map_err(|_| {
791                anyhow!(
792                    "the broker sent no complete frame within {}s ({} byte(s) buffered)",
793                    timeout.as_secs(),
794                    pending.len()
795                )
796            })?
797            .context("read failed while waiting for the broker's reply")?;
798        if n == 0 {
799            bail!(
800                "the broker closed the connection without replying ({} byte(s) buffered)",
801                pending.len()
802            );
803        }
804        pending.extend_from_slice(&buf[..n]);
805    }
806}
807
808/// The host part of `host:port`, which is what a STOMP `host` header defaults to.
809///
810/// Bracketed IPv6 literals keep their brackets stripped rather than being split on the wrong
811/// colon; anything unrecognisable is returned whole, because a wrong-but-present `host` header
812/// is better than an absent one that makes the broker reject the frame outright.
813fn host_of(remote_addr: &str) -> String {
814    let trimmed = remote_addr.trim();
815    if let Some(rest) = trimmed.strip_prefix('[') {
816        if let Some(end) = rest.find(']') {
817            return rest[..end].to_string();
818        }
819    }
820    match trimmed.rsplit_once(':') {
821        Some((host, _port)) if !host.is_empty() => host.to_string(),
822        _ => trimmed.to_string(),
823    }
824}
825
826/// Render a frame body for an event: text when it is printable, hex when it is not.
827///
828/// The `body_encoding` field says which, so nothing downstream has to guess — and passing the
829/// pair straight into `send_stomp_send` reproduces the exact bytes. This mirrors the server's
830/// own `body_for_event`, which is a private function there and so cannot be shared.
831fn body_for_event(body: &[u8]) -> (String, &'static str) {
832    if body
833        .iter()
834        .all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace())
835    {
836        (String::from_utf8_lossy(body).to_string(), "utf8")
837    } else {
838        (hex::encode(body), "hex")
839    }
840}