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}