netget/server/svn/mod.rs
1//! SVN (Subversion) server implementation
2pub mod actions;
3pub mod wire;
4
5use crate::llm::action_helper::call_llm;
6use crate::llm::ollama_client::OllamaClient;
7use crate::logging::emit::Log;
8use crate::protocol::Event;
9use crate::server::connection::ConnectionId;
10use crate::state::app_state::AppState;
11use crate::utils::WireFailure;
12use actions::{
13 SVN_AUTH_RESPONSE_EVENT, SVN_CLIENT_CAPABILITIES_EVENT, SVN_COMMAND_EVENT, SVN_GREETING_EVENT,
14};
15use anyhow::Result;
16use std::net::SocketAddr;
17use std::sync::Arc;
18use tokio::io::AsyncWriteExt;
19use tokio::net::TcpListener;
20use tokio::sync::{mpsc, Mutex};
21
22/// How long to wait for the client's answer to the greeting.
23///
24/// `svnserve` speaks first: the server's greeting goes out, and a real `svn` client answers
25/// with its capabilities immediately — it has nothing to decide and nobody to ask. A peer that
26/// has taken the greeting and gone quiet is holding a task and a connection slot on the
27/// strength of nothing at all.
28const FIRST_COMMAND_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
29
30/// How long to wait for a *further* command once one has been answered.
31///
32/// ra_svn after the greeting is strictly request/response — the client sends the next command
33/// as soon as it has consumed the last reply — so seconds of silence normally means the client
34/// is gone. The exception, and the reason this is minutes rather than seconds, is that `svn`
35/// prompts for credentials on the user's terminal *mid-session*: a human typing a password is
36/// a legitimate multi-minute pause with the connection live and nothing on the wire. Three
37/// minutes covers that and still bounds the hold.
38const IDLE_BETWEEN_COMMANDS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
39
40/// Concurrent connections this server admits.
41const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
42
43/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
44///
45/// ra_svn's own `( failure ( ( apr-err message file line ) ) )` tuple, built the same way
46/// [`svn_failure_tuple`] builds it, with apr-err 210003 — the code this file already uses for
47/// "the backend is at capacity" rather than "the request was wrong". A client that reads it
48/// where it expected a greeting reports malformed data rather than parsing the reason, which
49/// is a real limit of speaking before the greeting; what it buys is an operator, a packet
50/// capture and a `nc` session that can all see *why* in the bytes, instead of a bare reset
51/// indistinguishable from a crashed server.
52const CONNECTION_CAP_REFUSAL: &[u8] =
53 b"( failure ( ( 210003 28:netget: too many connections 0: 0 ) ) )\n";
54
55/// Largest single ra_svn message this server will buffer, in bytes.
56///
57/// An unbounded read lets one peer grow the process without limit — no authentication, no
58/// negotiation, just an open socket. The cap is applied by [`wire::ItemReader::read_item`] to
59/// the whole tuple *and* to any string length the peer declares, before a byte is allocated
60/// for it. A real ra_svn command tuple is a few hundred bytes; 64 KiB is far above anything
61/// the subset implemented here can produce and far below a memory problem.
62pub const MAX_COMMAND_BYTES: u64 = 64 * 1024;
63
64/// The trivial auth-request that precedes every command response in a real ra_svn session.
65///
66/// ra_svn's client calls `handle_auth_request` after *each* command it sends: it reads one
67/// `( success ( mechs:list realm:string ) )` and returns immediately, without replying, when
68/// the mechanism list is empty. So an already-authenticated session's every response is two
69/// tuples — this one, then the answer. Measured against `svn` 1.14.5: omit it and the client
70/// reads this tuple where the answer should be and reports `E210004: Malformed network data`.
71///
72/// It is written by the server rather than by an action because it carries no decision: it is
73/// the same bytes every time, and making the model emit it would be making the model do
74/// framing. It is written **only** for a peer that completed the handshake, so a `nc` session
75/// — or this repo's own mocked e2e tests, which never send a capability tuple — still sees
76/// exactly the bytes its action produced.
77const COMMAND_AUTH_PREFIX: &[u8] = b"( success ( ( ) 0: ) ) ";
78
79pub struct SvnServer;
80
81impl SvnServer {
82 /// Spawn SVN server with integrated LLM actions
83 pub async fn spawn_with_llm_actions(
84 listen_addr: SocketAddr,
85 llm_client: OllamaClient,
86 app_state: Arc<AppState>,
87 status_tx: mpsc::UnboundedSender<String>,
88 server_id: crate::state::ServerId,
89 ) -> Result<SocketAddr> {
90 let listener = TcpListener::bind(listen_addr).await?;
91 let local_addr = listener.local_addr()?;
92
93 Log::new(Some(&status_tx)).info(format!("SVN server listening on {}", local_addr));
94
95 let protocol = Arc::new(actions::SvnProtocol::new());
96
97 let task_registrar = app_state.clone();
98 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
99 let accept_handle = tokio::spawn(async move {
100 loop {
101 match crate::server::accept_bounded::accept_bounded(
102 &listener,
103 &limiter,
104 CONNECTION_CAP_REFUSAL,
105 "SVN",
106 Some(&status_tx),
107 )
108 .await
109 {
110 Ok((socket, peer_addr, permit)) => {
111 let connection_id =
112 ConnectionId::new(app_state.get_next_unified_id().await);
113
114 // Add connection to ServerInstance
115 use crate::state::server::{
116 ConnectionState as ServerConnectionState, ConnectionStatus,
117 };
118 let now = crate::utils::clock::Instant::now();
119 let conn_state = ServerConnectionState {
120 id: connection_id,
121 remote_addr: peer_addr,
122 local_addr,
123 bytes_sent: 0,
124 bytes_received: 0,
125 packets_sent: 0,
126 packets_received: 0,
127 last_activity: now,
128 status: ConnectionStatus::Active,
129 status_changed_at: now,
130 protocol_info: crate::state::server::ProtocolConnectionInfo::new(
131 serde_json::json!({
132 "protocol": "svn",
133 "authenticated": false,
134 "repository_url": null,
135 "commands_processed": 0
136 }),
137 ),
138 };
139 app_state
140 .add_connection_to_server(server_id, conn_state)
141 .await;
142 let _ = status_tx.send("__UPDATE_UI__".to_string());
143
144 Log::new(Some(&status_tx))
145 .info(format!("SVN client connected from {}", peer_addr));
146
147 let llm_clone = llm_client.clone();
148 let state_clone = app_state.clone();
149 let status_clone = status_tx.clone();
150 let protocol_clone = protocol.clone();
151 let connection_id_clone = connection_id;
152
153 // Tracked, not detached: stop_server must abort this task too.
154 let task_owner = app_state.clone();
155 task_owner
156 .spawn_server_task(server_id, async move {
157 // Held for the life of the connection, so the cap counts live
158 // sessions rather than accepts.
159 let _permit = permit;
160 handle_svn_connection(
161 socket,
162 peer_addr,
163 llm_clone,
164 state_clone,
165 status_clone,
166 server_id,
167 protocol_clone,
168 connection_id_clone,
169 )
170 .await
171 })
172 .await;
173 }
174 Err(e) => {
175 Log::new(Some(&status_tx)).error(format!("SVN accept error: {}", e));
176 break;
177 }
178 }
179 }
180 });
181
182 task_registrar
183 .register_server_task(server_id, accept_handle)
184 .await;
185
186 Ok(local_addr)
187 }
188}
189
190/// What the server expects from the peer next.
191///
192/// ra_svn messages are not self-describing: `( ANONYMOUS ( … ) )` and `( get-dir ( … ) )` are
193/// the same shape, and only the position in the handshake tells them apart. The one exception
194/// is the client's capability tuple, which begins with a **number** where every other client
195/// message begins with a word — so a peer that never sends one (a `nc` session, this repo's own
196/// mocked e2e tests) is taken straight into the command loop instead of being held at a
197/// handshake it does not know about.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199enum Phase {
200 /// The greeting has gone out. Next is the client's capability tuple — or, from a peer that
201 /// skips the handshake, a command.
202 AwaitingCapabilities,
203 /// The capability tuple arrived and was answered. ra_svn says the next message is the
204 /// client's choice of mechanism.
205 AwaitingAuthResponse,
206 /// Commands from here on.
207 Session,
208}
209
210/// One connection's identity, so the write helpers below take a handful of arguments rather
211/// than a paragraph of them.
212#[derive(Clone, Copy)]
213struct ConnRef<'a> {
214 app_state: &'a Arc<AppState>,
215 server_id: crate::state::ServerId,
216 connection_id: ConnectionId,
217 peer_addr: SocketAddr,
218}
219
220/// What happened to one handler's answer on the wire.
221struct Delivered {
222 /// At least one byte was written.
223 wrote: bool,
224 /// The handler asked to hang up.
225 close_requested: bool,
226 /// The socket failed; the connection is finished either way.
227 write_failed: bool,
228}
229
230/// Write one execution result's outputs to the peer.
231///
232/// `prefix` is written once, immediately before the first output, and is how the trivial
233/// auth-request that precedes every command response in a real session gets onto the wire
234/// without every action having to know about it.
235async fn deliver_results(
236 results: Vec<crate::llm::actions::protocol_trait::ActionResult>,
237 prefix: Option<&[u8]>,
238 write_half: &Arc<Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
239 conn: &ConnRef<'_>,
240 log: &Log<'_>,
241) -> Delivered {
242 let ConnRef {
243 app_state,
244 server_id,
245 connection_id,
246 peer_addr,
247 } = *conn;
248 use crate::llm::actions::protocol_trait::ActionResult;
249
250 let mut delivered = Delivered {
251 wrote: false,
252 close_requested: false,
253 write_failed: false,
254 };
255
256 for result in results {
257 match result {
258 ActionResult::Output(output_data) => {
259 let mut payload = Vec::new();
260 if !delivered.wrote {
261 if let Some(prefix) = prefix {
262 payload.extend_from_slice(prefix);
263 }
264 }
265 payload.extend_from_slice(&output_data);
266 delivered.wrote = true;
267
268 {
269 let mut writer = write_half.lock().await;
270 if let Err(e) = writer.write_all(&payload).await {
271 log.error(format!("SVN write error to {}: {}", peer_addr, e));
272 delivered.write_failed = true;
273 return delivered;
274 }
275 let _ = writer.flush().await;
276 }
277
278 app_state
279 .update_connection_stats(
280 server_id,
281 connection_id,
282 None,
283 Some(payload.len() as u64),
284 None,
285 Some(1),
286 )
287 .await;
288
289 // Summary + full payload FileOnly: the send_svn_* action template already
290 // reports the send to the TUI.
291 log.debug(format!("SVN sent {} bytes to {}", payload.len(), peer_addr));
292 log.trace(format!(
293 "SVN response: {}",
294 String::from_utf8_lossy(&payload)
295 ));
296 }
297 ActionResult::CloseConnection => {
298 delivered.close_requested = true;
299 }
300 _ => {}
301 }
302 }
303
304 delivered
305}
306
307/// The client's capability tuple, as event data for the model.
308///
309/// `( version:number ( cap:word … ) url:string ? ra-client:string ( ? client:string ) )`
310fn capabilities_event_data(
311 item: &wire::Item,
312 client_ip: &str,
313) -> (serde_json::Value, Option<String>) {
314 let elements: &[wire::Item] = match item {
315 wire::Item::List(items) => items,
316 _ => &[],
317 };
318
319 let version = match elements.first() {
320 Some(wire::Item::Number(n)) => *n,
321 _ => 0,
322 };
323 let capabilities: Vec<String> = match elements.get(1) {
324 Some(wire::Item::List(caps)) => caps
325 .iter()
326 .map(|c| match c {
327 wire::Item::Word(w) => w.clone(),
328 other => other.to_string(),
329 })
330 .collect(),
331 _ => Vec::new(),
332 };
333 let text = |i: Option<&wire::Item>| -> Option<String> {
334 match i {
335 Some(wire::Item::Str(bytes)) => Some(String::from_utf8_lossy(bytes).into_owned()),
336 Some(wire::Item::Word(w)) => Some(w.clone()),
337 _ => None,
338 }
339 };
340 let url = text(elements.get(2));
341 let ra_client = text(elements.get(3));
342
343 (
344 serde_json::json!({
345 "version": version,
346 "capabilities": capabilities,
347 "url": url.clone().unwrap_or_default(),
348 "ra_client": ra_client.unwrap_or_default(),
349 "client_ip": client_ip,
350 }),
351 url,
352 )
353}
354
355/// The client's answer to the auth-request: `( mech:word [ ( token:string ) ] )`.
356///
357/// The token is a counted string that may contain anything — the real `svn` client's
358/// `ANONYMOUS` token is base64 with a trailing newline, which is precisely the case a line
359/// reader cannot survive. It is trimmed for display only; the bytes are the model's to judge.
360fn auth_event_data(item: &wire::Item, url: Option<&str>, client_ip: &str) -> serde_json::Value {
361 let elements: &[wire::Item] = match item {
362 wire::Item::List(items) => items,
363 _ => &[],
364 };
365
366 let mechanism = match elements.first() {
367 Some(wire::Item::Word(w)) => w.clone(),
368 Some(other) => other.to_string(),
369 None => String::new(),
370 };
371 let token = match elements.get(1) {
372 Some(wire::Item::List(inner)) => match inner.first() {
373 Some(wire::Item::Str(bytes)) => String::from_utf8_lossy(bytes).trim().to_string(),
374 Some(other) => other.to_string(),
375 None => String::new(),
376 },
377 Some(wire::Item::Str(bytes)) => String::from_utf8_lossy(bytes).trim().to_string(),
378 _ => String::new(),
379 };
380
381 serde_json::json!({
382 "mechanism": mechanism,
383 "token": token,
384 "url": url.unwrap_or_default(),
385 "client_ip": client_ip,
386 })
387}
388
389/// A command tuple, as event data.
390///
391/// ra_svn spells a command `( command-name:word ( params… ) )`, so the params list is unwrapped
392/// into `args` — that is the shape the model is being asked to answer, and leaving it wrapped
393/// would make every argument index one deeper than the protocol document says. A tuple that is
394/// not that shape (`( get-latest-rev )`, or a peer improvising) keeps its remaining elements.
395///
396/// Public for `fuzz/fuzz_targets/svn_tuple.rs`: this is where a parsed item is walked
397/// recursively (`to_string`, `to_json`), so it is the half of the path `MAX_TUPLE_DEPTH`
398/// protects — the parser itself is iterative.
399pub fn command_event_data(item: &wire::Item, client_ip: &str) -> (String, serde_json::Value) {
400 let elements: &[wire::Item] = match item {
401 wire::Item::List(items) => items,
402 _ => std::slice::from_ref(item),
403 };
404
405 let command = match elements.first() {
406 Some(wire::Item::Word(w)) => w.clone(),
407 Some(other) => other.to_string(),
408 None => String::new(),
409 };
410
411 let rest = elements.get(1..).unwrap_or(&[]);
412 let args: Vec<serde_json::Value> = match rest {
413 [wire::Item::List(params)] => params.iter().map(wire::Item::to_json).collect(),
414 other => other.iter().map(wire::Item::to_json).collect(),
415 };
416
417 (
418 command.clone(),
419 serde_json::json!({
420 "command_line": item.to_string(),
421 "command": command,
422 "args": args,
423 "client_ip": client_ip,
424 }),
425 )
426}
427
428/// Does this look like `( MECHANISM ( token ) )` rather than a command?
429///
430/// ra_svn's mechanism names come from the SASL registry and are upper-case (`ANONYMOUS`,
431/// `CRAM-MD5`, `EXTERNAL`); its command names are lower-case and hyphenated (`get-latest-rev`,
432/// `stat`, `check-path`). The distinction is needed because a handler may answer the
433/// capability tuple with an **empty** mechanism list, which ra_svn reads as "no authentication
434/// required" — the client then sends no auth response at all and its next message is a
435/// command. Position alone would misread that as a mechanism choice.
436fn looks_like_auth_response(item: &wire::Item) -> bool {
437 match item.head() {
438 Some(wire::Item::Word(word)) => {
439 !word.is_empty() && !word.chars().any(|c| c.is_ascii_lowercase())
440 }
441 _ => false,
442 }
443}
444
445async fn handle_svn_connection(
446 socket: tokio::net::TcpStream,
447 peer_addr: SocketAddr,
448 llm_client: OllamaClient,
449 app_state: Arc<AppState>,
450 status_tx: mpsc::UnboundedSender<String>,
451 server_id: crate::state::ServerId,
452 protocol: Arc<actions::SvnProtocol>,
453 connection_id: ConnectionId,
454) {
455 // Split into an owned read half and a shared write half. The write half is an
456 // Arc<Mutex<..>> so the reader below and the dashboard's peer-command task both
457 // write through the same guarded sink (CLAUDE.md "Connection I/O").
458 let (reader, write_half) = tokio::io::split(socket);
459 let write_half = Arc::new(Mutex::new(write_half));
460 // Framed on tuple structure, not on newlines: see `wire.rs`.
461 let mut items = wire::ItemReader::new(reader);
462 let log = Log::new(Some(&status_tx));
463 let client_ip = peer_addr.ip().to_string();
464 let conn = ConnRef {
465 app_state: &app_state,
466 server_id,
467 connection_id,
468 peer_addr,
469 };
470
471 // Peer messaging: the dashboard can inject an action (send_svn_success,
472 // close_connection, ...) into THIS connection through the same executor the
473 // model's actions use. The task ends when the handle is dropped by one of the
474 // close paths below (each calls remove_peer_handle) or by server teardown.
475 let peer_rx = crate::server::peer_support::register_peer_channel(
476 &app_state,
477 server_id,
478 connection_id.as_u32(),
479 )
480 .await;
481 crate::server::peer_support::spawn_peer_command_task(
482 peer_rx,
483 protocol.clone(),
484 app_state.clone(),
485 server_id,
486 connection_id.as_u32(),
487 write_half.clone(),
488 status_tx.clone(),
489 );
490
491 // Send greeting event to LLM
492 let greeting_event = Event::new(
493 &SVN_GREETING_EVENT,
494 serde_json::json!({ "client_ip": client_ip }),
495 );
496
497 log.debug(format!("SVN sending greeting to {}", peer_addr));
498
499 match call_llm(
500 &llm_client,
501 &app_state,
502 server_id,
503 Some(connection_id),
504 &greeting_event,
505 protocol.as_ref(),
506 )
507 .await
508 {
509 Ok(execution_result) => {
510 // Display messages from LLM
511 for message in &execution_result.messages {
512 log.info(message);
513 }
514
515 // Three outcomes have to stay apart in the log: the handler answered on the
516 // wire, it asked to hang up, or it answered nothing at all. Only the third
517 // shares a shape with an LLM error, and the `decision=` tags below keep even
518 // those two greppable apart.
519 let delivered = deliver_results(
520 execution_result.protocol_results,
521 None,
522 &write_half,
523 &conn,
524 &log,
525 )
526 .await;
527
528 if delivered.write_failed {
529 app_state
530 .remove_peer_handle(server_id, connection_id.as_u32())
531 .await;
532 return;
533 }
534
535 if delivered.close_requested {
536 log.info(format!(
537 "SVN greeting from {} decision=model_close",
538 peer_addr
539 ));
540 close_svn_connection(&app_state, server_id, connection_id, &status_tx).await;
541 return;
542 }
543
544 if !delivered.wrote {
545 // The handler deliberately said nothing (a static handler with no actions,
546 // or a human answering "with nothing" at the dashboard before injecting
547 // bytes through `[ message this peer ]`). That is a real answer, so the
548 // connection stays open — but it is logged distinctly from the error path
549 // below so an operator can tell the two apart.
550 log.warn(format!(
551 "SVN greeting from {} decision=no_action (nothing written; peer is \
552 waiting for a greeting)",
553 peer_addr
554 ));
555 }
556 }
557 Err(e) => {
558 // The backend failed. The peer gets an svn `failure` tuple carrying only a
559 // category — never the error, which names the backend, the model and our own
560 // retry machinery. The error itself goes to the log and the status stream.
561 let failure = WireFailure::classify(&e);
562 log.error(format!(
563 "SVN greeting for {} decision=fail_closed_llm_error category={} error: {}",
564 peer_addr,
565 if failure.is_overloaded() {
566 "overloaded"
567 } else {
568 "unavailable"
569 },
570 e
571 ));
572 write_svn_failure(
573 &write_half,
574 &app_state,
575 server_id,
576 connection_id,
577 &log,
578 failure,
579 None,
580 )
581 .await;
582 close_svn_connection(&app_state, server_id, connection_id, &status_tx).await;
583 return;
584 }
585 }
586
587 // Main message loop.
588 let mut phase = Phase::AwaitingCapabilities;
589 // True once the peer has completed the ra_svn handshake, which is what decides whether a
590 // command response carries the trivial auth-request prefix.
591 let mut ra_svn_session = false;
592 // The URL the client asked for, carried from the capability tuple into the auth event so a
593 // handler can answer with a repository root the client will accept.
594 let mut session_url: Option<String> = None;
595 // False until this client has sent something, which is what separates "took the greeting
596 // and went quiet" from "is in a session".
597 let mut answered_one = false;
598
599 loop {
600 // Bounded in size *and* in time. `read_item` refuses at MAX_COMMAND_BYTES, at
601 // MAX_TUPLE_DEPTH nested lists and at any declared string length that would exceed
602 // the budget; the deadline stops a peer that sends no bytes at all.
603 //
604 // The deadline wraps this read alone. The LLM round-trip that answers the message, and
605 // a `manual` rule parking it for a human (`src/state/intercepts.rs`, 300s by default),
606 // both happen below once a whole tuple has arrived — neither is inside the deadline, so
607 // neither can be cut short by it.
608 let read_timeout = if answered_one {
609 IDLE_BETWEEN_COMMANDS_TIMEOUT
610 } else {
611 FIRST_COMMAND_READ_TIMEOUT
612 };
613
614 let read =
615 match tokio::time::timeout(read_timeout, items.read_item(MAX_COMMAND_BYTES)).await {
616 Ok(read) => read,
617 Err(_) => {
618 log.info(format!(
619 "SVN client {} sent nothing for {}s; closing idle connection",
620 peer_addr,
621 read_timeout.as_secs()
622 ));
623 break;
624 }
625 };
626 answered_one = true;
627
628 let (item, consumed) = match read {
629 Ok(Some(message)) => message,
630 Ok(None) => {
631 log.info(format!("SVN client {} disconnected", peer_addr));
632 break;
633 }
634 Err(wire::WireError::Io(e)) => {
635 log.error(format!("SVN read error from {}: {}", peer_addr, e));
636 break;
637 }
638 Err(wire::WireError::UnexpectedEof) => {
639 log.info(format!(
640 "SVN client {} closed the connection mid-tuple",
641 peer_addr
642 ));
643 break;
644 }
645 Err(e) => {
646 // A refused frame is answered in svn's own vocabulary — apr-err 210004,
647 // SVN_ERR_RA_SVN_MALFORMED_DATA — with a fixed string. The reason the bound
648 // fired goes to the log; the peer learns only that the message was refused.
649 log.warn(format!(
650 "SVN refused a message from {} decision=fail_closed_framing: {}",
651 peer_addr, e
652 ));
653 write_svn_malformed(&write_half, &app_state, server_id, connection_id, &log).await;
654 break;
655 }
656 };
657
658 app_state
659 .update_connection_stats(
660 server_id,
661 connection_id,
662 Some(consumed),
663 None,
664 Some(1),
665 None,
666 )
667 .await;
668
669 log.debug(format!(
670 "SVN received {} bytes from {}",
671 consumed, peer_addr
672 ));
673 log.trace(format!("SVN message: {}", item));
674
675 // Which of the three client messages this is. Only the capability tuple can be
676 // recognised from its own bytes (it opens with a number); the rest is position.
677 let looks_like_capabilities = matches!(item.head(), Some(wire::Item::Number(_)));
678 let (event, label, prefix): (Event, String, Option<&[u8]>) = match phase {
679 Phase::AwaitingCapabilities if looks_like_capabilities => {
680 let (data, url) = capabilities_event_data(&item, &client_ip);
681 session_url = url;
682 phase = Phase::AwaitingAuthResponse;
683 (
684 Event::new(&SVN_CLIENT_CAPABILITIES_EVENT, data),
685 "capabilities".to_string(),
686 None,
687 )
688 }
689 Phase::AwaitingAuthResponse if looks_like_auth_response(&item) => {
690 let data = auth_event_data(&item, session_url.as_deref(), &client_ip);
691 phase = Phase::Session;
692 // The handshake is complete from here: every command response the client
693 // reads is preceded by an auth-request it consumes silently.
694 ra_svn_session = true;
695 (
696 Event::new(&SVN_AUTH_RESPONSE_EVENT, data),
697 "auth-response".to_string(),
698 None,
699 )
700 }
701 _ => {
702 if phase == Phase::AwaitingAuthResponse {
703 // The handler answered the capability tuple with an empty mechanism list,
704 // which ra_svn reads as "no authentication required": the client sends no
705 // auth response and its next message is already a command. The handshake
706 // is complete all the same, so command replies still need the prefix.
707 ra_svn_session = true;
708 }
709 phase = Phase::Session;
710 let (command, data) = command_event_data(&item, &client_ip);
711 (
712 Event::new(&SVN_COMMAND_EVENT, data),
713 command,
714 if ra_svn_session {
715 Some(COMMAND_AUTH_PREFIX)
716 } else {
717 None
718 },
719 )
720 }
721 };
722
723 log.debug(format!("SVN calling LLM for {} from {}", label, peer_addr));
724
725 match call_llm(
726 &llm_client,
727 &app_state,
728 server_id,
729 Some(connection_id),
730 &event,
731 protocol.as_ref(),
732 )
733 .await
734 {
735 Ok(execution_result) => {
736 // Display messages from LLM
737 for message in &execution_result.messages {
738 log.info(message);
739 }
740
741 log.debug(format!(
742 "SVN got {} protocol results",
743 execution_result.protocol_results.len()
744 ));
745
746 let delivered = deliver_results(
747 execution_result.protocol_results,
748 prefix,
749 &write_half,
750 &conn,
751 &log,
752 )
753 .await;
754
755 if delivered.write_failed {
756 app_state
757 .remove_peer_handle(server_id, connection_id.as_u32())
758 .await;
759 return;
760 }
761
762 if delivered.close_requested {
763 log.debug(format!(
764 "SVN '{}' from {} decision=model_close",
765 label, peer_addr
766 ));
767 break;
768 }
769
770 if !delivered.wrote {
771 // Answered with nothing: a real answer (static handler with no
772 // actions, or a human choosing silence), kept distinct in the
773 // log from the backend-failure path below.
774 log.warn(format!(
775 "SVN '{}' from {} decision=no_action (nothing written)",
776 label, peer_addr
777 ));
778 }
779 }
780 Err(e) => {
781 // Same rule as the greeting: category on the wire, error in the
782 // log. Without this the peer sat blocked on a message it had
783 // already sent until its own timeout expired.
784 let failure = WireFailure::classify(&e);
785 log.error(format!(
786 "SVN '{}' from {} decision=fail_closed_llm_error category={} error: {}",
787 label,
788 peer_addr,
789 if failure.is_overloaded() {
790 "overloaded"
791 } else {
792 "unavailable"
793 },
794 e
795 ));
796 write_svn_failure(
797 &write_half,
798 &app_state,
799 server_id,
800 connection_id,
801 &log,
802 failure,
803 prefix,
804 )
805 .await;
806 break;
807 }
808 }
809 }
810
811 // Update connection status to closed. Reached by every loop `break` (EOF,
812 // read error, close_connection, LLM failure), so this is the one place the
813 // peer handle must be dropped — otherwise the rail keeps offering
814 // "message this peer" on a dead connection.
815 use crate::state::server::ConnectionStatus;
816 app_state
817 .remove_peer_handle(server_id, connection_id.as_u32())
818 .await;
819 app_state
820 .update_connection_status(server_id, connection_id, ConnectionStatus::Closed)
821 .await;
822 let _ = status_tx.send("__UPDATE_UI__".to_string());
823}
824
825/// Encode an svn `failure` tuple that carries a failure **category** and nothing else.
826///
827/// ra_svn lets the server answer any command — and the greeting itself — with
828/// `( failure ( ( apr-err:number message:string file:string line:number ) ) )`, so this is
829/// the protocol's own error shape rather than something invented here.
830///
831/// The two [`WireFailure`] categories map onto different apr error numbers so a client can
832/// tell "come back later" from "this request is not going to work":
833///
834/// - `Overloaded` -> 210003 `SVN_ERR_RA_SVN_IO_ERROR`, the transient transport-side failure
835/// - `Unavailable` -> 210000 `SVN_ERR_RA_SVN_CMD_ERR`, the generic command failure
836///
837/// The message is [`WireFailure::text`], a `&'static str`: no part of the underlying error
838/// — backend URL, model name, file path, anyhow chain — can reach the wire through here.
839fn svn_failure_tuple(failure: WireFailure) -> Vec<u8> {
840 let error_code = if failure.is_overloaded() {
841 210003
842 } else {
843 210000
844 };
845 let message = failure.text();
846 // Counted strings (`<len>:<bytes>`); the file field is the empty string `0:`.
847 format!(
848 "( failure ( ( {} {}:{} 0: 0 ) ) )\n",
849 error_code,
850 message.len(),
851 message
852 )
853 .into_bytes()
854}
855
856/// Write the failure tuple to the peer, best effort, and count the bytes.
857async fn write_svn_failure(
858 write_half: &Arc<Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
859 app_state: &Arc<AppState>,
860 server_id: crate::state::ServerId,
861 connection_id: ConnectionId,
862 log: &Log<'_>,
863 failure: WireFailure,
864 prefix: Option<&[u8]>,
865) {
866 // In a real session the client is waiting for the trivial auth-request before the answer,
867 // so a bare failure tuple would be read as that auth-request and desynchronise the stream:
868 // the peer would report malformed data instead of the refusal it was actually sent.
869 let mut payload = prefix.unwrap_or(&[]).to_vec();
870 payload.extend_from_slice(&svn_failure_tuple(failure));
871 let written = {
872 let mut writer = write_half.lock().await;
873 match writer.write_all(&payload).await {
874 Ok(()) => {
875 let _ = writer.flush().await;
876 true
877 }
878 Err(e) => {
879 log.debug(format!("SVN could not write failure tuple: {}", e));
880 false
881 }
882 }
883 };
884 if written {
885 app_state
886 .update_connection_stats(
887 server_id,
888 connection_id,
889 None,
890 Some(payload.len() as u64),
891 None,
892 Some(1),
893 )
894 .await;
895 }
896}
897
898/// Refuse a message the framing layer would not accept.
899///
900/// apr-err 210004 is `SVN_ERR_RA_SVN_MALFORMED_DATA` — svn's own name for "that was not a
901/// tuple I can read", which is exactly what a depth bomb, an oversized message or a stray
902/// paren is. The string is a byte literal for the same reason [`WireFailure::text`] returns
903/// `&'static str`: nothing about *why* the bound fired may reach the peer.
904const MALFORMED_REFUSAL: &[u8] =
905 b"( failure ( ( 210004 33:netget: refused malformed message 0: 0 ) ) )\n";
906
907async fn write_svn_malformed(
908 write_half: &Arc<Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
909 app_state: &Arc<AppState>,
910 server_id: crate::state::ServerId,
911 connection_id: ConnectionId,
912 log: &Log<'_>,
913) {
914 let written = {
915 let mut writer = write_half.lock().await;
916 match writer.write_all(MALFORMED_REFUSAL).await {
917 Ok(()) => {
918 let _ = writer.flush().await;
919 true
920 }
921 Err(e) => {
922 log.debug(format!("SVN could not write refusal tuple: {}", e));
923 false
924 }
925 }
926 };
927 if written {
928 app_state
929 .update_connection_stats(
930 server_id,
931 connection_id,
932 None,
933 Some(MALFORMED_REFUSAL.len() as u64),
934 None,
935 Some(1),
936 )
937 .await;
938 }
939}
940
941/// Drop the peer handle and mark the connection closed (the greeting paths return before
942/// reaching the loop's shared teardown).
943async fn close_svn_connection(
944 app_state: &Arc<AppState>,
945 server_id: crate::state::ServerId,
946 connection_id: ConnectionId,
947 status_tx: &mpsc::UnboundedSender<String>,
948) {
949 use crate::state::server::ConnectionStatus;
950 app_state
951 .remove_peer_handle(server_id, connection_id.as_u32())
952 .await;
953 app_state
954 .update_connection_status(server_id, connection_id, ConnectionStatus::Closed)
955 .await;
956 let _ = status_tx.send("__UPDATE_UI__".to_string());
957}