Skip to main content

netget/server/websocket/
mod.rs

1//! WebSocket (RFC 6455) server with LLM-controlled frames.
2//!
3//! # Why the handshake is hand-written and the framing is not
4//!
5//! `tokio-tungstenite`'s `accept_hdr_async` takes a **synchronous** callback to inspect the
6//! request and shape the 101 response. Choosing a subprotocol is the one genuinely
7//! interesting decision in this protocol and it needs a model call, which is `async`. So the
8//! HTTP Upgrade half of RFC 6455 §4.2 is done here by hand — read the request head, validate
9//! it, emit `websocket_handshake`, and only then write the 101 with the
10//! `Sec-WebSocket-Accept` derived from the client's `Sec-WebSocket-Key` — and the already
11//! upgraded socket is handed to `WebSocketStream::from_partially_read` for everything below
12//! §5: frame parsing, the mandatory client-to-server mask check, continuation-frame
13//! reassembly, automatic pongs, and the closing handshake.
14//!
15//! # What the LLM decides
16//!
17//! - whether an upgrade is accepted at all, and with which `Sec-WebSocket-Protocol`
18//! - every text and binary frame the server sends, including unprompted ones
19//! - the close code and reason
20//!
21//! # What the server does without asking
22//!
23//! - rejects a malformed or non-WebSocket request with an HTTP error (no model call: there is
24//!   no decision, the request is simply not a WebSocket upgrade)
25//! - answers pings with pongs, echoes the peer's close frame, reassembles fragments
26//! - refuses the upgrade when the model answers with neither `accept_websocket` nor
27//!   `reject_websocket` — silence must not read as consent
28//!
29//! # No storage
30//!
31//! `actions::WS_CONNECTIONS` holds live socket handles so the model can address a recipient by
32//! id. Nothing is retained past a disconnect: no message log, no rooms, no subscriptions.
33
34pub mod actions;
35pub mod deflate;
36
37use anyhow::{Context, Result};
38use futures::stream::StreamExt;
39use futures::SinkExt;
40use std::collections::VecDeque;
41use std::net::SocketAddr;
42use std::sync::Arc;
43use tokio::io::{AsyncReadExt, AsyncWriteExt};
44use tokio::net::TcpStream;
45use tokio::sync::{mpsc, Mutex};
46use tokio::task::JoinSet;
47use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
48use tokio_tungstenite::tungstenite::protocol::{CloseFrame, Role, WebSocketConfig};
49use tokio_tungstenite::tungstenite::Message;
50use tokio_tungstenite::WebSocketStream;
51use tracing::{debug, trace};
52
53use crate::llm::action_helper::call_llm;
54use crate::llm::actions::protocol_trait::ActionResult;
55use crate::llm::ollama_client::OllamaClient;
56use crate::logging::emit::Log;
57use crate::protocol::Event;
58use crate::server::connection::ConnectionId;
59use crate::state::app_state::AppState;
60use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
61use crate::state::ServerId;
62use actions::{
63    encode_inbound_payload, WebSocketProtocol, WsOut, WEBSOCKET_BINARY_MESSAGE_EVENT,
64    WEBSOCKET_CLOSE_EVENT, WEBSOCKET_CONNECTION_OPENED_EVENT, WEBSOCKET_HANDSHAKE_EVENT,
65    WEBSOCKET_PING_EVENT, WEBSOCKET_TEXT_MESSAGE_EVENT,
66};
67
68/// Largest HTTP request head accepted before the upgrade. Real handshakes are well under 2 KiB.
69const MAX_REQUEST_HEAD: usize = 16 * 1024;
70
71/// How long a client has to finish sending its request head after connecting.
72const HANDSHAKE_TIMEOUT_SECS: u64 = 15;
73
74const DEFAULT_MAX_MESSAGE_SIZE: usize = 1024 * 1024;
75const DEFAULT_MAX_FRAME_SIZE: usize = 1024 * 1024;
76pub const MAX_SIZE_LIMIT: usize = 64 * 1024 * 1024;
77
78/// How long an upgraded connection may go without a single frame from the peer.
79///
80/// A WebSocket is a session the client is entitled to hold open with nothing to say — so this
81/// bound is on the **peer's liveness**, not on its conversation. At half of it the server sends
82/// a Ping; every RFC 6455 endpoint answers a Ping with a Pong by itself (§5.5.2 — browsers,
83/// tungstenite, websocat and NetGet's own client all do, without any application code), and a
84/// Pong is a frame, so a live-but-silent client never reaches the bound. What does is a peer
85/// that has stopped reading or vanished without a FIN: exactly the socket that would otherwise
86/// sit in a slot forever. Every inbound frame counts — text, binary, Ping, Pong, Close.
87///
88/// 600 seconds, twice the default window a `manual` rule gives a human (300s,
89/// `src/state/intercepts.rs`). tungstenite, like most WebSocket libraries, answers a Ping from
90/// inside a read, so a client that stops reading while its application thinks — a model turn,
91/// a person — answers late. At twice that window a Ping sent at the worst moment is still
92/// answered inside the bound. NetGet's own client keeps reading through a parked turn
93/// (`tests/client/websocket/keepalive_test.rs` holds it to a 2-second bound); the margin is for
94/// peers that do not. The keepalive costs one small frame per idle connection every five
95/// minutes, which no deployment notices. Declared as the `idle_timeout_secs` startup parameter.
96///
97/// **A message still being answered is not silence.** Handlers run in their own tasks while the
98/// frame loop keeps reading, so each holds the connection's `ConnectionActivity` busy for the
99/// whole of its answer — a model round-trip, or a `manual` rule parked for a human — and the
100/// watchdog neither pings nor closes while anything is in flight. On expiry the server sends
101/// Close 1001 ("going away", RFC 6455 §7.4.1) and ends the connection.
102pub const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
103
104/// Whether the server accepts a client's RFC 7692 `permessage-deflate` offer. On by default:
105/// browsers offer it on every connection, and accepting is what a WebSocket server does. The
106/// `permessage_deflate` startup parameter turns it off.
107pub const DEFAULT_PERMESSAGE_DEFLATE: bool = true;
108
109/// Concurrent connections this server admits before it starts refusing.
110///
111/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. [`HANDSHAKE_TIMEOUT_SECS`]
112/// bounds how long a peer can hold a slot before it has sent a request head, and
113/// [`IDLE_TIMEOUT`] how long an upgraded one can hold it without showing it is alive. Neither
114/// bounds how many there are at once; this does.
115///
116/// So the number is a statement about sessions, not about arrival rate. Each one costs a
117/// socket, two tasks and up to [`DEFAULT_MAX_MESSAGE_SIZE`] of reassembly buffer; 256 is far
118/// above a browser application's fan-in to one endpoint and far below where a stranger's
119/// `connect()` loop starts costing this process its descriptors. A deployment that needs the
120/// per-connection cost lower should be tightening `max_message_size`, which is the half of the
121/// product that describes the memory.
122const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
123
124/// The reason phrase in [`CONNECTION_CAP_REFUSAL`].
125const CONNECTION_CAP_REASON: &str = "Too many connections, try again later";
126
127/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
128///
129/// **A WebSocket client is an HTTP client first, so this speaks HTTP.** RFC 6455 §4.1 makes
130/// the opening handshake an ordinary HTTP/1.1 GET, and §4.2.2 has the server answer anything
131/// it will not upgrade with a normal HTTP response — which is what this server already does
132/// for a refused upgrade, a bad path and a timed-out head. 503 with `Retry-After` is HTTP's
133/// own word for "full, come back", and every client surfaces it as a failed handshake with a
134/// status: browsers fire `error` on the `WebSocket` rather than `close`, and `websocat`,
135/// tungstenite and `wscat` all report the code. A close frame would be wrong twice over — it
136/// belongs to a connection that was never upgraded, and the peer has no frame parser running
137/// yet.
138///
139/// Writing it before the request head has arrived is deliberate and legal: RFC 9112 §3.3 lets
140/// a server send a final response before the request is complete, and `Connection: close`
141/// tells a client still sending its head to stop and read. Built through this server's own
142/// [`build_error_response`] so the `Content-Length` cannot drift from the body.
143static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
144    build_error_response(
145        503,
146        CONNECTION_CAP_REASON,
147        &[("Retry-After".to_string(), "30".to_string())],
148    )
149});
150
151// ============================================================================
152// HTTP request head parsing
153// ============================================================================
154
155/// The request line and headers of an HTTP/1.1 request, parsed far enough to decide whether
156/// it is a WebSocket upgrade.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct RequestHead {
159    pub method: String,
160    /// Request target exactly as sent, e.g. `/ws?token=abc`.
161    pub target: String,
162    pub version: String,
163    /// Header names lowercased, values trimmed, in the order received.
164    pub headers: Vec<(String, String)>,
165}
166
167impl RequestHead {
168    /// First value of a header, case-insensitively.
169    pub fn header(&self, name: &str) -> Option<&str> {
170        let lower = name.to_ascii_lowercase();
171        self.headers
172            .iter()
173            .find(|(k, _)| *k == lower)
174            .map(|(_, v)| v.as_str())
175    }
176
177    /// Every value of a header, in order.
178    pub fn header_all(&self, name: &str) -> Vec<&str> {
179        let lower = name.to_ascii_lowercase();
180        self.headers
181            .iter()
182            .filter(|(k, _)| *k == lower)
183            .map(|(_, v)| v.as_str())
184            .collect()
185    }
186
187    /// The path, without the query string.
188    pub fn path(&self) -> &str {
189        match self.target.split_once('?') {
190            Some((p, _)) => p,
191            None => &self.target,
192        }
193    }
194
195    /// The query string after `?`, or `""`.
196    pub fn query(&self) -> &str {
197        match self.target.split_once('?') {
198            Some((_, q)) => q,
199            None => "",
200        }
201    }
202
203    /// All headers as a JSON object, repeated headers joined with `", "`.
204    pub fn headers_json(&self) -> serde_json::Value {
205        let mut map = serde_json::Map::new();
206        for (k, v) in &self.headers {
207            match map.get_mut(k) {
208                Some(serde_json::Value::String(existing)) => {
209                    existing.push_str(", ");
210                    existing.push_str(v);
211                }
212                _ => {
213                    map.insert(k.clone(), serde_json::Value::String(v.clone()));
214                }
215            }
216        }
217        serde_json::Value::Object(map)
218    }
219
220    /// Subprotocols offered in `Sec-WebSocket-Protocol`, in the client's order of preference.
221    ///
222    /// RFC 6455 §4.1: the header may appear more than once and each occurrence may hold a
223    /// comma-separated list; both spellings mean the same thing.
224    pub fn offered_subprotocols(&self) -> Vec<String> {
225        self.header_all("sec-websocket-protocol")
226            .into_iter()
227            .flat_map(|v| v.split(','))
228            .map(|s| s.trim().to_string())
229            .filter(|s| !s.is_empty())
230            .collect()
231    }
232}
233
234/// Parse an HTTP/1.1 request head (everything before the terminating blank line).
235///
236/// Deliberately hand-written rather than pulling in another HTTP parser: the upgrade request
237/// is a request line plus headers, and the validation that matters (RFC 6455 §4.2.1) is done
238/// on the result, not by the parser.
239pub fn parse_request_head(bytes: &[u8]) -> std::result::Result<RequestHead, String> {
240    let text =
241        std::str::from_utf8(bytes).map_err(|_| "request head is not valid UTF-8".to_string())?;
242
243    let mut lines = text.split("\r\n");
244    let request_line = lines.next().unwrap_or("");
245    if request_line.is_empty() {
246        return Err("empty request line".to_string());
247    }
248
249    let mut parts = request_line.split(' ');
250    let method = parts.next().unwrap_or("").to_string();
251    let target = parts.next().unwrap_or("").to_string();
252    let version = parts.next().unwrap_or("").to_string();
253    if method.is_empty() || target.is_empty() || version.is_empty() {
254        return Err(format!("malformed request line: {request_line:?}"));
255    }
256    if !version.starts_with("HTTP/") {
257        return Err(format!("unrecognised HTTP version: {version:?}"));
258    }
259
260    let mut headers = Vec::new();
261    for line in lines {
262        if line.is_empty() {
263            break;
264        }
265        // Obsolete line folding (a header continuation starting with SP/HTAB) is rejected
266        // rather than mis-parsed; RFC 7230 §3.2.4 deprecates it and no WebSocket client emits it.
267        if line.starts_with(' ') || line.starts_with('\t') {
268            return Err("obsolete header line folding is not supported".to_string());
269        }
270        let Some((name, value)) = line.split_once(':') else {
271            return Err(format!("malformed header line: {line:?}"));
272        };
273        if name.is_empty() || name.ends_with(' ') {
274            return Err(format!("malformed header name: {name:?}"));
275        }
276        headers.push((name.to_ascii_lowercase(), value.trim().to_string()));
277    }
278
279    Ok(RequestHead {
280        method,
281        target,
282        version,
283        headers,
284    })
285}
286
287/// Why an HTTP request is not an acceptable WebSocket upgrade.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct UpgradeRejection {
290    pub status: u16,
291    pub reason: String,
292    /// Extra response headers, e.g. `Sec-WebSocket-Version: 13` on a version mismatch.
293    pub extra_headers: Vec<(String, String)>,
294}
295
296/// Check a parsed request against RFC 6455 §4.2.1 and return the client's
297/// `Sec-WebSocket-Key` when it is a valid upgrade.
298///
299/// This is pure mechanics with no decision in it, so it never reaches the model — a request
300/// that is not a WebSocket handshake at all is answered with an HTTP error directly.
301pub fn validate_upgrade(head: &RequestHead) -> std::result::Result<String, UpgradeRejection> {
302    let reject = |status: u16, reason: &str| UpgradeRejection {
303        status,
304        reason: reason.to_string(),
305        extra_headers: Vec::new(),
306    };
307
308    if !head.method.eq_ignore_ascii_case("GET") {
309        return Err(reject(
310            405,
311            "WebSocket upgrade requires the GET method (RFC 6455 section 4.1)",
312        ));
313    }
314    if head.version != "HTTP/1.1" {
315        return Err(reject(
316            505,
317            "WebSocket upgrade requires HTTP/1.1 (RFC 6455 section 4.1)",
318        ));
319    }
320
321    let upgrade_ok = head.header_all("upgrade").iter().any(|v| {
322        v.split(',')
323            .any(|t| t.trim().eq_ignore_ascii_case("websocket"))
324    });
325    if !upgrade_ok {
326        return Err(reject(400, "missing or invalid Upgrade: websocket header"));
327    }
328
329    let connection_ok = head.header_all("connection").iter().any(|v| {
330        v.split(',')
331            .any(|t| t.trim().eq_ignore_ascii_case("upgrade"))
332    });
333    if !connection_ok {
334        return Err(reject(400, "missing or invalid Connection: Upgrade header"));
335    }
336
337    match head.header("sec-websocket-version") {
338        Some("13") => {}
339        Some(other) => {
340            return Err(UpgradeRejection {
341                status: 426,
342                reason: format!(
343                    "unsupported Sec-WebSocket-Version {other:?}; this server speaks version 13"
344                ),
345                // RFC 6455 section 4.4: the 426 MUST name the versions we do support.
346                extra_headers: vec![("Sec-WebSocket-Version".into(), "13".into())],
347            });
348        }
349        None => {
350            return Err(UpgradeRejection {
351                status: 426,
352                reason: "missing Sec-WebSocket-Version header".to_string(),
353                extra_headers: vec![("Sec-WebSocket-Version".into(), "13".into())],
354            })
355        }
356    }
357
358    let key = head
359        .header("sec-websocket-key")
360        .ok_or_else(|| reject(400, "missing Sec-WebSocket-Key header"))?;
361
362    // RFC 6455 section 4.1: the key is a base64-encoded 16-byte nonce.
363    use base64::Engine as _;
364    match base64::engine::general_purpose::STANDARD.decode(key) {
365        Ok(raw) if raw.len() == 16 => {}
366        _ => {
367            return Err(reject(
368                400,
369                "Sec-WebSocket-Key is not a base64-encoded 16-byte value",
370            ))
371        }
372    }
373
374    Ok(key.to_string())
375}
376
377/// Build the `101 Switching Protocols` response for a validated handshake.
378///
379/// `Sec-WebSocket-Accept` is `base64(SHA-1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))`,
380/// computed by `tungstenite::handshake::derive_accept_key` — the same crate that then parses
381/// the frames, so there is exactly one implementation of the GUID concatenation in the tree.
382pub fn build_accept_response(
383    key: &str,
384    subprotocol: Option<&str>,
385    extensions: Option<&str>,
386) -> String {
387    let accept = tokio_tungstenite::tungstenite::handshake::derive_accept_key(key.as_bytes());
388    let mut response = String::from("HTTP/1.1 101 Switching Protocols\r\n");
389    response.push_str("Upgrade: websocket\r\n");
390    response.push_str("Connection: Upgrade\r\n");
391    response.push_str(&format!("Sec-WebSocket-Accept: {accept}\r\n"));
392    if let Some(sub) = subprotocol {
393        response.push_str(&format!("Sec-WebSocket-Protocol: {sub}\r\n"));
394    }
395    if let Some(ext) = extensions {
396        response.push_str(&format!("Sec-WebSocket-Extensions: {ext}\r\n"));
397    }
398    response.push_str("\r\n");
399    response
400}
401
402/// Build a plain HTTP error response used to refuse an upgrade.
403pub fn build_error_response(
404    status: u16,
405    reason: &str,
406    extra_headers: &[(String, String)],
407) -> Vec<u8> {
408    let phrase = match status {
409        400 => "Bad Request",
410        401 => "Unauthorized",
411        403 => "Forbidden",
412        404 => "Not Found",
413        405 => "Method Not Allowed",
414        426 => "Upgrade Required",
415        429 => "Too Many Requests",
416        500 => "Internal Server Error",
417        503 => "Service Unavailable",
418        505 => "HTTP Version Not Supported",
419        _ => "Error",
420    };
421    let body = format!("{reason}\n");
422    let mut response = format!("HTTP/1.1 {status} {phrase}\r\n");
423    for (name, value) in extra_headers {
424        response.push_str(&format!("{name}: {value}\r\n"));
425    }
426    response.push_str("Connection: close\r\n");
427    response.push_str("Content-Type: text/plain; charset=utf-8\r\n");
428    response.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
429    response.push_str(&body);
430    response.into_bytes()
431}
432
433// ============================================================================
434// Per-connection state machine
435// ============================================================================
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438enum HandlerState {
439    Idle,
440    Processing,
441    Accumulating,
442}
443
444/// One reassembled message from the client, waiting to be turned into an event.
445#[derive(Debug, Clone)]
446enum Inbound {
447    Text(String),
448    Binary(Vec<u8>),
449    Ping(Vec<u8>),
450}
451
452impl Inbound {
453    /// Join two messages of the same kind into one, for the `Accumulating` state.
454    fn merge(self, next: Inbound) -> std::result::Result<Inbound, (Inbound, Inbound)> {
455        match (self, next) {
456            (Inbound::Text(mut a), Inbound::Text(b)) => {
457                a.push_str(&b);
458                Ok(Inbound::Text(a))
459            }
460            (Inbound::Binary(mut a), Inbound::Binary(b)) => {
461                a.extend_from_slice(&b);
462                Ok(Inbound::Binary(a))
463            }
464            (a, b) => Err((a, b)),
465        }
466    }
467}
468
469struct ConnData {
470    state: HandlerState,
471    /// Messages that arrived while the model was thinking.
472    queued: VecDeque<Inbound>,
473    /// A message the model deferred with `wait_for_websocket_data`.
474    pending: Option<Inbound>,
475}
476
477/// Everything a message handler task needs; grouped so the task spawn stays readable.
478struct ConnCtx {
479    server_id: ServerId,
480    connection_id: ConnectionId,
481    llm_client: OllamaClient,
482    app_state: Arc<AppState>,
483    status_tx: mpsc::UnboundedSender<String>,
484    out_tx: mpsc::UnboundedSender<WsOut>,
485    protocol: Arc<WebSocketProtocol>,
486    data: Arc<Mutex<ConnData>>,
487    subprotocol: Option<String>,
488}
489
490// ============================================================================
491// Server
492// ============================================================================
493
494pub struct WebSocketServer;
495
496impl WebSocketServer {
497    /// Bind and start accepting. Bind failure is propagated so `server_startup` records
498    /// `ServerStatus::Error` rather than reporting a server that never took the port.
499    pub async fn spawn_with_llm_actions(
500        listen_addr: SocketAddr,
501        llm_client: OllamaClient,
502        app_state: Arc<AppState>,
503        status_tx: mpsc::UnboundedSender<String>,
504        server_id: ServerId,
505        startup_params: Option<crate::protocol::StartupParams>,
506    ) -> Result<SocketAddr> {
507        // Every parameter read here is declared in `get_startup_parameters()`, and every
508        // parameter declared there is read here. Errors are propagated, never unwrapped.
509        let permessage_deflate = match startup_params.as_ref() {
510            Some(params) => params
511                .get_optional_bool("permessage_deflate")
512                .map_err(|e| anyhow::anyhow!("WebSocket startup parameter error: {e}"))?
513                .unwrap_or(DEFAULT_PERMESSAGE_DEFLATE),
514            None => DEFAULT_PERMESSAGE_DEFLATE,
515        };
516        let (path_filter, max_message_size, max_frame_size, idle_timeout) =
517            match startup_params.as_ref() {
518                Some(params) => {
519                    let path = params
520                        .get_optional_string("path")
521                        .map_err(|e| anyhow::anyhow!("WebSocket startup parameter error: {e}"))?
522                        .map(|p| {
523                            if p.starts_with('/') {
524                                p
525                            } else {
526                                format!("/{p}")
527                            }
528                        });
529                    let msg = params
530                        .get_optional_u64("max_message_size")
531                        .map_err(|e| anyhow::anyhow!("WebSocket startup parameter error: {e}"))?
532                        .map(|v| (v as usize).clamp(125, MAX_SIZE_LIMIT))
533                        .unwrap_or(DEFAULT_MAX_MESSAGE_SIZE);
534                    let frame = params
535                        .get_optional_u64("max_frame_size")
536                        .map_err(|e| anyhow::anyhow!("WebSocket startup parameter error: {e}"))?
537                        .map(|v| (v as usize).clamp(125, MAX_SIZE_LIMIT))
538                        .unwrap_or(DEFAULT_MAX_FRAME_SIZE);
539                    let idle = params
540                        .get_optional_u64("idle_timeout_secs")
541                        .map_err(|e| anyhow::anyhow!("WebSocket startup parameter error: {e}"))?
542                        .map(std::time::Duration::from_secs)
543                        .unwrap_or(IDLE_TIMEOUT);
544                    (path, msg, frame, idle)
545                }
546                None => (
547                    None,
548                    DEFAULT_MAX_MESSAGE_SIZE,
549                    DEFAULT_MAX_FRAME_SIZE,
550                    IDLE_TIMEOUT,
551                ),
552            };
553
554        let ws_config = WebSocketConfig {
555            max_message_size: Some(max_message_size),
556            max_frame_size: Some(max_frame_size),
557            // Left at the RFC default (false): a server MUST fail the connection on an
558            // unmasked client frame (RFC 6455 section 5.1).
559            accept_unmasked_frames: false,
560            ..Default::default()
561        };
562
563        let listener =
564            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
565        let local_addr = listener.local_addr()?;
566
567        Log::new(Some(&status_tx)).info(format!("WebSocket server listening on {}", local_addr));
568        if let Some(p) = &path_filter {
569            Log::new(Some(&status_tx)).info(format!("WebSocket server only upgrades path {}", p));
570        }
571
572        let accept_state = app_state.clone();
573        let accept_status_tx = status_tx.clone();
574
575        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
576        let accept_handle = tokio::spawn(async move {
577            loop {
578                match crate::server::accept_bounded::accept_bounded(
579                    &listener,
580                    &limiter,
581                    &CONNECTION_CAP_REFUSAL,
582                    "WEBSOCKET",
583                    Some(&accept_status_tx),
584                )
585                .await
586                {
587                    Ok((socket, peer_addr, permit)) => {
588                        let llm_client = llm_client.clone();
589                        let app_state = accept_state.clone();
590                        let status_tx = accept_status_tx.clone();
591                        let path_filter = path_filter.clone();
592                        // Tracked, not detached: stop_server must abort this task too.
593                        let task_owner = app_state.clone();
594                        task_owner
595                            .spawn_server_task(server_id, async move {
596                                // One upgraded WebSocket is *two* tasks — this one and the
597                                // writer `run_connection` spawns — and the read loop waits
598                                // only five seconds for the writer before giving up on it, so
599                                // the permit goes in as an `Arc` held by both and the slot
600                                // comes back when the later of the two ends. Holding it here
601                                // alone would release the slot while a writer was still
602                                // draining a slow peer, which un-caps the server silently.
603                                let permit = Arc::new(permit);
604                                if let Err(e) = Self::handle_connection(
605                                    socket,
606                                    peer_addr,
607                                    local_addr,
608                                    llm_client,
609                                    app_state,
610                                    status_tx,
611                                    server_id,
612                                    path_filter,
613                                    ws_config,
614                                    permit,
615                                    idle_timeout,
616                                    permessage_deflate,
617                                )
618                                .await
619                                {
620                                    debug!("WebSocket connection from {} ended: {}", peer_addr, e);
621                                }
622                            })
623                            .await;
624                    }
625                    Err(e) => {
626                        Log::new(Some(&accept_status_tx))
627                            .error(format!("WebSocket accept error: {}", e));
628                        break;
629                    }
630                }
631            }
632        });
633
634        // Without this the socket survives stop_server.
635        app_state
636            .register_server_task(server_id, accept_handle)
637            .await;
638
639        let _ = status_tx.send("__UPDATE_UI__".to_string());
640        Ok(local_addr)
641    }
642
643    /// Read the HTTP request head, run the handshake decision, and hand off to the frame loop.
644    #[allow(clippy::too_many_arguments)]
645    async fn handle_connection(
646        mut socket: TcpStream,
647        peer_addr: SocketAddr,
648        local_addr: SocketAddr,
649        llm_client: OllamaClient,
650        app_state: Arc<AppState>,
651        status_tx: mpsc::UnboundedSender<String>,
652        server_id: ServerId,
653        path_filter: Option<String>,
654        ws_config: WebSocketConfig,
655        permit: Arc<crate::server::accept_bounded::ConnectionPermit>,
656        idle_timeout: std::time::Duration,
657        permessage_deflate: bool,
658    ) -> Result<()> {
659        // ---- 1. read the request head -------------------------------------
660        let (head_bytes, leftover) = match tokio::time::timeout(
661            std::time::Duration::from_secs(HANDSHAKE_TIMEOUT_SECS),
662            read_request_head(&mut socket),
663        )
664        .await
665        {
666            Ok(Ok(v)) => v,
667            Ok(Err(e)) => {
668                debug!("WebSocket handshake read failed from {}: {}", peer_addr, e);
669                return Ok(());
670            }
671            Err(_) => {
672                debug!("WebSocket handshake timed out for {}", peer_addr);
673                let _ = socket
674                    .write_all(&build_error_response(
675                        408,
676                        "Timed out waiting for the HTTP request",
677                        &[],
678                    ))
679                    .await;
680                return Ok(());
681            }
682        };
683
684        let head = match parse_request_head(&head_bytes) {
685            Ok(h) => h,
686            Err(e) => {
687                Log::new(Some(&status_tx)).warn(format!(
688                    "WebSocket malformed request from {}: {}",
689                    peer_addr, e
690                ));
691                let _ = socket.write_all(&build_error_response(400, &e, &[])).await;
692                return Ok(());
693            }
694        };
695
696        // ---- 2. mechanical validation (no model call) ----------------------
697        let key = match validate_upgrade(&head) {
698            Ok(k) => k,
699            Err(rej) => {
700                Log::new(Some(&status_tx)).debug(format!(
701                    "WebSocket upgrade refused for {} ({}): {}",
702                    peer_addr, rej.status, rej.reason
703                ));
704                let _ = socket
705                    .write_all(&build_error_response(
706                        rej.status,
707                        &rej.reason,
708                        &rej.extra_headers,
709                    ))
710                    .await;
711                return Ok(());
712            }
713        };
714
715        if let Some(want) = &path_filter {
716            if head.path() != want {
717                Log::new(Some(&status_tx)).debug(format!(
718                    "WebSocket 404 for path {} (server serves {})",
719                    head.path(),
720                    want
721                ));
722                let _ = socket
723                    .write_all(&build_error_response(
724                        404,
725                        &format!("This server only serves WebSocket at {want}"),
726                        &[],
727                    ))
728                    .await;
729                return Ok(());
730            }
731        }
732
733        // ---- 3. register the connection ------------------------------------
734        let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
735        let offered = head.offered_subprotocols();
736        let path = head.path().to_string();
737
738        let (out_tx, out_rx) = mpsc::unbounded_channel::<WsOut>();
739        actions::register_connection(
740            server_id,
741            connection_id,
742            peer_addr.to_string(),
743            path.clone(),
744            out_tx.clone(),
745        );
746
747        let now = crate::utils::clock::Instant::now();
748        app_state
749            .add_connection_to_server(
750                server_id,
751                ConnectionState {
752                    id: connection_id,
753                    remote_addr: peer_addr,
754                    local_addr,
755                    bytes_sent: 0,
756                    bytes_received: 0,
757                    packets_sent: 0,
758                    packets_received: 0,
759                    last_activity: now,
760                    status: ConnectionStatus::Active,
761                    status_changed_at: now,
762                    protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
763                        "path": path,
764                        "subprotocol": serde_json::Value::Null,
765                        "state": "Handshake",
766                    })),
767                },
768            )
769            .await;
770        let _ = status_tx.send("__UPDATE_UI__".to_string());
771
772        let protocol = Arc::new(WebSocketProtocol::for_connection(
773            server_id,
774            connection_id,
775            out_tx.clone(),
776            status_tx.clone(),
777            offered.clone(),
778        ));
779
780        // ---- 4. ask the model whether to accept, and with which subprotocol -
781        let decision = Self::run_handshake_decision(
782            &head,
783            peer_addr,
784            &llm_client,
785            &app_state,
786            &status_tx,
787            server_id,
788            connection_id,
789            protocol.as_ref(),
790        )
791        .await;
792
793        let subprotocol = match decision {
794            HandshakeDecision::Accept { subprotocol } => subprotocol,
795            HandshakeDecision::Reject { status, reason } => {
796                // The tag on the fail-closed refusals is written by `run_handshake_decision`,
797                // which is the only place that knows *why* the upgrade was refused; this line
798                // covers the one case it does not reach, an explicit `reject_websocket`.
799                Log::new(Some(&status_tx)).info(format!(
800                    "WebSocket upgrade from {} rejected by handler ({}): {}",
801                    peer_addr, status, reason
802                ));
803                let _ = socket
804                    .write_all(&build_error_response(status, &reason, &[]))
805                    .await;
806                Self::cleanup(&app_state, server_id, connection_id, &status_tx).await;
807                return Ok(());
808            }
809        };
810
811        // ---- 5. write the 101 ----------------------------------------------
812        // RFC 7692 is mechanical, not a decision for the model: accept the first offer this
813        // server can honour, or run uncompressed, which is always allowed.
814        let deflate = if permessage_deflate {
815            deflate::negotiate(head.header_all("sec-websocket-extensions"))
816        } else {
817            None
818        };
819        let response = build_accept_response(
820            &key,
821            subprotocol.as_deref(),
822            deflate.as_ref().map(|(_, value)| value.as_str()),
823        );
824        let deflate = deflate.map(|(params, _)| params);
825        trace!("WebSocket 101 response to {}:\n{}", peer_addr, response);
826        if let Err(e) = socket.write_all(response.as_bytes()).await {
827            debug!("WebSocket failed to write 101 to {}: {}", peer_addr, e);
828            Self::cleanup(&app_state, server_id, connection_id, &status_tx).await;
829            return Ok(());
830        }
831        if let Err(e) = socket.flush().await {
832            debug!("WebSocket failed to flush 101 to {}: {}", peer_addr, e);
833            Self::cleanup(&app_state, server_id, connection_id, &status_tx).await;
834            return Ok(());
835        }
836
837        actions::set_connection_subprotocol(connection_id, subprotocol.clone());
838        app_state
839            .with_server_mut(server_id, |server| {
840                if let Some(conn) = server.connections.get_mut(&connection_id) {
841                    conn.protocol_info = ProtocolConnectionInfo::new(serde_json::json!({
842                        "path": path,
843                        "subprotocol": subprotocol,
844                        "permessage_deflate": deflate.is_some(),
845                        "state": "Open",
846                    }));
847                }
848            })
849            .await;
850
851        Log::new(Some(&status_tx)).info(format!(
852            "WebSocket {} open from {} ({}{}{})",
853            connection_id,
854            peer_addr,
855            path,
856            subprotocol
857                .as_ref()
858                .map(|s| format!(", subprotocol {s}"))
859                .unwrap_or_default(),
860            if deflate.is_some() {
861                ", permessage-deflate"
862            } else {
863                ""
864            }
865        ));
866
867        // ---- 6. hand the upgraded socket to the framing layer ---------------
868        // `from_partially_read` rather than `from_raw_socket`: a pipelined client may already
869        // have written frames into the same TCP segment as the request head, and those bytes
870        // are in `leftover`. Discarding them would silently drop the client's first message.
871        //
872        // The leftover goes to the deflate layer, not tungstenite: it is frame bytes, and with
873        // the extension on, a compressed first message may already be in it.
874        let max_message = ws_config
875            .max_message_size
876            .unwrap_or(DEFAULT_MAX_MESSAGE_SIZE);
877        let max_frame = ws_config.max_frame_size.unwrap_or(DEFAULT_MAX_FRAME_SIZE);
878        let ws_stream = WebSocketStream::from_partially_read(
879            deflate::DeflateStream::server(socket, deflate, max_message, max_frame, leftover),
880            Vec::new(),
881            Role::Server,
882            Some(ws_config),
883        )
884        .await;
885
886        Self::run_connection(
887            ws_stream,
888            out_rx,
889            permit,
890            ConnCtx {
891                server_id,
892                connection_id,
893                llm_client,
894                app_state: app_state.clone(),
895                status_tx: status_tx.clone(),
896                out_tx,
897                protocol,
898                data: Arc::new(Mutex::new(ConnData {
899                    state: HandlerState::Idle,
900                    queued: VecDeque::new(),
901                    pending: None,
902                })),
903                subprotocol,
904            },
905            path,
906            peer_addr,
907            idle_timeout,
908            deflate,
909        )
910        .await;
911
912        Self::cleanup(&app_state, server_id, connection_id, &status_tx).await;
913        Ok(())
914    }
915
916    /// Emit `websocket_handshake` and turn the answer into a decision.
917    ///
918    /// Default is refusal. An LLM outage, a handler that returns nothing, and a handler that
919    /// returns an unrelated action all end here with a 503, and each is logged distinctly from
920    /// an explicit `reject_websocket` — silence must never be indistinguishable from consent.
921    #[allow(clippy::too_many_arguments)]
922    async fn run_handshake_decision(
923        head: &RequestHead,
924        peer_addr: SocketAddr,
925        llm_client: &OllamaClient,
926        app_state: &Arc<AppState>,
927        status_tx: &mpsc::UnboundedSender<String>,
928        server_id: ServerId,
929        connection_id: ConnectionId,
930        protocol: &WebSocketProtocol,
931    ) -> HandshakeDecision {
932        let event = Event::new(
933            &WEBSOCKET_HANDSHAKE_EVENT,
934            serde_json::json!({
935                "path": head.path(),
936                "query": head.query(),
937                "subprotocols": head.offered_subprotocols(),
938                "answer_with": actions::handshake_answer_with(&head.offered_subprotocols()),
939                "origin": head.header("origin").unwrap_or(""),
940                "headers": head.headers_json(),
941                "client_ip": peer_addr.ip().to_string(),
942                "client_port": peer_addr.port(),
943            }),
944        );
945
946        let result = match call_llm(
947            llm_client,
948            app_state,
949            server_id,
950            Some(connection_id),
951            &event,
952            protocol,
953        )
954        .await
955        {
956            Ok(r) => r,
957            Err(e) => {
958                // The 503 reason is a fixed string; the backend's error text stays here. The
959                // two categories are kept apart in the log because the wire cannot carry the
960                // distinction — an HTTP error body is all a pre-upgrade client ever sees.
961                let tag = match crate::utils::wire_failure::WireFailure::classify(&e) {
962                    crate::utils::wire_failure::WireFailure::Overloaded => {
963                        "fail_closed_llm_overloaded"
964                    }
965                    crate::utils::wire_failure::WireFailure::Unavailable => "fail_closed_llm_error",
966                };
967                Log::new(Some(status_tx)).error(format!(
968                    "WebSocket upgrade from {} refused decision={}: handler error ({})",
969                    peer_addr, tag, e
970                ));
971                return HandshakeDecision::Reject {
972                    status: 503,
973                    reason: "The upgrade handler failed, so the connection was not opened"
974                        .to_string(),
975                };
976            }
977        };
978
979        for msg in result.messages {
980            let _ = status_tx.send(msg);
981        }
982
983        for r in &result.protocol_results {
984            if let ActionResult::Custom { name, data } = r {
985                match name.as_str() {
986                    "accept_websocket" => {
987                        let subprotocol = data
988                            .get("subprotocol")
989                            .and_then(|v| v.as_str())
990                            .map(|s| s.to_string());
991                        Log::new(Some(status_tx)).info(format!(
992                            "WebSocket upgrade from {} accepted decision=model_answer{}",
993                            peer_addr,
994                            subprotocol
995                                .as_ref()
996                                .map(|s| format!(" (subprotocol {s})"))
997                                .unwrap_or_default()
998                        ));
999                        return HandshakeDecision::Accept { subprotocol };
1000                    }
1001                    "reject_websocket" => {
1002                        let status = data
1003                            .get("status_code")
1004                            .and_then(|v| v.as_u64())
1005                            .unwrap_or(403) as u16;
1006                        Log::new(Some(status_tx)).info(format!(
1007                            "WebSocket upgrade from {} refused decision=model_reject ({})",
1008                            peer_addr, status
1009                        ));
1010                        return HandshakeDecision::Reject {
1011                            status,
1012                            reason: data
1013                                .get("reason")
1014                                .and_then(|v| v.as_str())
1015                                .unwrap_or("Upgrade refused")
1016                                .to_string(),
1017                        };
1018                    }
1019                    _ => {}
1020                }
1021            }
1022        }
1023
1024        // Structurally distinct from the explicit rejection above: this is the fail-closed
1025        // path, and it says so in the log rather than quietly behaving like an approval. The
1026        // two tags separate a handler that said nothing at all from one whose answer this
1027        // protocol could not use — the second is a prompt or an executor problem, the first
1028        // is an absent decision.
1029        let tag = if result.raw_actions.is_empty() {
1030            "fail_closed_no_action"
1031        } else {
1032            "fail_closed_bad_action"
1033        };
1034        Log::new(Some(status_tx)).error(format!(
1035            "WebSocket upgrade from {} refused decision={}: handler returned neither \
1036             accept_websocket nor reject_websocket",
1037            peer_addr, tag
1038        ));
1039        HandshakeDecision::Reject {
1040            status: 503,
1041            reason: "The upgrade handler did not accept the connection".to_string(),
1042        }
1043    }
1044
1045    /// The post-upgrade loop: writer task, `websocket_connection_opened`, then frames.
1046    #[allow(clippy::too_many_arguments)]
1047    async fn run_connection(
1048        ws_stream: WebSocketStream<deflate::DeflateStream<TcpStream>>,
1049        mut out_rx: mpsc::UnboundedReceiver<WsOut>,
1050        permit: Arc<crate::server::accept_bounded::ConnectionPermit>,
1051        ctx: ConnCtx,
1052        path: String,
1053        peer_addr: SocketAddr,
1054        idle_timeout: std::time::Duration,
1055        deflate: Option<deflate::DeflateParams>,
1056    ) {
1057        let (mut sink, mut stream) = ws_stream.split();
1058
1059        // Single writer. Every producer — the message handlers, an async push action, a
1060        // scheduled task — funnels through `out_rx`, so no two frames can interleave and no
1061        // lock is ever held across the `.send().await`.
1062        let writer_status_tx = ctx.status_tx.clone();
1063        let writer_conn = ctx.connection_id;
1064        // The writer's own claim on the connection's slot. The read loop below waits at most
1065        // five seconds for this task, so it can outlive the task that accepted the peer; the
1066        // slot is released by whichever of the two drops its `Arc` last.
1067        let writer_permit = Arc::clone(&permit);
1068        let writer_handle = tokio::spawn(async move {
1069            let _permit = writer_permit;
1070            while let Some(out) = out_rx.recv().await {
1071                let closing = matches!(out, WsOut::Close { .. });
1072                let message = match out {
1073                    WsOut::Text(t) => Message::Text(t),
1074                    WsOut::Binary(b) => Message::Binary(b),
1075                    WsOut::Ping(p) => Message::Ping(p),
1076                    WsOut::Close { code, reason } => Message::Close(Some(CloseFrame {
1077                        code: CloseCode::from(code),
1078                        reason: reason.into(),
1079                    })),
1080                };
1081                if let Err(e) = sink.send(message).await {
1082                    Log::new(Some(&writer_status_tx))
1083                        .debug(format!("WebSocket write failed on {}: {}", writer_conn, e));
1084                    break;
1085                }
1086                if closing {
1087                    // RFC 6455 section 7.1.2: after sending a close frame the endpoint sends
1088                    // nothing more. The read half stays open until the peer's close arrives.
1089                    let _ = sink.flush().await;
1090                    break;
1091                }
1092            }
1093            let _ = sink.flush().await;
1094        });
1095
1096        // `websocket_connection_opened` — the server's chance to speak first.
1097        let opened_event = Event::new(
1098            &WEBSOCKET_CONNECTION_OPENED_EVENT,
1099            serde_json::json!({
1100                "path": path,
1101                "answer_with": actions::OPENED_ANSWER_WITH,
1102                "subprotocol": ctx.subprotocol.clone().unwrap_or_default(),
1103                "permessage_deflate": deflate.map(|p| p.to_json()),
1104                "client_ip": peer_addr.ip().to_string(),
1105                "client_port": peer_addr.port(),
1106            }),
1107        );
1108        ctx.protocol.set_speaking_first(true);
1109        let opened_answer = call_llm(
1110            &ctx.llm_client,
1111            &ctx.app_state,
1112            ctx.server_id,
1113            Some(ctx.connection_id),
1114            &opened_event,
1115            ctx.protocol.as_ref(),
1116        )
1117        .await;
1118        ctx.protocol.set_speaking_first(false);
1119        match opened_answer {
1120            Ok(result) => {
1121                for msg in result.messages {
1122                    let _ = ctx.status_tx.send(msg);
1123                }
1124            }
1125            Err(e) => {
1126                // Not fatal: nothing has been promised to the client yet, so the connection
1127                // stays up and the first real message gets its own chance.
1128                //
1129                // `llm_error_notice_only` rather than `fail_closed_*`: the upgrade was already
1130                // decided by `websocket_handshake`, this event only offers the server a chance
1131                // to speak first, and nothing was refused or closed. Tagging it fail-closed
1132                // would put a line in front of every `grep decision=fail_closed` that denied
1133                // nothing.
1134                Log::new(Some(&ctx.status_tx)).warn(format!(
1135                    "WebSocket connection-opened handler failed on {} \
1136                     decision=llm_error_notice_only: {}",
1137                    ctx.connection_id, e
1138                ));
1139            }
1140        }
1141
1142        // Frame loop. Handlers run in their own tasks so a slow model call does not stall
1143        // reading; the per-connection state machine keeps them from overlapping.
1144        let mut handlers: JoinSet<()> = JoinSet::new();
1145        let ctx = Arc::new(ctx);
1146
1147        // Whether this connection is doing anything: touched by every inbound frame, held busy
1148        // by every handler for the whole of its answer. See IDLE_TIMEOUT.
1149        let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
1150
1151        loop {
1152            let keepalive_tx = ctx.out_tx.clone();
1153            let message = tokio::select! {
1154                message = stream.next() => message,
1155                _ = crate::server::accept_bounded::watch_idle_with_probe(
1156                    Arc::clone(&activity),
1157                    idle_timeout,
1158                    move || {
1159                        let _ = keepalive_tx.send(WsOut::Ping(
1160                            crate::server::accept_bounded::KEEPALIVE_PING_PAYLOAD.to_vec(),
1161                        ));
1162                    },
1163                ) => {
1164                    Log::new(Some(&ctx.status_tx)).info(format!(
1165                        "WebSocket {} sent no frame for {}s, not even a Pong; closing \
1166                         decision=idle_timeout",
1167                        ctx.connection_id,
1168                        idle_timeout.as_secs()
1169                    ));
1170                    let _ = ctx.out_tx.send(WsOut::Close {
1171                        code: 1001,
1172                        reason: "idle timeout".to_string(),
1173                    });
1174                    break;
1175                }
1176            };
1177            let Some(message) = message else { break };
1178            activity.touch();
1179            match message {
1180                Ok(Message::Text(text)) => {
1181                    trace!("WebSocket {} <- text {:?}", ctx.connection_id, text);
1182                    let ctx = ctx.clone();
1183                    let busy = activity.busy();
1184                    handlers.spawn(async move {
1185                        let _busy = busy;
1186                        Self::handle_inbound(ctx, Inbound::Text(text)).await;
1187                    });
1188                }
1189                Ok(Message::Binary(bytes)) => {
1190                    trace!(
1191                        "WebSocket {} <- binary {} bytes: {}",
1192                        ctx.connection_id,
1193                        bytes.len(),
1194                        hex::encode(&bytes)
1195                    );
1196                    let ctx = ctx.clone();
1197                    let busy = activity.busy();
1198                    handlers.spawn(async move {
1199                        let _busy = busy;
1200                        Self::handle_inbound(ctx, Inbound::Binary(bytes)).await;
1201                    });
1202                }
1203                Ok(Message::Ping(payload)) => {
1204                    // The pong is already queued by the framing layer.
1205                    let ctx = ctx.clone();
1206                    let busy = activity.busy();
1207                    handlers.spawn(async move {
1208                        let _busy = busy;
1209                        Self::handle_inbound(ctx, Inbound::Ping(payload)).await;
1210                    });
1211                }
1212                Ok(Message::Pong(_)) => {
1213                    debug!("WebSocket {} <- pong", ctx.connection_id);
1214                }
1215                Ok(Message::Close(frame)) => {
1216                    let (code, reason) = match &frame {
1217                        Some(f) => (u16::from(f.code), f.reason.to_string()),
1218                        // 1005 "no status received" is the RFC's name for an empty close.
1219                        None => (1005u16, String::new()),
1220                    };
1221                    Log::new(Some(&ctx.status_tx)).info(format!(
1222                        "WebSocket {} closed by client (code {} reason {:?})",
1223                        ctx.connection_id, code, reason
1224                    ));
1225                    // Run inline: this is the terminal event and must not race the shutdown.
1226                    Self::handle_close(ctx.clone(), code, reason).await;
1227                    break;
1228                }
1229                Ok(Message::Frame(_)) => {}
1230                Err(e) => {
1231                    let text = e.to_string();
1232                    if text.contains("permessage-deflate") {
1233                        // The deflate layer refused the peer's bytes: a message that inflates
1234                        // past the limit, or compressed framing RFC 7692 forbids. 1009 is the
1235                        // RFC 6455 code for the first; the peer is told before the close.
1236                        Log::new(Some(&ctx.status_tx)).warn(format!(
1237                            "WebSocket {} closed: {} decision=permessage_deflate_refused",
1238                            ctx.connection_id, text
1239                        ));
1240                        let _ = ctx.out_tx.send(WsOut::Close {
1241                            code: 1009,
1242                            reason: "message too big".to_string(),
1243                        });
1244                    } else {
1245                        Log::new(Some(&ctx.status_tx)).debug(format!(
1246                            "WebSocket {} read error: {}",
1247                            ctx.connection_id, text
1248                        ));
1249                    }
1250                    break;
1251                }
1252            }
1253        }
1254
1255        // Let in-flight handlers finish so a reply produced just before the peer went away
1256        // still reaches the writer.
1257        while handlers.join_next().await.is_some() {}
1258
1259        // Dropping every sender ends the writer task.
1260        drop(ctx);
1261        let _ = tokio::time::timeout(std::time::Duration::from_secs(5), writer_handle).await;
1262        // Explicit rather than implicit: this is the read side's half of the shared permit,
1263        // and dropping it here — after the writer has been waited for — is what makes the
1264        // slot come back exactly once both halves are done with the connection.
1265        drop(permit);
1266    }
1267
1268    /// Handle one inbound message through the Idle -> Processing -> Accumulating machine.
1269    async fn handle_inbound(ctx: Arc<ConnCtx>, frame: Inbound) {
1270        {
1271            let mut data = ctx.data.lock().await;
1272            if data.state == HandlerState::Processing {
1273                data.queued.push_back(frame);
1274                Log::new(Some(&ctx.status_tx))
1275                    .debug(format!("Queued a message for {}", ctx.connection_id));
1276                return;
1277            }
1278            data.state = HandlerState::Processing;
1279
1280            // Join onto anything the model deferred with wait_for_websocket_data.
1281            match data.pending.take() {
1282                Some(pending) => match pending.merge(frame) {
1283                    Ok(merged) => data.queued.push_front(merged),
1284                    Err((held, new)) => {
1285                        // Different kinds cannot be joined; process them in arrival order.
1286                        data.queued.push_front(new);
1287                        data.queued.push_front(held);
1288                    }
1289                },
1290                None => data.queued.push_front(frame),
1291            }
1292        }
1293
1294        loop {
1295            let next = {
1296                let mut data = ctx.data.lock().await;
1297                match data.queued.pop_front() {
1298                    Some(f) => f,
1299                    None => {
1300                        data.state = HandlerState::Idle;
1301                        return;
1302                    }
1303                }
1304            };
1305
1306            let sub = ctx.subprotocol.clone().unwrap_or_default();
1307            let event = match &next {
1308                Inbound::Text(text) => Event::new(
1309                    &WEBSOCKET_TEXT_MESSAGE_EVENT,
1310                    serde_json::json!({
1311                        "text": text,
1312                        "message_bytes": text.len(),
1313                        "subprotocol": sub,
1314                        "answer_with": actions::TEXT_MESSAGE_ANSWER_WITH,
1315                    }),
1316                ),
1317                Inbound::Binary(bytes) => {
1318                    let (data, encoding) = encode_inbound_payload(bytes);
1319                    Event::new(
1320                        &WEBSOCKET_BINARY_MESSAGE_EVENT,
1321                        serde_json::json!({
1322                            "data": data,
1323                            "encoding": encoding,
1324                            "message_bytes": bytes.len(),
1325                            "subprotocol": sub,
1326                        }),
1327                    )
1328                }
1329                Inbound::Ping(payload) => {
1330                    let (data, encoding) = encode_inbound_payload(payload);
1331                    Event::new(
1332                        &WEBSOCKET_PING_EVENT,
1333                        serde_json::json!({ "payload": data, "encoding": encoding }),
1334                    )
1335                }
1336            };
1337
1338            match call_llm(
1339                &ctx.llm_client,
1340                &ctx.app_state,
1341                ctx.server_id,
1342                Some(ctx.connection_id),
1343                &event,
1344                ctx.protocol.as_ref(),
1345            )
1346            .await
1347            {
1348                Ok(result) => {
1349                    for msg in result.messages {
1350                        let _ = ctx.status_tx.send(msg);
1351                    }
1352
1353                    let mut should_close = false;
1354                    let mut should_wait = false;
1355                    for r in &result.protocol_results {
1356                        match r {
1357                            ActionResult::CloseConnection => should_close = true,
1358                            ActionResult::WaitForMore => should_wait = true,
1359                            _ => {}
1360                        }
1361                    }
1362
1363                    // One `decision=` line per message. The silent case is the one that used
1364                    // to vanish entirely: a handler that returns nothing sends no frame, and
1365                    // this connection simply goes quiet — indistinguishable, in the log, from
1366                    // a handler that deliberately answered with nothing.
1367                    let log = Log::new(Some(&ctx.status_tx));
1368                    let subject =
1369                        format!("WebSocket {} {}", ctx.connection_id, event.event_type.id);
1370                    if result.raw_actions.is_empty() {
1371                        log.warn(format!(
1372                            "{} decision=model_silent: no action, so no frame was sent",
1373                            subject
1374                        ));
1375                    } else if !result.failures.is_empty() && result.protocol_results.is_empty() {
1376                        log.error(format!(
1377                            "{} decision=fail_closed_bad_action: {}",
1378                            subject,
1379                            result
1380                                .failures
1381                                .iter()
1382                                .map(|f| format!("{}: {}", f.action, f.error))
1383                                .collect::<Vec<_>>()
1384                                .join("; ")
1385                        ));
1386                    } else if should_close {
1387                        log.info(format!(
1388                            "{} decision=model_reject: the handler closed the connection",
1389                            subject
1390                        ));
1391                    } else {
1392                        log.info(format!("{} decision=model_answer", subject));
1393                    }
1394
1395                    if should_wait {
1396                        let mut data = ctx.data.lock().await;
1397                        data.pending = Some(next);
1398                        data.state = HandlerState::Accumulating;
1399                        Log::new(Some(&ctx.status_tx)).debug(format!(
1400                            "Holding a message from {} until more arrives",
1401                            ctx.connection_id
1402                        ));
1403                        return;
1404                    }
1405                    if should_close {
1406                        let mut data = ctx.data.lock().await;
1407                        data.state = HandlerState::Idle;
1408                        data.queued.clear();
1409                        return;
1410                    }
1411                }
1412                Err(e) => {
1413                    // Do not reset to Idle and write nothing: the peer would wait for a reply
1414                    // that is never coming. 1011 is the RFC's "the server hit an unexpected
1415                    // condition"; 1013 ("try again later") is the one the RFC gives for a
1416                    // temporarily saturated server, so an overloaded backend tells a client to
1417                    // back off rather than to record a permanent fault. The reason string is a
1418                    // fixed literal in both cases — a close reason is peer-visible, and the
1419                    // backend's error text belongs in the line below it, not on the wire.
1420                    let overloaded = crate::llm::is_overload_error(&e);
1421                    let (code, tag) = if overloaded {
1422                        (1013u16, "fail_closed_llm_overloaded")
1423                    } else {
1424                        (1011u16, "fail_closed_llm_error")
1425                    };
1426                    Log::new(Some(&ctx.status_tx)).error(format!(
1427                        "WebSocket {} {} decision={}: handler failed, closing with {} ({})",
1428                        ctx.connection_id, event.event_type.id, tag, code, e
1429                    ));
1430                    let _ = ctx.out_tx.send(WsOut::Close {
1431                        code,
1432                        reason: "handler failed".to_string(),
1433                    });
1434                    let mut data = ctx.data.lock().await;
1435                    data.state = HandlerState::Idle;
1436                    data.queued.clear();
1437                    return;
1438                }
1439            }
1440        }
1441    }
1442
1443    /// Emit `websocket_close` for a client-initiated close.
1444    async fn handle_close(ctx: Arc<ConnCtx>, code: u16, reason: String) {
1445        let event = Event::new(
1446            &WEBSOCKET_CLOSE_EVENT,
1447            serde_json::json!({ "code": code, "reason": reason }),
1448        );
1449        match call_llm(
1450            &ctx.llm_client,
1451            &ctx.app_state,
1452            ctx.server_id,
1453            Some(ctx.connection_id),
1454            &event,
1455            ctx.protocol.as_ref(),
1456        )
1457        .await
1458        {
1459            Ok(result) => {
1460                for msg in result.messages {
1461                    let _ = ctx.status_tx.send(msg);
1462                }
1463            }
1464            Err(e) => {
1465                // `llm_error_notice_only`: `websocket_close` fires because the peer has
1466                // already closed, so there is nothing left to refuse and nothing to send.
1467                // Raised from DEBUG to WARN — a backend failure that the operator can only
1468                // see by turning debug logging on is a backend failure nobody sees.
1469                Log::new(Some(&ctx.status_tx)).warn(format!(
1470                    "WebSocket close handler failed on {} decision=llm_error_notice_only: {}",
1471                    ctx.connection_id, e
1472                ));
1473            }
1474        }
1475    }
1476
1477    async fn cleanup(
1478        app_state: &Arc<AppState>,
1479        server_id: ServerId,
1480        connection_id: ConnectionId,
1481        status_tx: &mpsc::UnboundedSender<String>,
1482    ) {
1483        actions::unregister_connection(connection_id);
1484        app_state
1485            .close_connection_on_server(server_id, connection_id)
1486            .await;
1487        let _ = status_tx.send("__UPDATE_UI__".to_string());
1488    }
1489}
1490
1491/// The outcome of the `websocket_handshake` event.
1492enum HandshakeDecision {
1493    Accept { subprotocol: Option<String> },
1494    Reject { status: u16, reason: String },
1495}
1496
1497/// Read bytes until the blank line that terminates an HTTP request head.
1498///
1499/// Returns the head (without the terminating CRLFCRLF) and any bytes that followed it in the
1500/// same read, which belong to the WebSocket stream.
1501async fn read_request_head(socket: &mut TcpStream) -> Result<(Vec<u8>, Vec<u8>)> {
1502    let mut buf: Vec<u8> = Vec::with_capacity(1024);
1503    let mut chunk = [0u8; 1024];
1504
1505    loop {
1506        if let Some(pos) = find_head_end(&buf) {
1507            let leftover = buf.split_off(pos + 4);
1508            buf.truncate(pos);
1509            return Ok((buf, leftover));
1510        }
1511        if buf.len() > MAX_REQUEST_HEAD {
1512            anyhow::bail!("HTTP request head exceeded {MAX_REQUEST_HEAD} bytes");
1513        }
1514        let n = socket
1515            .read(&mut chunk)
1516            .await
1517            .context("read failed while waiting for the HTTP request head")?;
1518        if n == 0 {
1519            anyhow::bail!("peer closed before sending a complete HTTP request head");
1520        }
1521        buf.extend_from_slice(&chunk[..n]);
1522    }
1523}
1524
1525/// Index of the `\r\n\r\n` that ends the request head.
1526fn find_head_end(buf: &[u8]) -> Option<usize> {
1527    buf.windows(4).position(|w| w == b"\r\n\r\n")
1528}