Skip to main content

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}