Skip to main content

netget/server/quic/
mod.rs

1//! QUIC server implementation using Quinn
2pub mod actions;
3
4use crate::utils::quic::{
5    ConnectionGuard, EndpointGuard, EXCHANGE_TIMEOUT, HANDSHAKE_TIMEOUT, IDLE_TIMEOUT,
6    MAX_CONNECTIONS, MAX_STREAMS, RAW_ALPN,
7};
8use anyhow::{Context, Result};
9use bytes::Bytes;
10use futures::{stream::FuturesUnordered, StreamExt};
11use quinn::{Endpoint, ServerConfig};
12use std::collections::HashMap;
13use std::net::SocketAddr;
14use std::sync::Arc;
15use tokio::sync::{mpsc, Mutex};
16use tracing::{debug, error};
17
18use super::connection::ConnectionId;
19use crate::llm::action_helper::call_llm;
20use crate::llm::ollama_client::OllamaClient;
21use crate::llm::ActionResult;
22use crate::logging::emit::Log;
23use crate::protocol::Event;
24use crate::state::app_state::AppState;
25use actions::{
26    QuicProtocol, QUIC_CONNECTION_OPENED_EVENT, QUIC_DATAGRAM_RECEIVED_EVENT,
27    QUIC_DATA_RECEIVED_EVENT, QUIC_STREAM_OPENED_EVENT,
28};
29
30/// Stream state for LLM processing
31#[derive(Debug, Clone, PartialEq)]
32enum StreamState {
33    Idle,
34    Processing,
35    Accumulating,
36}
37
38/// Per-stream data for LLM handling.
39///
40/// There is deliberately no `memory` field. One used to sit here, read at the top of
41/// each pass and written straight back unchanged at the bottom — it was never put into
42/// an event, never passed to `call_llm`, and never read by anything else. Per-server
43/// memory lives in `AppState` and `call_llm` reads it from there.
44struct StreamData {
45    state: StreamState,
46    queued_data: Vec<u8>,
47    send_stream: Arc<Mutex<quinn::SendStream>>,
48}
49
50/// How many bytes may pile up for one stream while an LLM call is in flight.
51///
52/// Data that arrives mid-call is queued and merged into the next turn, and nothing else
53/// bounds it: a peer that keeps writing while the model thinks grows this `Vec` for as
54/// long as it likes. The queue only exists to hold a message that was split across
55/// reads, so a megabyte is far past any legitimate use — and the merged buffer is what
56/// ends up in an LLM prompt, where it is useless long before that.
57///
58/// Over the cap the stream is reset with the raw application's overload code.
59pub const MAX_STREAM_QUEUE_BYTES: usize = 1024 * 1024;
60/// Application-local codes for netget-quic. These retain their historical numeric
61/// values for existing raw peers; they do not imply HTTP/3 framing or semantics.
62const RAW_INTERNAL_ERROR: u32 = 0x0102;
63const RAW_EXCESSIVE_LOAD: u32 = 0x0107;
64
65fn wire_failure_code(failure: crate::utils::WireFailure) -> quinn::VarInt {
66    quinn::VarInt::from_u32(match failure {
67        crate::utils::WireFailure::Overloaded => RAW_EXCESSIVE_LOAD,
68        crate::utils::WireFailure::Unavailable => RAW_INTERNAL_ERROR,
69    })
70}
71
72/// QUIC server that listens for incoming connections
73pub struct QuicServer;
74
75impl QuicServer {
76    /// Spawn the QUIC server with integrated LLM actions
77    pub async fn spawn_with_llm_actions(
78        listen_addr: SocketAddr,
79        llm_client: OllamaClient,
80        app_state: Arc<AppState>,
81        status_tx: mpsc::UnboundedSender<String>,
82        server_id: crate::state::ServerId,
83        tls_config: Option<Arc<rustls::ServerConfig>>,
84    ) -> Result<SocketAddr> {
85        // Use provided TLS config or generate default
86        let mut server_crypto = match tls_config {
87            Some(config) => (*config).clone(),
88            None => {
89                // Generate default self-signed certificate
90                let config = crate::server::tls_cert_manager::generate_default_tls_config()
91                    .context("Failed to generate default TLS config")?;
92                (*config).clone()
93            }
94        };
95
96        // Raw stream bytes must not advertise HTTP/3.
97        server_crypto.alpn_protocols = vec![RAW_ALPN.to_vec()];
98        server_crypto.max_early_data_size = 0;
99
100        // Create QUIC server configuration
101        let mut server_config = ServerConfig::with_crypto(Arc::new(
102            quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto)
103                .context("Failed to create QUIC crypto config")?,
104        ));
105
106        server_config.transport_config(crate::utils::quic::transport(
107            IDLE_TIMEOUT,
108            MAX_STREAMS as u32,
109            0,
110        ));
111        server_config.migration(false);
112
113        // Bind endpoint
114        let endpoint = EndpointGuard(
115            Endpoint::server(server_config, listen_addr)
116                .context("Failed to create QUIC endpoint")?,
117        );
118        let local_addr = endpoint
119            .0
120            .local_addr()
121            .context("Failed to get local address")?;
122
123        Log::new(Some(&status_tx)).info(format!(
124            "QUIC server (action-based) listening on {}",
125            local_addr
126        ));
127
128        let task_registrar = app_state.clone();
129        let accept_handle = tokio::spawn(async move {
130            let mut sessions = FuturesUnordered::new();
131            loop {
132                tokio::select! {
133                    incoming=endpoint.0.accept()=>{
134                        let Some(incoming)=incoming else{break;};
135                        if sessions.len()>=MAX_CONNECTIONS {incoming.refuse();continue;}
136                        sessions.push(Self::session(incoming,local_addr,llm_client.clone(),app_state.clone(),status_tx.clone(),server_id));
137                    }
138                    _=sessions.next(),if !sessions.is_empty()=>{}
139                }
140            }
141        });
142        task_registrar
143            .register_server_task(server_id, accept_handle)
144            .await;
145        Ok(local_addr)
146    }
147
148    async fn session(
149        incoming: quinn::Incoming,
150        local_addr: SocketAddr,
151        llm_client: OllamaClient,
152        app_state: Arc<AppState>,
153        status_tx: mpsc::UnboundedSender<String>,
154        server_id: crate::state::ServerId,
155    ) {
156        let connection = match tokio::time::timeout(HANDSHAKE_TIMEOUT, incoming).await {
157            Ok(Ok(c)) => ConnectionGuard(c),
158            _ => return,
159        };
160        let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
161        use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
162        let now = crate::utils::clock::Instant::now();
163        app_state
164            .add_connection_to_server(
165                server_id,
166                ConnectionState {
167                    id: connection_id,
168                    remote_addr: connection.0.remote_address(),
169                    local_addr,
170                    bytes_sent: 0,
171                    bytes_received: 0,
172                    packets_sent: 0,
173                    packets_received: 0,
174                    last_activity: now,
175                    status: ConnectionStatus::Active,
176                    status_changed_at: now,
177                    protocol_info: ProtocolConnectionInfo::new(
178                        serde_json::json!({"alpn":"netget-quic"}),
179                    ),
180                },
181            )
182            .await;
183        let _ = status_tx.send("__UPDATE_UI__".into());
184        let protocol = Arc::new(QuicProtocol::new());
185        let event = Event::new(&QUIC_CONNECTION_OPENED_EVENT, serde_json::json!({}));
186        // A slow connection notification must not consume a connection slot forever.
187        let _ = tokio::time::timeout(
188            EXCHANGE_TIMEOUT,
189            call_llm(
190                &llm_client,
191                &app_state,
192                server_id,
193                Some(connection_id),
194                &event,
195                protocol.as_ref(),
196            ),
197        )
198        .await;
199        let streams = Arc::new(Mutex::new(HashMap::new()));
200        let mut work = FuturesUnordered::new();
201        // One datagram turn at a time. While it runs, datagrams wait in Quinn's bounded
202        // receive buffer and the oldest are dropped past it — which RFC 9221 permits, and
203        // which is the only bound that cannot be outrun by a sender.
204        let mut datagram_turn = FuturesUnordered::new();
205        loop {
206            tokio::select! {
207                datagram=connection.0.read_datagram(),if datagram_turn.is_empty()=>{
208                    let Ok(datagram)=datagram else{break;};
209                    datagram_turn.push(Self::handle_datagram(
210                        datagram,
211                        connection.0.clone(),
212                        connection_id,
213                        server_id,
214                        llm_client.clone(),
215                        app_state.clone(),
216                        status_tx.clone(),
217                        protocol.clone(),
218                    ));
219                }
220                _=datagram_turn.next(),if !datagram_turn.is_empty()=>{}
221                result=connection.0.accept_bi(),if work.len()<MAX_STREAMS=>{
222                    let Ok((send,recv))=result else{break;};
223                    tracing::trace!(stream = %recv.id(), "QUIC stream accepted");
224                    let stream_map=streams.clone();let llm=llm_client.clone();let state=app_state.clone();let status=status_tx.clone();let proto=protocol.clone();
225                    let stopped=send.stopped();
226                    work.push(async move {
227                        // A sibling stream may already be queued for the fair AppState
228                        // lock. Awaiting it in the accept branch would stop polling that
229                        // sibling forever; keep all stream state waits in the collection.
230                        let stream_id=ConnectionId::new(state.get_next_unified_id().await);
231                        tokio::select! {
232                            _=stopped=>{},
233                            _=tokio::time::timeout(EXCHANGE_TIMEOUT,Self::handle_stream_with_actions(stream_id,connection_id,server_id,send,recv,llm,state,status,stream_map.clone(),proto))=>{}
234                        }
235                        // A timed-out future may have inserted a stream before cancellation.
236                        if let Some(stream)=stream_map.lock().await.remove(&stream_id) {
237                            let _=stream.send_stream.lock().await.reset(quinn::VarInt::from_u32(3));
238                        }
239                    });
240                }
241                _=work.next(),if !work.is_empty()=>{}
242                _=connection.0.closed()=>break,
243            }
244        }
245        drop(work);
246        drop(datagram_turn);
247        drop(streams);
248        app_state
249            .close_connection_on_server(server_id, connection_id)
250            .await;
251        let _ = status_tx.send("__UPDATE_UI__".into());
252    }
253
254    /// Answer one RFC 9221 DATAGRAM frame. The handler's `send_quic_datagram` actions go back
255    /// as DATAGRAM frames on the same connection; anything larger than the peer accepts is
256    /// refused here, where the connection's limit is known.
257    #[allow(clippy::too_many_arguments)]
258    async fn handle_datagram(
259        datagram: Bytes,
260        connection: quinn::Connection,
261        connection_id: ConnectionId,
262        server_id: crate::state::ServerId,
263        llm_client: OllamaClient,
264        app_state: Arc<AppState>,
265        status_tx: mpsc::UnboundedSender<String>,
266        protocol: Arc<QuicProtocol>,
267    ) {
268        app_state
269            .update_connection_stats(
270                server_id,
271                connection_id,
272                Some(datagram.len() as u64),
273                None,
274                Some(1),
275                None,
276            )
277            .await;
278        let (data, encoding) = actions::encode_quic_payload(&datagram);
279        let event = Event::new(
280            &QUIC_DATAGRAM_RECEIVED_EVENT,
281            serde_json::json!({
282                "data": data,
283                "encoding": encoding,
284                "bytes": datagram.len(),
285                "max_datagram_size": connection.max_datagram_size(),
286            }),
287        );
288        let result = tokio::time::timeout(
289            EXCHANGE_TIMEOUT,
290            call_llm(
291                &llm_client,
292                &app_state,
293                server_id,
294                Some(connection_id),
295                &event,
296                protocol.as_ref(),
297            ),
298        )
299        .await;
300        let results = match result {
301            Ok(Ok(r)) => r.protocol_results,
302            Ok(Err(e)) => {
303                // A datagram has no reply channel to fail on: answering it is optional, so a
304                // backend failure is silence, logged with the reason.
305                let decision = if crate::llm::is_overload_error(&e) {
306                    "fail_closed_llm_overloaded"
307                } else {
308                    "fail_closed_llm_error"
309                };
310                Log::new(Some(&status_tx)).error(format!(
311                    "QUIC datagram on connection {connection_id} decision={decision}: {e}"
312                ));
313                return;
314            }
315            Err(_) => {
316                Log::new(Some(&status_tx)).warn(format!(
317                    "QUIC datagram on connection {connection_id} decision=fail_closed_timeout"
318                ));
319                return;
320            }
321        };
322        for result in results {
323            let ActionResult::Custom { name, data } = result else {
324                continue;
325            };
326            if name != actions::DATAGRAM_RESULT {
327                continue;
328            }
329            let Some(bytes) = data.as_str().and_then(|h| hex::decode(h).ok()) else {
330                continue;
331            };
332            let n = bytes.len();
333            match connection.send_datagram(Bytes::from(bytes)) {
334                Ok(()) => {
335                    app_state
336                        .update_connection_stats(
337                            server_id,
338                            connection_id,
339                            None,
340                            Some(n as u64),
341                            None,
342                            Some(1),
343                        )
344                        .await;
345                }
346                Err(e) => Log::new(Some(&status_tx)).warn(format!(
347                    "QUIC datagram of {n} bytes not sent on connection {connection_id} \
348                     (peer limit {:?}): {e}",
349                    connection.max_datagram_size()
350                )),
351            }
352        }
353    }
354
355    /// Handle a QUIC stream with LLM actions.
356    ///
357    /// Statistics are recorded against `connection_id`, not `stream_id`: only the
358    /// QUIC connection is registered in the server's connection map, so an update
359    /// keyed by a stream id would silently do nothing. Keeping `last_activity`
360    /// fresh matters here more than for HTTP/1.1 — `cleanup_old_connections`
361    /// evicts a connection idle for 10s, and a QUIC connection carrying a slow
362    /// stream easily exceeds that.
363    #[allow(clippy::too_many_arguments)]
364    async fn handle_stream_with_actions(
365        stream_id: ConnectionId,
366        connection_id: ConnectionId,
367        server_id: crate::state::ServerId,
368        send_stream: quinn::SendStream,
369        mut recv_stream: quinn::RecvStream,
370        llm_client: OllamaClient,
371        app_state: Arc<AppState>,
372        status_tx: mpsc::UnboundedSender<String>,
373        streams: Arc<Mutex<HashMap<ConnectionId, StreamData>>>,
374        protocol: Arc<QuicProtocol>,
375    ) {
376        let send_stream_arc = Arc::new(Mutex::new(send_stream));
377
378        // Add stream to tracking
379        streams.lock().await.insert(
380            stream_id,
381            StreamData {
382                state: StreamState::Idle,
383                queued_data: Vec::new(),
384                send_stream: send_stream_arc.clone(),
385            },
386        );
387
388        // Notify LLM of new stream
389        let event = Event::new(
390            &QUIC_STREAM_OPENED_EVENT,
391            serde_json::json!({
392                "stream_id": stream_id.to_string()
393            }),
394        );
395
396        match call_llm(
397            &llm_client,
398            &app_state,
399            server_id,
400            Some(connection_id),
401            &event,
402            protocol.as_ref(),
403        )
404        .await
405        {
406            Ok(execution_result) => {
407                for msg in execution_result.messages {
408                    let _ = status_tx.send(msg);
409                }
410
411                // Handle any initial actions
412                for protocol_result in execution_result.protocol_results {
413                    if matches!(protocol_result, ActionResult::CloseConnection) {
414                        let _ = send_stream_arc.lock().await.finish();
415                        let _ = recv_stream.stop(0u32.into());
416                        streams.lock().await.remove(&stream_id);
417                        return;
418                    }
419                    if let ActionResult::Output(output_data) = protocol_result {
420                        let write_result = {
421                            let mut send = send_stream_arc.lock().await;
422                            send.write_all(&output_data).await
423                        };
424                        if let Err(e) = write_result {
425                            error!("Failed to send initial data on stream {}: {}", stream_id, e);
426                        } else {
427                            Log::new(Some(&status_tx)).debug(format!(
428                                "QUIC sent {} bytes on stream {}",
429                                output_data.len(),
430                                stream_id
431                            ));
432                            app_state
433                                .update_connection_stats(
434                                    server_id,
435                                    connection_id,
436                                    None,
437                                    Some(output_data.len() as u64),
438                                    None,
439                                    Some(1),
440                                )
441                                .await;
442                        }
443                    }
444                }
445            }
446            Err(e) => {
447                // Non-fatal, and deliberately not a reset: whatever the model would have
448                // said here is an unsolicited greeting, not a reply the peer is blocked on
449                // — the client writes first on a raw QUIC stream. Killing the stream over a
450                // missing banner would be worse than not sending one, and the next
451                // `quic_data_received` gets its own chance (and its own reset on failure).
452                // Same shape as TCP's banner path.
453                Log::new(Some(&status_tx)).warn(format!(
454                    "LLM error on stream {} opened (decision=llm_error, no greeting sent, \
455                     stream left open): {}",
456                    stream_id, e
457                ));
458            }
459        }
460
461        // Read loop
462        let mut buffer = vec![0u8; 65536];
463        loop {
464            match recv_stream.read(&mut buffer).await {
465                Ok(Some(n)) => {
466                    let data = Bytes::copy_from_slice(&buffer[..n]);
467
468                    // Count every inbound read exactly once, before dispatch: the
469                    // handler below may queue the data instead of processing it.
470                    app_state
471                        .update_connection_stats(
472                            server_id,
473                            connection_id,
474                            Some(n as u64),
475                            None,
476                            Some(1),
477                            None,
478                        )
479                        .await;
480
481                    // Data summary + full payload are FileOnly: the quic_data_received
482                    // event template renders the equivalent lines to the TUI, so streaming
483                    // the payload here too would duplicate it and load the unbounded
484                    // status channel.
485                    let log = Log::new(Some(&status_tx));
486                    if data
487                        .iter()
488                        .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
489                    {
490                        let data_str = String::from_utf8_lossy(&data);
491                        // `truncate_for_log` rather than `&s[..100]`: the ASCII guard above
492                        // makes byte-slicing safe *today*, but the guard and the slice are
493                        // twenty lines apart and a byte-index cut on a `&str` is the panic
494                        // shape CLAUDE.md records (fixed tree-wide in b9aa1058).
495                        let preview = crate::utils::truncate_for_log(&data_str, 100);
496                        log.debug(format!(
497                            "QUIC received {} bytes on stream {}: {}",
498                            n, stream_id, preview
499                        ));
500                        log.trace(format!("QUIC data (text): {:?}", data_str));
501                    } else {
502                        log.debug(format!(
503                            "QUIC received {} bytes on stream {} (binary data)",
504                            n, stream_id
505                        ));
506                        log.trace(format!("QUIC data (hex): {}", hex::encode(&data)));
507                    }
508
509                    // Handle data directly (await to ensure processing completes before stream removal)
510                    Self::handle_data_with_actions(
511                        stream_id,
512                        connection_id,
513                        server_id,
514                        data,
515                        llm_client.clone(),
516                        app_state.clone(),
517                        status_tx.clone(),
518                        streams.clone(),
519                        protocol.clone(),
520                    )
521                    .await;
522                    if !streams.lock().await.contains_key(&stream_id) {
523                        break;
524                    }
525                }
526                Ok(None) => {
527                    // Stream finished
528                    Log::new(Some(&status_tx)).info(format!("QUIC stream {} finished", stream_id));
529                    streams.lock().await.remove(&stream_id);
530                    break;
531                }
532                Err(e) => {
533                    Log::new(Some(&status_tx))
534                        .error(format!("Read error on stream {}: {}", stream_id, e));
535                    streams.lock().await.remove(&stream_id);
536                    break;
537                }
538            }
539        }
540        let _ = send_stream_arc.lock().await.finish();
541    }
542
543    /// Handle data received on a stream with LLM actions
544    #[allow(clippy::too_many_arguments)]
545    async fn handle_data_with_actions(
546        stream_id: ConnectionId,
547        connection_id: ConnectionId,
548        server_id: crate::state::ServerId,
549        data: Bytes,
550        llm_client: OllamaClient,
551        app_state: Arc<AppState>,
552        status_tx: mpsc::UnboundedSender<String>,
553        streams: Arc<Mutex<HashMap<ConnectionId, StreamData>>>,
554        protocol: Arc<QuicProtocol>,
555    ) {
556        // Check stream state
557        let current_state = {
558            let streams_lock = streams.lock().await;
559            if let Some(stream_data) = streams_lock.get(&stream_id) {
560                stream_data.state.clone()
561            } else {
562                return; // Stream not found
563            }
564        };
565
566        // If processing, queue the data — bounded, so a peer that keeps writing while the
567        // model thinks cannot grow this buffer without limit.
568        if current_state == StreamState::Processing {
569            let over_limit = {
570                let mut streams_lock = streams.lock().await;
571                match streams_lock.get_mut(&stream_id) {
572                    Some(stream) => {
573                        if stream.queued_data.len() + data.len() > MAX_STREAM_QUEUE_BYTES {
574                            true
575                        } else {
576                            stream.queued_data.extend_from_slice(&data);
577                            false
578                        }
579                    }
580                    // Stream went away while we were between locks.
581                    None => return,
582                }
583            };
584
585            if over_limit {
586                let log = Log::new(Some(&status_tx));
587                log.warn(format!(
588                    "Stream {} queued more than {} bytes while an LLM call was in flight                      (decision=refused_queue_too_large, raw_error_code=0x{:x}); resetting",
589                    stream_id, MAX_STREAM_QUEUE_BYTES, RAW_EXCESSIVE_LOAD
590                ));
591                let send_stream = {
592                    let streams_lock = streams.lock().await;
593                    streams_lock.get(&stream_id).map(|s| s.send_stream.clone())
594                };
595                if let Some(send_stream) = send_stream {
596                    let mut send = send_stream.lock().await;
597                    let _ = send.reset(quinn::VarInt::from_u32(RAW_EXCESSIVE_LOAD));
598                }
599                streams.lock().await.remove(&stream_id);
600                return;
601            }
602
603            Log::new(Some(&status_tx)).debug(format!(
604                "Queued {} bytes for stream {}",
605                data.len(),
606                stream_id
607            ));
608            return;
609        }
610
611        // Merge any queued data with new data. The stream can disappear between
612        // the state check above and this lock (peer reset, close_this_stream on
613        // another task), so this must not unwrap.
614        let mut all_data = {
615            let mut streams_lock = streams.lock().await;
616            let Some(stream_data) = streams_lock.get_mut(&stream_id) else {
617                debug!(
618                    "Stream {} closed before its data could be processed",
619                    stream_id
620                );
621                return;
622            };
623            stream_data.state = StreamState::Processing;
624            let mut merged = stream_data.queued_data.clone();
625            merged.extend_from_slice(&data);
626            stream_data.queued_data.clear();
627            Bytes::from(merged)
628        };
629
630        loop {
631            // Get send_stream for context
632            let send_stream = {
633                let streams_lock = streams.lock().await;
634                streams_lock.get(&stream_id).map(|s| s.send_stream.clone())
635            };
636
637            let Some(send_stream) = send_stream else {
638                return; // Stream not found
639            };
640
641            // Format data for the event parameter. Printable ASCII is passed
642            // through as text, anything else is hex-encoded. `encoding` names
643            // which of the two the model is looking at, and uses the same
644            // vocabulary send_quic_data accepts, so the model can echo the
645            // payload back byte-for-byte by passing both fields straight
646            // through. (It used to say "text" where the outbound side had no
647            // encoding field at all, which made binary un-echoable.)
648            let (data_str, encoding) = crate::server::quic::actions::encode_quic_payload(&all_data);
649
650            // Create data received event
651            let event = Event::new(
652                &QUIC_DATA_RECEIVED_EVENT,
653                serde_json::json!({
654                    "stream_id": stream_id.to_string(),
655                    "data": data_str,
656                    "encoding": encoding
657                }),
658            );
659
660            // Call LLM
661            match call_llm(
662                &llm_client,
663                &app_state,
664                server_id,
665                Some(connection_id),
666                &event,
667                protocol.as_ref(),
668            )
669            .await
670            {
671                Ok(execution_result) => {
672                    debug!("LLM QUIC response received");
673
674                    // Display messages
675                    for msg in execution_result.messages {
676                        let _ = status_tx.send(msg);
677                    }
678
679                    // Handle protocol results
680                    let action_count = execution_result.protocol_results.len();
681                    let mut should_close = false;
682                    let mut should_wait = false;
683
684                    for protocol_result in execution_result.protocol_results {
685                        match protocol_result {
686                            ActionResult::Output(output_data) => {
687                                let write_result = {
688                                    let mut send = send_stream.lock().await;
689                                    send.write_all(&output_data).await
690                                };
691                                if let Err(e) = write_result {
692                                    error!(
693                                        "Failed to send response on stream {}: {}",
694                                        stream_id, e
695                                    );
696                                } else {
697                                    app_state
698                                        .update_connection_stats(
699                                            server_id,
700                                            connection_id,
701                                            None,
702                                            Some(output_data.len() as u64),
703                                            None,
704                                            Some(1),
705                                        )
706                                        .await;
707                                    // Sent-data summary + payload are FileOnly: the
708                                    // send_quic_data action template already reports the send
709                                    // to the TUI.
710                                    let log = Log::new(Some(&status_tx));
711                                    if output_data
712                                        .iter()
713                                        .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
714                                    {
715                                        let data_str = String::from_utf8_lossy(&output_data);
716                                        let preview =
717                                            crate::utils::truncate_for_log(&data_str, 100);
718                                        log.debug(format!(
719                                            "QUIC sent {} bytes on stream {}: {}",
720                                            output_data.len(),
721                                            stream_id,
722                                            preview
723                                        ));
724                                        log.trace(format!("QUIC sent (text): {:?}", data_str));
725                                    } else {
726                                        log.debug(format!(
727                                            "QUIC sent {} bytes on stream {} (binary data)",
728                                            output_data.len(),
729                                            stream_id
730                                        ));
731                                        log.trace(format!(
732                                            "QUIC sent (hex): {}",
733                                            hex::encode(&output_data)
734                                        ));
735                                    }
736                                    log.debug(format!(
737                                        "Sent {} bytes on stream {}",
738                                        output_data.len(),
739                                        stream_id
740                                    ));
741                                }
742                            }
743                            ActionResult::CloseConnection => {
744                                should_close = true;
745                            }
746                            ActionResult::WaitForMore => {
747                                should_wait = true;
748                            }
749                            _ => {}
750                        }
751                    }
752
753                    // Handle wait_for_more
754                    if should_wait {
755                        streams
756                            .lock()
757                            .await
758                            .entry(stream_id)
759                            .and_modify(|s| s.state = StreamState::Accumulating);
760                        Log::new(Some(&status_tx))
761                            .debug(format!("Waiting for more data on stream {}", stream_id));
762                        return;
763                    }
764
765                    // Handle close_stream
766                    if should_close {
767                        streams.lock().await.remove(&stream_id);
768                        Log::new(Some(&status_tx)).info(format!("Closed stream {}", stream_id));
769                        return;
770                    }
771
772                    // The model was reached and chose to say nothing. That is a real
773                    // answer for a stream protocol — the peer may simply not be owed a
774                    // reply yet — and it is NOT the same as the backend failing, so it is
775                    // tagged separately in the log and leaves the stream open.
776                    if action_count == 0 {
777                        Log::new(Some(&status_tx)).debug(format!(
778                            "No actions returned for QUIC data on stream {} \
779                             (decision=model_no_actions); stream left open",
780                            stream_id
781                        ));
782                    }
783
784                    // Check for queued data
785                    let has_queued = {
786                        let streams_lock = streams.lock().await;
787                        streams_lock
788                            .get(&stream_id)
789                            .map(|s| !s.queued_data.is_empty())
790                            .unwrap_or(false)
791                    };
792
793                    if has_queued {
794                        // Take the queue and make it the next iteration's payload.
795                        // Leaving it in place re-sent the SAME bytes to the model on
796                        // every pass and never emptied the queue: one response per
797                        // iteration, forever, for a single line of input.
798                        let queued = {
799                            let mut streams_lock = streams.lock().await;
800                            match streams_lock.get_mut(&stream_id) {
801                                Some(stream) => std::mem::take(&mut stream.queued_data),
802                                None => return,
803                            }
804                        };
805                        if queued.is_empty() {
806                            streams
807                                .lock()
808                                .await
809                                .entry(stream_id)
810                                .and_modify(|s| s.state = StreamState::Idle);
811                            return;
812                        }
813                        all_data = Bytes::from(queued);
814                    } else {
815                        // Go to Idle state
816                        streams
817                            .lock()
818                            .await
819                            .entry(stream_id)
820                            .and_modify(|s| s.state = StreamState::Idle);
821                        return;
822                    }
823                }
824                Err(e) => {
825                    // Say *something* on the wire. This path used to reset the stream
826                    // state to Idle and write nothing, so a peer that had just sent a
827                    // request blocked until its own timeout with no indication anything
828                    // had gone wrong.
829                    //
830                    // The category, and only the category, reaches the peer: a
831                    // RESET_STREAM carrying an application-local error code. `e` is logged and
832                    // never rendered into anything the peer can read.
833                    let failure = crate::utils::WireFailure::classify(&e);
834                    let code = wire_failure_code(failure);
835                    let log = Log::new(Some(&status_tx));
836                    log.warn(format!(
837                        "LLM error for QUIC data on stream {} (decision=llm_error, category={}, \
838                         raw_error_code=0x{:x}): {}",
839                        stream_id,
840                        failure.text(),
841                        code.into_inner(),
842                        e
843                    ));
844
845                    // RESET_STREAM drops anything still buffered for this stream. In a
846                    // request/response exchange the peer has already consumed the previous
847                    // round's reply before sending the request we just failed on, so the
848                    // loss window is the reply we are not going to produce anyway.
849                    {
850                        let mut send = send_stream.lock().await;
851                        let _ = send.reset(code);
852                    }
853                    streams.lock().await.remove(&stream_id);
854                    log.info(format!(
855                        "Reset QUIC stream {} after LLM error (decision=llm_error)",
856                        stream_id
857                    ));
858                    return;
859                }
860            }
861        }
862    }
863}