Skip to main content

netget/server/
peer_support.rs

1//! Generic adoption helpers for messaging ONE connection of a server.
2//!
3//! The server-side mirror of `client/command_support.rs`: the dashboard's
4//! "message this peer" (and any programmatic caller) injects an action into a
5//! specific live connection via [`AppState::send_to_peer`]. A protocol opts in
6//! per connection:
7//!
8//! ```ignore
9//! // In the accept loop, once the connection is registered:
10//! let peer_rx = peer_support::register_peer_channel(&app_state, server_id, conn.as_u32()).await;
11//! peer_support::spawn_peer_command_task(
12//!     peer_rx, protocol.clone(), app_state.clone(), server_id, connection_id,
13//!     write_half_arc.clone(), status_tx.clone(),
14//! );
15//! // In the connection's close paths:
16//! app_state.remove_peer_handle(server_id, connection_id.as_u32()).await;
17//! ```
18//!
19//! The command task executes the action through the same central executor the
20//! LLM path uses (`execute_actions` + the protocol's own `execute_action`), so
21//! an injected `send_tcp_data` is encoded by exactly the code that encodes the
22//! model's. `ActionResult::Output` bytes are written to the connection's write
23//! half; everything else is reported as executed.
24//!
25//! Only protocols that adopt this offer the affordance — the UI shows
26//! "message this peer" exactly where a handle exists, which is the honest
27//! rendering of "only if the protocol permits it".
28
29use std::sync::Arc;
30
31use tokio::io::{AsyncWrite, AsyncWriteExt};
32use tokio::sync::{mpsc, Mutex};
33use tracing::{debug, warn};
34
35use crate::llm::actions::protocol_trait::{ActionResult, Server};
36use crate::state::client_handles::{
37    ClientCommand, ClientHandle, ClientSendOutcome, CLIENT_COMMAND_CAPACITY,
38};
39use crate::state::{AccessLogOwner, AppState, ServerId};
40
41/// Create and register the command channel for one live connection.
42pub async fn register_peer_channel(
43    state: &AppState,
44    server_id: ServerId,
45    connection_id: u32,
46) -> mpsc::Receiver<ClientCommand> {
47    let (command_tx, command_rx) = mpsc::channel(CLIENT_COMMAND_CAPACITY);
48    state
49        .register_peer_handle(server_id, connection_id, ClientHandle { command_tx })
50        .await;
51    command_rx
52}
53
54/// Drive a connection's command channel until it closes. The worker is also owned by
55/// its connection, so either peer close or server teardown cancels blocked work.
56///
57/// Runs as its own task: unlike the client loops, a server connection task
58/// blocks in `read()` without a select, and injected sends must not wait for
59/// the peer to talk first. Writes are safe against the reader's own responses
60/// because both go through the same `Arc<Mutex<WriteHalf>>`.
61pub fn spawn_peer_command_task<W>(
62    mut command_rx: mpsc::Receiver<ClientCommand>,
63    protocol: Arc<dyn Server>,
64    state: Arc<AppState>,
65    server_id: ServerId,
66    connection_id: u32,
67    write_half: Arc<Mutex<W>>,
68    status_tx: mpsc::UnboundedSender<String>,
69) where
70    W: AsyncWrite + Unpin + Send + 'static,
71{
72    let task_registrar = state.clone();
73    let worker = tokio::spawn(async move {
74        while let Some(command) = command_rx.recv().await {
75            handle_peer_command(
76                protocol.as_ref(),
77                &state,
78                server_id,
79                connection_id,
80                &write_half,
81                command,
82                &status_tx,
83            )
84            .await;
85        }
86        debug!(
87            "peer command task for server #{} connection #{} ended",
88            server_id.as_u32(),
89            connection_id
90        );
91    });
92    // This API is synchronous and is shared by many protocol loops. Registration only
93    // needs the state lock; it never waits for the worker. If removal wins the race,
94    // register_peer_task aborts the worker instead of resurrecting a closed peer.
95    tokio::spawn(async move {
96        task_registrar
97            .register_peer_task(server_id, connection_id, worker)
98            .await;
99    });
100}
101
102/// Execute one injected action against a connection and reply with the outcome.
103async fn handle_peer_command<W>(
104    protocol: &dyn Server,
105    state: &AppState,
106    server_id: ServerId,
107    connection_id: u32,
108    write_half: &Arc<Mutex<W>>,
109    command: ClientCommand,
110    status_tx: &mpsc::UnboundedSender<String>,
111) where
112    W: AsyncWrite + Unpin,
113{
114    let action = command.action.clone();
115    let outcome = execute_peer_action(protocol, state, server_id, write_half, &action).await;
116
117    // Record it in the access log under this server + connection, so the send
118    // appears in the peer's request list exactly like LLM-produced traffic.
119    let outcome_json = match &outcome {
120        Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
121        Err(e) => serde_json::json!({"error": e.to_string()}),
122    };
123    state
124        .record_access_log(
125            AccessLogOwner::Server(server_id.as_u32()),
126            protocol.protocol_name(),
127            Some(connection_id),
128            "injected_action",
129            action,
130            vec![outcome_json],
131        )
132        .await;
133
134    match &outcome {
135        Err(e) => warn!(
136            "injected action on server #{} connection #{} failed: {}",
137            server_id.as_u32(),
138            connection_id,
139            e
140        ),
141        // The server has hung up (FIN sent): say so now rather than when the
142        // peer gets around to closing its side. A peer that never reads —
143        // our own client parked on a manual question, say — would otherwise
144        // leave the row reading "live" after the operator disconnected it.
145        // The reader's own close path runs again on EOF; both calls are
146        // idempotent.
147        Ok(ClientSendOutcome::Disconnected) => {
148            state.remove_peer_handle(server_id, connection_id).await;
149            state
150                .close_connection_on_server(
151                    server_id,
152                    crate::server::connection::ConnectionId::new(connection_id),
153                )
154                .await;
155        }
156        // Injected bytes count like any other write: the rail's ↑ counter and
157        // last_activity read these, and a send from the dashboard that left
158        // them at zero looked like nothing went out.
159        Ok(ClientSendOutcome::Sent { bytes_sent }) => {
160            state
161                .update_connection_stats(
162                    server_id,
163                    crate::server::connection::ConnectionId::new(connection_id),
164                    None,
165                    Some(*bytes_sent as u64),
166                    None,
167                    Some(1),
168                )
169                .await;
170        }
171        Ok(_) => {}
172    }
173    let _ = status_tx.send("__UPDATE_UI__".to_string());
174    let _ = command.reply_tx.send(outcome);
175}
176
177/// Run the action through the central executor and write any `Output` bytes.
178async fn execute_peer_action<W>(
179    protocol: &dyn Server,
180    state: &AppState,
181    server_id: ServerId,
182    write_half: &Arc<Mutex<W>>,
183    action: &serde_json::Value,
184) -> anyhow::Result<ClientSendOutcome>
185where
186    W: AsyncWrite + Unpin,
187{
188    let result = crate::llm::actions::executor::execute_actions(
189        vec![action.clone()],
190        state,
191        Some(protocol),
192        Some(server_id),
193        None,
194    )
195    .await?;
196
197    let mut bytes_sent = 0usize;
198    let mut closed = false;
199    let mut details: Vec<String> = Vec::new();
200    let mut stack: Vec<ActionResult> = result.protocol_results;
201    stack.reverse();
202    while let Some(item) = stack.pop() {
203        match item {
204            ActionResult::Output(bytes) => {
205                let mut write = write_half.lock().await;
206                write.write_all(&bytes).await?;
207                write.flush().await?;
208                bytes_sent += bytes.len();
209            }
210            ActionResult::Multiple(items) => {
211                for inner in items.into_iter().rev() {
212                    stack.push(inner);
213                }
214            }
215            // Half-close the write side. The reader task owns the read half and
216            // the close bookkeeping, so this is the one generic way to end a
217            // connection from outside it: the peer reads EOF, closes, and the
218            // reader's `read() == 0` path runs the protocol's own teardown.
219            ActionResult::CloseConnection => {
220                let mut write = write_half.lock().await;
221                write.shutdown().await?;
222                closed = true;
223            }
224            ActionResult::WaitForMore | ActionResult::NoAction => {}
225            other => details.push(format!("{other:?}")),
226        }
227    }
228
229    if closed {
230        Ok(ClientSendOutcome::Disconnected)
231    } else if bytes_sent > 0 {
232        Ok(ClientSendOutcome::Sent { bytes_sent })
233    } else if details.is_empty() {
234        Ok(ClientSendOutcome::Executed {
235            detail: "executed (nothing to write)".to_string(),
236        })
237    } else {
238        Ok(ClientSendOutcome::Executed {
239            detail: crate::utils::truncate_for_log(&details.join("; "), 160),
240        })
241    }
242}