netget/server/udp/mod.rs
1//! UDP server implementation for raw UDP stack
2pub mod actions;
3
4use crate::server::connection::ConnectionId;
5use actions::UdpProtocol;
6use anyhow::Result;
7use std::collections::HashMap;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::net::UdpSocket;
11use tokio::sync::{mpsc, Mutex};
12
13use crate::llm::action_helper::call_llm;
14use crate::llm::ollama_client::OllamaClient;
15use crate::logging::emit::Log;
16use crate::protocol::Event;
17use crate::state::app_state::AppState;
18use actions::UDP_DATAGRAM_RECEIVED_EVENT;
19
20/// UDP server that manages UDP connections
21pub struct UdpServer;
22
23impl UdpServer {
24 /// Spawn UDP server with action-based LLM handling
25 pub async fn spawn_with_llm_actions(
26 listen_addr: SocketAddr,
27 llm_client: OllamaClient,
28 app_state: Arc<AppState>,
29 status_tx: mpsc::UnboundedSender<String>,
30 server_id: crate::state::ServerId,
31 ) -> Result<SocketAddr> {
32 let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
33 let local_addr = socket.local_addr()?;
34 Log::new(Some(&status_tx)).info(format!("UDP server listening on {}", local_addr));
35
36 let protocol = Arc::new(UdpProtocol::with_socket(socket.clone()));
37
38 let task_registrar = app_state.clone();
39 let accept_handle = tokio::spawn(async move {
40 let mut buffer = vec![0u8; 65535]; // Maximum UDP datagram size
41
42 loop {
43 match socket.recv_from(&mut buffer).await {
44 Ok((n, peer_addr)) => {
45 let data = buffer[..n].to_vec();
46 let connection_id =
47 ConnectionId::new(app_state.get_next_unified_id().await);
48
49 // Add connection to ServerInstance (UDP "connection" = recent peer)
50 use crate::state::server::{
51 ConnectionState as ServerConnectionState, ConnectionStatus,
52 ProtocolConnectionInfo,
53 };
54 let now = crate::utils::clock::Instant::now();
55 let conn_state = ServerConnectionState {
56 id: connection_id,
57 remote_addr: peer_addr,
58 local_addr,
59 bytes_sent: 0,
60 bytes_received: n as u64,
61 packets_sent: 0,
62 packets_received: 1,
63 last_activity: now,
64 status: ConnectionStatus::Active,
65 status_changed_at: now,
66 protocol_info: ProtocolConnectionInfo::empty(),
67 };
68 app_state
69 .add_connection_to_server(server_id, conn_state)
70 .await;
71 let _ = status_tx.send("__UPDATE_UI__".to_string());
72
73 // Datagram summary + full payload are FileOnly: the
74 // udp_datagram_received event template surfaces the datagram to
75 // the TUI, so streaming the payload here too would duplicate it
76 // on the unbounded channel.
77 {
78 let log = Log::new(Some(&status_tx));
79 if data
80 .iter()
81 .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
82 {
83 let data_str = String::from_utf8_lossy(&data);
84 let preview = if data_str.len() > 100 {
85 format!("{}...", &data_str[..100])
86 } else {
87 data_str.to_string()
88 };
89 log.debug(format!(
90 "UDP received {} bytes from {}: {}",
91 n, peer_addr, preview
92 ));
93 log.trace(format!("UDP data (text): {:?}", data_str));
94 } else {
95 log.debug(format!(
96 "UDP received {} bytes from {} (binary data)",
97 n, peer_addr
98 ));
99 log.trace(format!("UDP data (hex): {}", hex::encode(&data)));
100 }
101 }
102
103 let llm_clone = llm_client.clone();
104 let state_clone = app_state.clone();
105 let status_clone = status_tx.clone();
106 let socket_clone = socket.clone();
107 let protocol_clone = protocol.clone();
108
109 // Tracked, not detached: stop_server must abort this task too.
110 let task_owner = app_state.clone();
111 task_owner.spawn_server_task(server_id, async move {
112 let log = Log::new(Some(&status_clone));
113 // Render the payload the way the model will have to reply in.
114 //
115 // This used to be `format!("{:?}", data)` on a Vec<u8>, i.e. the
116 // model was shown `[72, 101, 108, 108, 111]` for "Hello" and had
117 // to reconstruct the text from decimal byte codes. Printable
118 // payloads are now shown as text and binary ones as hex, and
119 // data_encoding tells the model which it is looking at - and so
120 // which `encoding` to use in send_udp_response.
121 const PREVIEW_BYTES: usize = 200;
122 let shown = &data[..data.len().min(PREVIEW_BYTES)];
123 let printable = !shown.is_empty()
124 && shown
125 .iter()
126 .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace());
127 let (data_encoding, mut data_preview) = if printable {
128 ("text", String::from_utf8_lossy(shown).into_owned())
129 } else {
130 ("hex", hex::encode(shown))
131 };
132 if data.len() > PREVIEW_BYTES {
133 data_preview.push_str("...");
134 }
135
136 let event = Event::new(
137 &UDP_DATAGRAM_RECEIVED_EVENT,
138 serde_json::json!({
139 "peer_address": peer_addr.to_string(),
140 "data_length": data.len(),
141 "data_encoding": data_encoding,
142 "data_preview": data_preview
143 }),
144 );
145
146 log.debug(format!("UDP calling LLM for datagram from {}", peer_addr));
147
148 match call_llm(
149 &llm_clone,
150 &state_clone,
151 server_id,
152 None,
153 &event,
154 protocol_clone.as_ref(),
155 )
156 .await
157 {
158 Ok(execution_result) => {
159 for message in &execution_result.messages {
160 log.info(message);
161 }
162
163 log.debug(format!(
164 "UDP got {} protocol results",
165 execution_result.protocol_results.len()
166 ));
167
168 // Classify *before* `protocol_results` is consumed below.
169 //
170 // UDP puts nothing on the wire in three of the five
171 // outcomes, so the log is the only place they can be told
172 // apart: a model that answered `ignore_datagram`, a model
173 // that answered nothing at all, and an action the executor
174 // refused are all the same silence on the socket - and the
175 // same silence a dead backend produces. Without a
176 // `decision=` token an operator has no way to tell which
177 // of the four they are looking at.
178 let action_types: Vec<&str> = execution_result
179 .raw_actions
180 .iter()
181 .filter_map(|a| a.get("type").and_then(|v| v.as_str()))
182 .collect();
183 let explicit_ignore = action_types.contains(&"ignore_datagram");
184 let dispatched_elsewhere =
185 action_types.contains(&"send_to_address");
186 let failure_summary = execution_result
187 .failures
188 .iter()
189 .map(|f| format!("{}: {}", f.action, f.error))
190 .collect::<Vec<_>>()
191 .join("; ");
192 let mut sent = 0usize;
193 let mut send_failed = false;
194
195 for protocol_result in execution_result.protocol_results {
196 if let Some(output_data) =
197 protocol_result.get_all_output().first()
198 {
199 if let Err(e) =
200 socket_clone.send_to(output_data, peer_addr).await
201 {
202 send_failed = true;
203 log.error(format!(
204 "Failed to send UDP response: {}",
205 e
206 ));
207 } else {
208 sent += 1;
209 // Sent summary + payload are FileOnly; the
210 // access line below carries the TUI.
211 if output_data.iter().all(|&b| {
212 b.is_ascii_graphic() || b.is_ascii_whitespace()
213 }) {
214 let data_str =
215 String::from_utf8_lossy(output_data);
216 let preview = if data_str.len() > 100 {
217 format!("{}...", &data_str[..100])
218 } else {
219 data_str.to_string()
220 };
221 log.debug(format!(
222 "UDP sent {} bytes to {}: {}",
223 output_data.len(),
224 peer_addr,
225 preview
226 ));
227 log.trace(format!(
228 "UDP sent (text): {:?}",
229 data_str
230 ));
231 } else {
232 log.debug(format!(
233 "UDP sent {} bytes to {} (binary data)",
234 output_data.len(),
235 peer_addr
236 ));
237 log.trace(format!(
238 "UDP sent (hex): {}",
239 hex::encode(output_data)
240 ));
241 }
242
243 // Keep the pseudo-connection's counters live.
244 // This server registered every datagram with
245 // bytes_received set and then never touched
246 // the row again, so the rail's ↑ column and
247 // last_activity stayed at their initial values
248 // for the whole life of the entry — the module
249 // CLAUDE.md claimed "Response increments
250 // packets_sent and bytes_sent" and nothing did.
251 state_clone
252 .update_connection_stats(
253 server_id,
254 connection_id,
255 None,
256 Some(output_data.len() as u64),
257 None,
258 Some(1),
259 )
260 .await;
261
262 log.info(format!(
263 "UDP response to {} ({} bytes)",
264 peer_addr,
265 output_data.len()
266 ));
267 }
268 } else {
269 log.debug("UDP protocol result has no output data");
270 }
271 }
272
273 // One decision line per datagram, whatever happened.
274 let subject = format!(
275 "UDP datagram from {} ({})",
276 peer_addr, connection_id
277 );
278 if sent > 0 {
279 log.info(format!(
280 "{} decision=model_answer ({} reply datagram(s))",
281 subject, sent
282 ));
283 } else if send_failed {
284 log.error(format!(
285 "{} decision=protocol_error: the model answered but \
286 the reply could not be put on the socket",
287 subject
288 ));
289 } else if !failure_summary.is_empty() {
290 log.error(format!(
291 "{} decision=fail_closed_bad_action: nothing was sent \
292 because the model's action(s) could not be executed \
293 ({})",
294 subject, failure_summary
295 ));
296 } else if explicit_ignore {
297 log.info(format!(
298 "{} decision=model_reject (ignore_datagram: the model \
299 chose to send nothing)",
300 subject
301 ));
302 } else if dispatched_elsewhere {
303 log.info(format!(
304 "{} decision=model_answer (send_to_address: the \
305 datagram went to another address, nothing back to \
306 this peer)",
307 subject
308 ));
309 } else {
310 log.warn(format!(
311 "{} decision=model_silent: the model was asked and \
312 returned no usable action, so nothing was sent - \
313 bare UDP has no error form",
314 subject
315 ));
316 }
317 }
318 Err(e) => {
319 // Deliberately silent, and the one protocol in this group
320 // where that is the right answer.
321 //
322 // Bare UDP (RFC 768) has no error frame, no transaction
323 // identifier and no application semantics: this server does
324 // not know what the datagram meant, so it has nothing to
325 // say back that the peer could parse. Anything we invented
326 // would be indistinguishable from a real reply in whatever
327 // protocol the peer is actually speaking - a *worse*
328 // failure than silence, because the peer would act on it.
329 // Dropping the datagram is also normal, expected UDP
330 // behaviour that every UDP client already handles.
331 //
332 // A protocol layered on UDP that *does* have an error form
333 // must answer with it: see DNS (SERVFAIL), STUN (500
334 // Binding Error Response) and NTP (Kiss-o'-Death) in this
335 // same tree. So the silence is logged loudly here rather
336 // than left to be inferred.
337 // ERROR on both channels by design (see above and the
338 // module CLAUDE.md): the silent drop must be explained.
339 // tests/server/udp/llm_failure_test.rs asserts the phrase
340 // "no reply possible: bare UDP has no error form" reaches
341 // the status stream, so keep it here verbatim.
342 //
343 // The `decision=` token is what separates this silence from
344 // the model's own `ignore_datagram` silence and from a
345 // model that simply said nothing. On the wire all three are
346 // identical, so the distinction can only live in the log.
347 let decision = match crate::utils::wire_failure::WireFailure::classify(&e) {
348 crate::utils::wire_failure::WireFailure::Overloaded => {
349 "fail_closed_llm_overloaded"
350 }
351 crate::utils::wire_failure::WireFailure::Unavailable => {
352 "fail_closed_llm_error"
353 }
354 };
355 log.error(format!(
356 "UDP LLM call failed for datagram from {} ({}) decision={}: {} - no reply possible: bare UDP has no error form",
357 peer_addr, connection_id, decision, e
358 ));
359 if crate::llm::is_overload_error(&e) {
360 log.warn(format!(
361 "UDP datagram from {} dropped: LLM capacity exhausted",
362 peer_addr
363 ));
364 }
365 }
366 }
367 }).await;
368 }
369 Err(e) => {
370 Log::new(Some(&status_tx)).error(format!("UDP receive error: {}", e));
371 }
372 }
373 }
374 });
375
376 task_registrar
377 .register_server_task(server_id, accept_handle)
378 .await;
379
380 Ok(local_addr)
381 }
382}
383
384/// Shared UDP socket for sending responses
385pub type SharedUdpSocket = Arc<Mutex<Arc<UdpSocket>>>;
386
387/// Map from connection ID to peer address for UDP responses
388pub type UdpPeerMap = Arc<Mutex<HashMap<ConnectionId, SocketAddr>>>;