Skip to main content

netget/client/websocket/
mod.rs

1//! WebSocket (RFC 6455) client.
2//!
3//! `tokio-tungstenite`'s `connect_async` performs the whole HTTP Upgrade handshake, including
4//! generating the 16-byte `Sec-WebSocket-Key` nonce and verifying the server's
5//! `Sec-WebSocket-Accept`, and its `Role::Client` framing masks every outgoing frame with a
6//! fresh 32-bit key as RFC 6455 §5.3 requires. What is written here is the part NetGet owns:
7//! turning the offered subprotocols and extra headers into the request, and turning received
8//! frames into events the model answers with actions.
9//!
10//! ws:// only. The `tokio-tungstenite` dependency is built without a TLS backend, so wss://
11//! is refused with a clear error rather than silently downgraded.
12
13pub mod actions;
14
15pub use actions::WebSocketClientProtocol;
16
17use anyhow::{Context, Result};
18use futures::stream::StreamExt;
19use futures::SinkExt;
20use std::collections::VecDeque;
21use std::net::SocketAddr;
22use std::sync::Arc;
23use tokio::sync::{mpsc, Mutex};
24use tokio_tungstenite::tungstenite::client::IntoClientRequest;
25use tokio_tungstenite::tungstenite::http::{HeaderName, HeaderValue};
26use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
27use tokio_tungstenite::tungstenite::protocol::CloseFrame;
28use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
29use tokio_tungstenite::tungstenite::Message;
30use tracing::{debug, error, info, trace, warn};
31
32use crate::client::llm_budget::call_llm_for_client;
33use crate::llm::actions::client_trait::{Client, ClientActionResult};
34use crate::llm::ollama_client::OllamaClient;
35use crate::llm::ClientLlmResult;
36use crate::protocol::Event;
37use crate::server::websocket::actions::{encode_inbound_payload, WsOut};
38use crate::state::app_state::AppState;
39use crate::state::{ClientId, ClientStatus};
40use actions::{
41    WEBSOCKET_CLIENT_BINARY_MESSAGE_EVENT, WEBSOCKET_CLIENT_CLOSED_EVENT,
42    WEBSOCKET_CLIENT_CONNECTED_EVENT, WEBSOCKET_CLIENT_TEXT_MESSAGE_EVENT,
43};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum HandlerState {
47    Idle,
48    Processing,
49    Accumulating,
50}
51
52#[derive(Debug, Clone)]
53enum Inbound {
54    Text(String),
55    Binary(Vec<u8>),
56}
57
58impl Inbound {
59    fn merge(self, next: Inbound) -> std::result::Result<Inbound, (Inbound, Inbound)> {
60        match (self, next) {
61            (Inbound::Text(mut a), Inbound::Text(b)) => {
62                a.push_str(&b);
63                Ok(Inbound::Text(a))
64            }
65            (Inbound::Binary(mut a), Inbound::Binary(b)) => {
66                a.extend_from_slice(&b);
67                Ok(Inbound::Binary(a))
68            }
69            (a, b) => Err((a, b)),
70        }
71    }
72}
73
74impl Inbound {
75    fn len(&self) -> usize {
76        match self {
77            Inbound::Text(t) => t.len(),
78            Inbound::Binary(b) => b.len(),
79        }
80    }
81}
82
83/// How many messages may wait for the model while a turn is running.
84///
85/// The read loop must never block on the model — that is the whole point of running it
86/// separately, since a read loop that waits stops answering Pings — so a message that does not
87/// fit is dropped and logged with `decision=turn_queue_full` rather than stalling the socket.
88/// 256 unanswered messages is already many minutes of model time.
89const TURN_QUEUE_CAPACITY: usize = 256;
90
91/// Largest message accepted from the server, after inflation when permessage-deflate is on:
92/// tungstenite's own default, which this client used before it dialled its own socket.
93const MAX_INBOUND_MESSAGE_BYTES: usize = 64 << 20;
94/// Largest single frame accepted: tungstenite's own default.
95const MAX_INBOUND_FRAME_BYTES: usize = 16 << 20;
96
97/// Whether the client offers RFC 7692 permessage-deflate (`permessage_deflate`).
98pub const DEFAULT_PERMESSAGE_DEFLATE: bool = true;
99
100/// The queued payload bytes allowed at once, alongside [`TURN_QUEUE_CAPACITY`].
101///
102/// The count alone is not a memory bound: tungstenite's default message limit is 64 MiB, so
103/// 256 of them would be 16 GiB. 16 MiB caps what a flooding server can pin per client while a
104/// turn is parked; one message larger than that is still accepted when the queue is empty,
105/// because refusing it outright would make the limit a second, undeclared max message size.
106const TURN_QUEUE_MAX_BYTES: usize = 16 * 1024 * 1024;
107
108/// One thing the model is asked about, in arrival order.
109enum WsTurn {
110    Connected {
111        remote_addr: String,
112        path: String,
113        subprotocol: String,
114        permessage_deflate: Option<crate::server::websocket::deflate::DeflateParams>,
115    },
116    Message(Inbound),
117    Closed {
118        code: u16,
119        reason: String,
120    },
121}
122
123/// Put a message on the turn queue without ever waiting for room.
124fn enqueue_inbound(
125    turn_tx: &mpsc::Sender<WsTurn>,
126    queued_bytes: &std::sync::atomic::AtomicUsize,
127    frame: Inbound,
128    client_id: ClientId,
129) {
130    use std::sync::atomic::Ordering;
131    let len = frame.len();
132    let already = queued_bytes.load(Ordering::SeqCst);
133    if already > 0 && already.saturating_add(len) > TURN_QUEUE_MAX_BYTES {
134        warn!(
135            "WebSocket client {} dropped a {}-byte message: {} bytes already wait for the model \
136             decision=turn_queue_full",
137            client_id, len, already
138        );
139        return;
140    }
141    queued_bytes.fetch_add(len, Ordering::SeqCst);
142    match turn_tx.try_send(WsTurn::Message(frame)) {
143        Ok(()) => {}
144        Err(mpsc::error::TrySendError::Full(_)) => {
145            queued_bytes.fetch_sub(len, Ordering::SeqCst);
146            warn!(
147                "WebSocket client {} dropped a {}-byte message: {} messages already wait for \
148                 the model decision=turn_queue_full",
149                client_id, len, TURN_QUEUE_CAPACITY
150            );
151        }
152        // The turn task has ended (aborted with the client); nothing to answer.
153        Err(mpsc::error::TrySendError::Closed(_)) => {
154            queued_bytes.fetch_sub(len, Ordering::SeqCst);
155        }
156    }
157}
158
159struct ClientData {
160    state: HandlerState,
161    queued: VecDeque<Inbound>,
162    pending: Option<Inbound>,
163    memory: String,
164}
165
166pub struct WebSocketClient;
167
168impl WebSocketClient {
169    /// Connect, run the `websocket_client_connected` event, and start the read loop.
170    pub async fn connect_with_llm_actions(
171        ctx: crate::protocol::ConnectContext,
172    ) -> Result<SocketAddr> {
173        let crate::protocol::ConnectContext {
174            remote_addr,
175            llm_client,
176            state: app_state,
177            status_tx,
178            client_id,
179            startup_params,
180        } = ctx;
181
182        // Every parameter read here is declared in `get_startup_parameters()`.
183        let offer_deflate = match startup_params.as_ref() {
184            Some(params) => params
185                .get_optional_bool("permessage_deflate")
186                .map_err(|e| anyhow::anyhow!("WebSocket client parameter error: {e}"))?
187                .unwrap_or(DEFAULT_PERMESSAGE_DEFLATE),
188            None => DEFAULT_PERMESSAGE_DEFLATE,
189        };
190        let (path, subprotocols, extra_headers) = match startup_params.as_ref() {
191            Some(params) => {
192                let path = params
193                    .get_optional_string("path")
194                    .map_err(|e| anyhow::anyhow!("WebSocket client parameter error: {e}"))?
195                    .unwrap_or_else(|| "/".to_string());
196                let subprotocols: Vec<String> = params
197                    .get_optional_array("subprotocols")
198                    .map_err(|e| anyhow::anyhow!("WebSocket client parameter error: {e}"))?
199                    .map(|arr| {
200                        arr.iter()
201                            .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
202                            .filter(|s| !s.is_empty())
203                            .collect()
204                    })
205                    .unwrap_or_default();
206                let headers = params
207                    .get_optional_object("headers")
208                    .map_err(|e| anyhow::anyhow!("WebSocket client parameter error: {e}"))?
209                    .map(|obj| {
210                        obj.iter()
211                            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
212                            .collect::<Vec<_>>()
213                    })
214                    .unwrap_or_default();
215                (path, subprotocols, headers)
216            }
217            None => ("/".to_string(), Vec::new(), Vec::new()),
218        };
219
220        let path = if path.starts_with('/') {
221            path
222        } else {
223            format!("/{path}")
224        };
225
226        // `remote_addr` may already be a URL (the TUI accepts either); normalise both shapes.
227        let url = if remote_addr.starts_with("ws://") {
228            remote_addr.clone()
229        } else if remote_addr.starts_with("wss://") || remote_addr.starts_with("https://") {
230            anyhow::bail!(
231                "wss:// is not supported: the WebSocket client is built without a TLS backend. \
232                 Use ws:// (optionally behind a local TLS terminator)."
233            );
234        } else {
235            let host = remote_addr
236                .trim_start_matches("http://")
237                .trim_end_matches('/');
238            format!("ws://{host}{path}")
239        };
240
241        let mut request = url
242            .as_str()
243            .into_client_request()
244            .with_context(|| format!("Invalid WebSocket URL: {url}"))?;
245
246        if !subprotocols.is_empty() {
247            let value = subprotocols.join(", ");
248            request.headers_mut().insert(
249                "sec-websocket-protocol",
250                HeaderValue::from_str(&value)
251                    .with_context(|| format!("Invalid subprotocol list: {value:?}"))?,
252            );
253        }
254        for (name, value) in &extra_headers {
255            let header_name = HeaderName::from_bytes(name.to_ascii_lowercase().as_bytes())
256                .with_context(|| format!("Invalid header name: {name:?}"))?;
257            // The handshake headers are computed by the library; silently letting a caller
258            // overwrite Sec-WebSocket-Key would break the accept-key check it then performs.
259            if matches!(
260                header_name.as_str(),
261                "sec-websocket-key"
262                    | "sec-websocket-version"
263                    | "sec-websocket-accept"
264                    | "connection"
265                    | "upgrade"
266                    | "host"
267            ) {
268                warn!(
269                    "WebSocket client ignoring handshake header {:?} supplied in 'headers'",
270                    name
271                );
272                continue;
273            }
274            request.headers_mut().insert(
275                header_name,
276                HeaderValue::from_str(value)
277                    .with_context(|| format!("Invalid value for header {name:?}"))?,
278            );
279        }
280
281        if offer_deflate {
282            request.headers_mut().insert(
283                "sec-websocket-extensions",
284                HeaderValue::from_static(crate::server::websocket::deflate::CLIENT_OFFER),
285            );
286        }
287
288        // The socket is dialled here rather than by `connect_async` so the RFC 7692 layer can
289        // sit beneath tungstenite: it reads the 101 itself and switches to frame mode at the
290        // exact byte the response head ends.
291        let authority = request
292            .uri()
293            .authority()
294            .map(|a| a.to_string())
295            .with_context(|| format!("WebSocket URL has no host: {url}"))?;
296        let dial = if authority.contains(':') {
297            authority
298        } else {
299            format!("{authority}:80")
300        };
301        let tcp = tokio::net::TcpStream::connect(&dial)
302            .await
303            .with_context(|| format!("WebSocket TCP connect to {dial} failed"))?;
304        let local_addr = tcp.local_addr()?;
305        let config = WebSocketConfig {
306            max_message_size: Some(MAX_INBOUND_MESSAGE_BYTES),
307            max_frame_size: Some(MAX_INBOUND_FRAME_BYTES),
308            ..Default::default()
309        };
310        let stream = crate::server::websocket::deflate::DeflateStream::client(
311            tcp,
312            MAX_INBOUND_MESSAGE_BYTES,
313            MAX_INBOUND_FRAME_BYTES,
314        );
315        let (ws_stream, response) =
316            tokio_tungstenite::client_async_with_config(request, stream, Some(config))
317                .await
318                .with_context(|| format!("WebSocket handshake failed against {url}"))?;
319        let permessage_deflate = ws_stream.get_ref().active();
320
321        let negotiated = response
322            .headers()
323            .get("sec-websocket-protocol")
324            .and_then(|v| v.to_str().ok())
325            .map(|s| s.trim().to_string())
326            .unwrap_or_default();
327
328        info!(
329            "WebSocket client {} connected to {} (subprotocol {:?}, permessage-deflate {})",
330            client_id,
331            url,
332            negotiated,
333            permessage_deflate.is_some()
334        );
335        app_state
336            .update_client_status(client_id, ClientStatus::Connected)
337            .await;
338        let _ = status_tx.send(format!(
339            "[CLIENT] WebSocket client {client_id} connected to {url}"
340        ));
341        let _ = status_tx.send("__UPDATE_UI__".to_string());
342
343        let (sink, mut stream) = ws_stream.split();
344        // The sink is shared rather than owned by the writer task: the injected-command loop
345        // writes through it too, and it needs `send`'s own result to report a truthful
346        // outcome instead of "queued somewhere". A frame is written under the lock, so the
347        // two producers can never interleave halves of a frame.
348        let sink = Arc::new(Mutex::new(sink));
349        let (out_tx, mut out_rx) = mpsc::unbounded_channel::<WsOut>();
350
351        // One writer for the connection, so frames produced by different handlers cannot
352        // interleave and no lock is held across an LLM call.
353        let writer_sink = sink.clone();
354        let writer_handle = tokio::spawn(async move {
355            while let Some(out) = out_rx.recv().await {
356                let closing = matches!(out, WsOut::Close { .. });
357                let message = ws_out_to_message(out);
358                let mut guard = writer_sink.lock().await;
359                if let Err(e) = guard.send(message).await {
360                    debug!("WebSocket client write failed: {}", e);
361                    break;
362                }
363                if closing {
364                    let _ = guard.flush().await;
365                    break;
366                }
367            }
368            let _ = writer_sink.lock().await.flush().await;
369        });
370
371        let data = Arc::new(Mutex::new(ClientData {
372            state: HandlerState::Idle,
373            queued: VecDeque::new(),
374            pending: None,
375            memory: String::new(),
376        }));
377
378        // Command channel for injected actions (the dashboard's [ send ]). Registered BEFORE
379        // the connected-event LLM call below, which a manual `*` rule can park for minutes —
380        // the operator has to be able to push a frame out while it waits.
381        let command_rx =
382            crate::client::command_support::register_command_channel(&app_state, client_id).await;
383        let cmd_sink = sink.clone();
384        let cmd_state = app_state.clone();
385        let cmd_status_tx = status_tx.clone();
386        let cmd_task = tokio::spawn(async move {
387            Self::command_loop(command_rx, cmd_sink, client_id, cmd_state, cmd_status_tx).await;
388        });
389        app_state.register_client_task(client_id, cmd_task).await;
390
391        // Two tasks per connection, because the socket and the model run on different clocks.
392        // A model turn can take minutes — a `manual` rule parks it for a human for up to 300s —
393        // and tungstenite answers a Ping only when the stream is polled: the Pong it queues on
394        // reading a Ping is flushed by the *next* read. A read loop that awaited the model
395        // stopped doing that, and a server with a liveness bound (NetGet's own closes a peer
396        // that sends no frame, not even a Pong, for `idle_timeout_secs`) hung up on it.
397        //
398        // So the read loop only reads: it answers Pings by polling, and hands every message to
399        // the turn task over a queue. The turn task answers them one at a time, in order —
400        // `websocket_client_connected` first — which is also what keeps two model turns from
401        // ever overlapping on this connection.
402        let (turn_tx, turn_rx) = mpsc::channel::<WsTurn>(TURN_QUEUE_CAPACITY);
403        let queued_bytes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
404        // Queued before either task starts, so it is always the first turn.
405        let _ = turn_tx.try_send(WsTurn::Connected {
406            remote_addr: remote_addr.clone(),
407            path: path.clone(),
408            subprotocol: negotiated.clone(),
409            permessage_deflate,
410        });
411
412        let turn_abort = app_state
413            .spawn_client_task(
414                client_id,
415                Self::run_model_turns(
416                    turn_rx,
417                    client_id,
418                    llm_client,
419                    app_state.clone(),
420                    status_tx.clone(),
421                    out_tx.clone(),
422                    data,
423                    queued_bytes.clone(),
424                ),
425            )
426            .await;
427
428        let read_state = app_state.clone();
429        let read_status_tx = status_tx.clone();
430        app_state
431            .spawn_client_task(client_id, async move {
432                let mut clean_close = false;
433                while let Some(message) = stream.next().await {
434                    match message {
435                        Ok(Message::Text(text)) => {
436                            trace!("WebSocket client {} <- text {:?}", client_id, text);
437                            enqueue_inbound(
438                                &turn_tx,
439                                &queued_bytes,
440                                Inbound::Text(text),
441                                client_id,
442                            );
443                        }
444                        Ok(Message::Binary(bytes)) => {
445                            trace!(
446                                "WebSocket client {} <- binary {} bytes: {}",
447                                client_id,
448                                bytes.len(),
449                                hex::encode(&bytes)
450                            );
451                            enqueue_inbound(
452                                &turn_tx,
453                                &queued_bytes,
454                                Inbound::Binary(bytes),
455                                client_id,
456                            );
457                        }
458                        Ok(Message::Ping(_)) => {
459                            // tungstenite queued the Pong; the next `stream.next()` flushes it.
460                            debug!("WebSocket client {} <- ping (pong queued)", client_id);
461                        }
462                        Ok(Message::Pong(_)) => {
463                            debug!("WebSocket client {} <- pong", client_id);
464                        }
465                        Ok(Message::Close(frame)) => {
466                            let (code, reason) = match &frame {
467                                Some(f) => (u16::from(f.code), f.reason.to_string()),
468                                None => (1005u16, String::new()),
469                            };
470                            info!(
471                                "WebSocket client {} closed by server: code={} reason={:?}",
472                                client_id, code, reason
473                            );
474                            // Behind whatever is already queued, so the model hears about the
475                            // close after the messages that preceded it.
476                            if turn_tx.try_send(WsTurn::Closed { code, reason }).is_err() {
477                                warn!(
478                                    "WebSocket client {} could not queue its close event \
479                                     decision=turn_queue_full",
480                                    client_id
481                                );
482                            }
483                            clean_close = true;
484                            break;
485                        }
486                        Ok(Message::Frame(_)) => {}
487                        Err(e) => {
488                            debug!("WebSocket client {} read ended: {}", client_id, e);
489                            break;
490                        }
491                    }
492                }
493
494                if !clean_close {
495                    // The socket died without a Close: there is no event to report, and a turn
496                    // still running — or parked for a human — has nothing left to answer on.
497                    turn_abort.abort();
498                }
499                read_state
500                    .update_client_status(client_id, ClientStatus::Disconnected)
501                    .await;
502                // Every exit path lands here: drop the command handle so the dashboard stops
503                // offering [ send ] on a dead connection, and so the command loop's channel
504                // closes and its task ends.
505                read_state.remove_client_handle(client_id).await;
506                let _ =
507                    read_status_tx.send(format!("[CLIENT] WebSocket client {client_id} closed"));
508                let _ = read_status_tx.send("__UPDATE_UI__".to_string());
509            })
510            .await;
511
512        // The writer ends when every sender is dropped, which happens when the turn task and
513        // its clones go away. Nothing waits on it here so `connect` can return promptly.
514        drop(writer_handle);
515        drop(out_tx);
516
517        Ok(local_addr)
518    }
519
520    /// Answer queued messages with the model, one at a time and in arrival order.
521    #[allow(clippy::too_many_arguments)]
522    async fn run_model_turns(
523        mut turn_rx: mpsc::Receiver<WsTurn>,
524        client_id: ClientId,
525        llm_client: OllamaClient,
526        app_state: Arc<AppState>,
527        status_tx: mpsc::UnboundedSender<String>,
528        out_tx: mpsc::UnboundedSender<WsOut>,
529        data: Arc<Mutex<ClientData>>,
530        queued_bytes: Arc<std::sync::atomic::AtomicUsize>,
531    ) {
532        while let Some(turn) = turn_rx.recv().await {
533            match turn {
534                WsTurn::Connected {
535                    remote_addr,
536                    path,
537                    subprotocol,
538                    permessage_deflate,
539                } => {
540                    Self::emit_connected(
541                        client_id,
542                        remote_addr,
543                        path,
544                        subprotocol,
545                        permessage_deflate,
546                        &llm_client,
547                        &app_state,
548                        &status_tx,
549                        &out_tx,
550                        &data,
551                    )
552                    .await;
553                }
554                WsTurn::Message(frame) => {
555                    queued_bytes.fetch_sub(frame.len(), std::sync::atomic::Ordering::SeqCst);
556                    Self::handle_inbound(
557                        frame,
558                        client_id,
559                        &llm_client,
560                        &app_state,
561                        &status_tx,
562                        &out_tx,
563                        &data,
564                    )
565                    .await;
566                }
567                WsTurn::Closed { code, reason } => {
568                    Self::emit_closed(
569                        client_id,
570                        code,
571                        reason,
572                        &llm_client,
573                        &app_state,
574                        &status_tx,
575                        &out_tx,
576                        &data,
577                    )
578                    .await;
579                    break;
580                }
581            }
582        }
583    }
584
585    /// `websocket_client_connected` — the client's chance to speak first.
586    #[allow(clippy::too_many_arguments)]
587    async fn emit_connected(
588        client_id: ClientId,
589        remote_addr: String,
590        path: String,
591        subprotocol: String,
592        permessage_deflate: Option<crate::server::websocket::deflate::DeflateParams>,
593        llm_client: &OllamaClient,
594        app_state: &Arc<AppState>,
595        status_tx: &mpsc::UnboundedSender<String>,
596        out_tx: &mpsc::UnboundedSender<WsOut>,
597        data: &Arc<Mutex<ClientData>>,
598    ) {
599        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
600            return;
601        };
602        let event = Event::new(
603            &WEBSOCKET_CLIENT_CONNECTED_EVENT,
604            serde_json::json!({
605                "remote_addr": remote_addr,
606                "path": path,
607                "subprotocol": subprotocol,
608                "permessage_deflate": permessage_deflate.map(|p| p.to_json()),
609            }),
610        );
611        let protocol = WebSocketClientProtocol::for_connection(out_tx.clone());
612        let memory = { data.lock().await.memory.clone() };
613        match call_llm_for_client(
614            llm_client,
615            app_state,
616            client_id.to_string(),
617            &instruction,
618            &memory,
619            Some(&event),
620            &protocol,
621            status_tx,
622        )
623        .await
624        {
625            Ok(ClientLlmResult {
626                actions,
627                memory_updates,
628            }) => {
629                if let Some(mem) = memory_updates {
630                    data.lock().await.memory = mem;
631                }
632                for action in actions {
633                    if let Err(e) = protocol.execute_action(action) {
634                        error!("WebSocket client action failed after connect: {}", e);
635                        let _ = status_tx.send(format!("[CLIENT] WebSocket action failed: {e}"));
636                    }
637                }
638            }
639            Err(e) => {
640                error!("LLM error on websocket_client_connected: {}", e);
641                let _ = status_tx.send(format!("[CLIENT] WebSocket LLM error: {e}"));
642            }
643        }
644    }
645
646    /// Drain injected commands until the channel closes (the client was removed, or the read
647    /// loop exited) or an injected close/disconnect ends the session.
648    ///
649    /// The generic `command_support::handle_stream_client_command` cannot serve this client:
650    /// its write half is a `Sink<Message>`, not an `AsyncWrite`, and every wire verb yields
651    /// `ClientActionResult::Custom`. So the action is executed through the protocol's **own**
652    /// `execute_action` — the same code the LLM path runs, including the RFC 6455 validation
653    /// of close codes and ping payload lengths — against a private frame channel that this
654    /// loop then drains and writes itself. That is what makes a truthful byte count possible:
655    /// the frames are written and flushed here, not handed to another task.
656    async fn command_loop<S>(
657        mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
658        sink: Arc<Mutex<S>>,
659        client_id: ClientId,
660        app_state: Arc<AppState>,
661        status_tx: mpsc::UnboundedSender<String>,
662    ) where
663        S: futures::Sink<Message> + Unpin + Send,
664        S::Error: std::fmt::Display,
665    {
666        use crate::llm::actions::protocol_trait::Protocol;
667        use crate::state::client_handles::ClientSendOutcome;
668        use crate::state::AccessLogOwner;
669
670        // A private frame channel, drained synchronously below, so `execute_action` produces
671        // exactly the frames this command asked for and nothing else can be attributed to it.
672        let (frame_tx, mut frame_rx) = mpsc::unbounded_channel::<WsOut>();
673        let protocol = WebSocketClientProtocol::for_connection(frame_tx);
674
675        while let Some(command) = command_rx.recv().await {
676            let action = command.action.clone();
677            let executed = protocol.execute_action(action.clone());
678
679            // Whatever `execute_action` queued belongs to this command.
680            let mut frames: Vec<WsOut> = Vec::new();
681            while let Ok(frame) = frame_rx.try_recv() {
682                frames.push(frame);
683            }
684
685            let mut bytes_sent = 0usize;
686            let mut write_error: Option<String> = None;
687            for frame in frames {
688                // Payload bytes: the RFC 6455 header and the client mask are added by the
689                // framing layer and are not reported here.
690                let payload_len = match &frame {
691                    WsOut::Text(t) => t.len(),
692                    WsOut::Binary(b) => b.len(),
693                    WsOut::Ping(p) => p.len(),
694                    WsOut::Close { reason, .. } => reason.len(),
695                };
696                let message = ws_out_to_message(frame);
697                let mut guard = sink.lock().await;
698                match guard.send(message).await {
699                    // `SinkExt::send` flushes, so the bytes really are on the socket.
700                    Ok(()) => bytes_sent += payload_len,
701                    Err(e) => {
702                        write_error = Some(e.to_string());
703                        break;
704                    }
705                }
706            }
707
708            let outcome: anyhow::Result<ClientSendOutcome> = match (&executed, &write_error) {
709                (_, Some(e)) => Err(anyhow::anyhow!("WebSocket write failed: {e}")),
710                (Err(e), None) => Ok(ClientSendOutcome::Rejected {
711                    error: e.to_string(),
712                }),
713                (Ok(ClientActionResult::Disconnect), None) => Ok(ClientSendOutcome::Disconnected),
714                (Ok(ClientActionResult::WaitForMore), None) => Ok(ClientSendOutcome::Executed {
715                    detail: "wait_for_websocket_data: nothing written".to_string(),
716                }),
717                (Ok(_), None) => {
718                    if bytes_sent > 0 {
719                        Ok(ClientSendOutcome::Sent { bytes_sent })
720                    } else {
721                        Ok(ClientSendOutcome::Executed {
722                            detail: "executed; the frame carried an empty payload".to_string(),
723                        })
724                    }
725                }
726            };
727
728            let outcome_json = match &outcome {
729                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
730                Err(e) => serde_json::json!({"error": e.to_string()}),
731            };
732            app_state
733                .record_access_log(
734                    AccessLogOwner::Client(client_id.as_u32()),
735                    protocol.protocol_name(),
736                    None,
737                    "injected_action",
738                    action,
739                    vec![outcome_json],
740                )
741                .await;
742
743            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
744            if let Err(e) = &outcome {
745                error!(
746                    "WebSocket client {} injected action failed: {}",
747                    client_id, e
748                );
749                let _ = status_tx.send(format!(
750                    "[WARN] Client {client_id} injected action failed: {e}"
751                ));
752            }
753            let _ = status_tx.send("__UPDATE_UI__".to_string());
754            crate::client::command_support::reply(command, outcome);
755
756            if disconnect || write_error.is_some() {
757                // `close_websocket` already put the closing frame on the wire above; closing
758                // the sink ends the TCP side so the read loop sees the connection go away.
759                let _ = sink.lock().await.close().await;
760                // Drop the handle here rather than waiting for the read loop: it may still be
761                // inside the `websocket_client_closed` LLM call, and this client cannot accept
762                // another command either way.
763                app_state.remove_client_handle(client_id).await;
764                let _ = status_tx.send("__UPDATE_UI__".to_string());
765                break;
766            }
767        }
768    }
769
770    #[allow(clippy::too_many_arguments)]
771    async fn handle_inbound(
772        frame: Inbound,
773        client_id: ClientId,
774        llm_client: &OllamaClient,
775        app_state: &Arc<AppState>,
776        status_tx: &mpsc::UnboundedSender<String>,
777        out_tx: &mpsc::UnboundedSender<WsOut>,
778        data: &Arc<Mutex<ClientData>>,
779    ) {
780        {
781            let mut d = data.lock().await;
782            if d.state == HandlerState::Processing {
783                d.queued.push_back(frame);
784                return;
785            }
786            d.state = HandlerState::Processing;
787            match d.pending.take() {
788                Some(pending) => match pending.merge(frame) {
789                    Ok(merged) => d.queued.push_front(merged),
790                    Err((held, new)) => {
791                        d.queued.push_front(new);
792                        d.queued.push_front(held);
793                    }
794                },
795                None => d.queued.push_front(frame),
796            }
797        }
798
799        loop {
800            let next = {
801                let mut d = data.lock().await;
802                match d.queued.pop_front() {
803                    Some(f) => f,
804                    None => {
805                        d.state = HandlerState::Idle;
806                        return;
807                    }
808                }
809            };
810
811            let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
812                let mut d = data.lock().await;
813                d.state = HandlerState::Idle;
814                return;
815            };
816
817            let event = match &next {
818                Inbound::Text(text) => Event::new(
819                    &WEBSOCKET_CLIENT_TEXT_MESSAGE_EVENT,
820                    serde_json::json!({ "text": text, "message_bytes": text.len() }),
821                ),
822                Inbound::Binary(bytes) => {
823                    let (payload, encoding) = encode_inbound_payload(bytes);
824                    Event::new(
825                        &WEBSOCKET_CLIENT_BINARY_MESSAGE_EVENT,
826                        serde_json::json!({
827                            "data": payload,
828                            "encoding": encoding,
829                            "message_bytes": bytes.len(),
830                        }),
831                    )
832                }
833            };
834
835            let protocol = WebSocketClientProtocol::for_connection(out_tx.clone());
836            let memory = { data.lock().await.memory.clone() };
837
838            match call_llm_for_client(
839                llm_client,
840                app_state,
841                client_id.to_string(),
842                &instruction,
843                &memory,
844                Some(&event),
845                &protocol,
846                status_tx,
847            )
848            .await
849            {
850                Ok(ClientLlmResult {
851                    actions,
852                    memory_updates,
853                }) => {
854                    if let Some(mem) = memory_updates {
855                        data.lock().await.memory = mem;
856                    }
857                    let mut should_wait = false;
858                    let mut should_stop = false;
859                    for action in actions {
860                        match protocol.execute_action(action) {
861                            Ok(ClientActionResult::WaitForMore) => should_wait = true,
862                            Ok(ClientActionResult::Disconnect) => should_stop = true,
863                            Ok(_) => {}
864                            Err(e) => {
865                                error!("WebSocket client action failed: {}", e);
866                                let _ = status_tx
867                                    .send(format!("[CLIENT] WebSocket action failed: {e}"));
868                            }
869                        }
870                    }
871                    if should_wait {
872                        let mut d = data.lock().await;
873                        d.pending = Some(next);
874                        d.state = HandlerState::Accumulating;
875                        return;
876                    }
877                    if should_stop {
878                        let mut d = data.lock().await;
879                        d.state = HandlerState::Idle;
880                        d.queued.clear();
881                        return;
882                    }
883                }
884                Err(e) => {
885                    error!("LLM error for WebSocket client {}: {}", client_id, e);
886                    let _ = status_tx.send(format!("[CLIENT] WebSocket LLM error: {e}"));
887                    let mut d = data.lock().await;
888                    d.state = HandlerState::Idle;
889                    d.queued.clear();
890                    return;
891                }
892            }
893        }
894    }
895
896    #[allow(clippy::too_many_arguments)]
897    async fn emit_closed(
898        client_id: ClientId,
899        code: u16,
900        reason: String,
901        llm_client: &OllamaClient,
902        app_state: &Arc<AppState>,
903        status_tx: &mpsc::UnboundedSender<String>,
904        out_tx: &mpsc::UnboundedSender<WsOut>,
905        data: &Arc<Mutex<ClientData>>,
906    ) {
907        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
908            return;
909        };
910        let event = Event::new(
911            &WEBSOCKET_CLIENT_CLOSED_EVENT,
912            serde_json::json!({ "code": code, "reason": reason }),
913        );
914        let protocol = WebSocketClientProtocol::for_connection(out_tx.clone());
915        let memory = { data.lock().await.memory.clone() };
916        match call_llm_for_client(
917            llm_client,
918            app_state,
919            client_id.to_string(),
920            &instruction,
921            &memory,
922            Some(&event),
923            &protocol,
924            status_tx,
925        )
926        .await
927        {
928            Ok(ClientLlmResult {
929                actions,
930                memory_updates,
931            }) => {
932                // The socket is closed, so a `send_message` here has nowhere to go — but the
933                // pattern used to be `{ memory_updates, .. }`, which dropped the actions
934                // without saying so. Reporting them means an operator can see the model tried
935                // to answer a close with a send, which is a prompt worth fixing rather than a
936                // silence.
937                if !actions.is_empty() {
938                    let _ = status_tx.send(format!(
939                        "[CLIENT] WebSocket client {} is closed; {} action(s) from \
940                         websocket_client_closed were not sent",
941                        client_id,
942                        actions.len()
943                    ));
944                }
945                if let Some(mem) = memory_updates {
946                    data.lock().await.memory = mem;
947                }
948            }
949            Err(e) => debug!("WebSocket client close handler failed: {}", e),
950        }
951    }
952}
953
954/// Turn one outbound frame request into a tungstenite message. Shared by the writer task and
955/// the injected-command loop so the two cannot encode a frame differently.
956fn ws_out_to_message(out: WsOut) -> Message {
957    match out {
958        WsOut::Text(t) => Message::Text(t),
959        WsOut::Binary(b) => Message::Binary(b),
960        WsOut::Ping(p) => Message::Ping(p),
961        WsOut::Close { code, reason } => Message::Close(Some(CloseFrame {
962            code: CloseCode::from(code),
963            reason: reason.into(),
964        })),
965    }
966}