Skip to main content

netget/server/torrent_peer/
mod.rs

1//! BitTorrent Peer Wire Protocol server implementation
2//!
3//! TCP-based protocol for peer-to-peer data transfer between BitTorrent clients
4
5pub mod actions;
6
7use anyhow::Result;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::net::TcpListener;
11use tokio::sync::mpsc;
12use tracing::error;
13
14use crate::llm::action_helper::call_llm;
15use crate::llm::ollama_client::OllamaClient;
16use crate::logging::emit::Log;
17use crate::protocol::Event;
18use crate::server::connection::ConnectionId;
19use crate::state::app_state::AppState;
20use crate::utils::WireFailure;
21use actions::TorrentPeerProtocol;
22
23/// What the connection loop should do after one event has been dispatched.
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25enum Disposition {
26    /// Keep reading from the peer.
27    Continue,
28    /// The peer has been refused and its write half half-closed; stop reading.
29    Close,
30}
31
32/// `<len=0001><id=0>` — the peer wire protocol's choke message.
33///
34/// The only refusal the base protocol has. There is no error frame and no free-text field
35/// anywhere in BEP 3, so a failure category can be expressed by *which* refusal is sent and
36/// whether the connection survives it — never by text, which has nowhere to go.
37const CHOKE_FRAME: [u8; 5] = [0x00, 0x00, 0x00, 0x01, 0x00];
38
39/// How long to wait for a peer's handshake after it has connected.
40///
41/// **This was 30 seconds, and the argument for 30 described a peer this server rarely has.**
42/// It read: BEP 3 has the initiating peer send its 68-byte handshake immediately — it is the
43/// first thing on the wire and nothing precedes it — so a peer that has connected and sent
44/// nothing is not mid-handshake, it is holding a socket. That is true of every mainline
45/// client. It is false of NetGet's own peer client, and there the two ends deadlock.
46///
47/// `src/client/torrent_peer/mod.rs` connects and its read loop's **first** act is
48/// `read_exact` on the *other* peer's 68-byte handshake. Sending its own is only the
49/// `send_handshake` action, which needs a model turn or a person at `[ send message ]`: a
50/// client created from the dashboard's `[ + torrent_peer client ]` is routed
51/// `torrent_peer_connected` → static-with-no-actions and then `*` → manual
52/// (`src/tui/modal/form.rs`). So both ends sit in `read`, and at 30 seconds this server is the
53/// one that gives up — while the operator is still reading the parked event that would have
54/// unblocked it.
55///
56/// 300 seconds is the window a `manual` rule gives a human to answer one event
57/// (`src/state/intercepts.rs`), which is the number this product already uses for how long
58/// someone might take. Nothing in BEP 3 argues against it: the specification says when a
59/// handshake is sent, not how long a listener must wait for one.
60///
61/// What it costs: a stranger holding a socket, a task, an `AppState` row and one of
62/// [`MAX_CONNECTIONS`] slots while saying nothing now gets 300 seconds rather than 30 — a
63/// tenfold rise in how long one idle slot is held, not a removal of the bound. The cap still
64/// holds, and a peer over it still gets [`CONNECTION_CAP_REFUSAL`]: a plain close. A listener
65/// genuinely exposed to a public swarm should set `first_byte_timeout_secs` low; 30 is the old
66/// value and remains a sound choice for one.
67const HANDSHAKE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
68
69/// The two read deadlines for one connection, resolved from the server's startup parameters.
70///
71/// Carried as one value so threading them from `spawn_with_llm_actions` into the connection
72/// task costs one argument rather than two.
73#[derive(Clone, Copy)]
74struct ReadDeadlines {
75    /// [`HANDSHAKE_READ_TIMEOUT`], or this server's `first_byte_timeout_secs`.
76    first_byte: std::time::Duration,
77    /// [`IDLE_AFTER_HANDSHAKE_TIMEOUT`], or this server's `idle_timeout_secs`.
78    idle: std::time::Duration,
79}
80
81impl ReadDeadlines {
82    fn resolve(first_byte_secs: Option<u64>, idle_secs: Option<u64>) -> Self {
83        Self {
84            first_byte: first_byte_secs
85                .map(std::time::Duration::from_secs)
86                .unwrap_or(HANDSHAKE_READ_TIMEOUT),
87            idle: idle_secs
88                .map(std::time::Duration::from_secs)
89                .unwrap_or(IDLE_AFTER_HANDSHAKE_TIMEOUT),
90        }
91    }
92}
93
94/// How long to wait for a *further* message once the handshake is done.
95///
96/// The peer wire protocol's own answer to this question is the keep-alive: a zero-length
97/// message sent roughly every two minutes precisely so an idle-but-live peer can be told from
98/// a dead one, and every mainline client drops a connection that goes quiet for about that
99/// long. Three minutes gives a conforming peer a full missed keep-alive of slack before this
100/// server does what the convention already expects it to do.
101///
102/// Overridable per server with `idle_timeout_secs`, for the same reason the first bound is:
103/// the right value is a property of who is on the other end, and only the operator knows that.
104const IDLE_AFTER_HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
105
106/// Concurrent connections this server admits.
107///
108/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. Real swarms are far smaller
109/// than this — mainline clients cap global peers in the low hundreds and per-torrent peers at
110/// a few dozen — so 256 refuses nothing a real swarm produces.
111const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
112
113/// A peer over [`MAX_CONNECTIONS`] gets a plain close and no bytes, and that is deliberate.
114///
115/// BEP 3 has no busy, error or free-text message of any kind, and the one refusal it does
116/// define — [`CHOKE_FRAME`] — is only legal *after* a handshake this peer has not sent. Any
117/// bytes written here would be read as the first 5 of the 68 handshake bytes, so a client
118/// would report a malformed handshake rather than a full server: strictly worse than silence,
119/// which is the same reason twenty protocols in this tree stay silent on an LLM failure. The
120/// refusal is in the log instead, and the log line is the diagnosis.
121const CONNECTION_CAP_REFUSAL: &[u8] = b"";
122
123/// Largest the inbound frame accumulator may grow before the peer is closed.
124///
125/// A peer that never completes a frame would otherwise grow this buffer without bound. The
126/// largest legitimate frame is a piece message, capped well under this by every client in use.
127/// Note the peer-supplied length prefix is never used to allocate — it only gates whether a
128/// whole frame has arrived — so this cap is what a `0xFFFFFFFF` prefix actually runs into.
129///
130/// Module level rather than inside the read loop, so `metadata()` can declare the constant the
131/// loop enforces instead of repeating the number.
132pub const MAX_PENDING: usize = 2 * 1024 * 1024;
133
134/// BitTorrent Peer Wire Protocol server
135pub struct TorrentPeerServer;
136
137impl TorrentPeerServer {
138    /// Spawn BitTorrent Peer server with LLM actions
139    #[allow(clippy::too_many_arguments)]
140    pub async fn spawn_with_llm_actions(
141        listen_addr: SocketAddr,
142        llm_client: OllamaClient,
143        app_state: Arc<AppState>,
144        status_tx: mpsc::UnboundedSender<String>,
145        server_id: crate::state::ServerId,
146        first_byte_timeout_secs: Option<u64>,
147        idle_timeout_secs: Option<u64>,
148    ) -> Result<SocketAddr> {
149        // Both bounds are tunable because their right value is a property of who is on the
150        // other end, which only the operator knows. The defaults serve NetGet's own peer
151        // client, which waits for our handshake before it sends its own; a listener on a
152        // public swarm wants the first one much lower.
153        let deadlines = ReadDeadlines::resolve(first_byte_timeout_secs, idle_timeout_secs);
154        let listener = TcpListener::bind(listen_addr).await?;
155        let local_addr = listener.local_addr()?;
156        Log::new(Some(&status_tx)).info(format!(
157            "BitTorrent Peer server (action-based) listening on {}",
158            local_addr
159        ));
160
161        let protocol = Arc::new(TorrentPeerProtocol::new());
162
163        let task_registrar = app_state.clone();
164        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
165        let accept_handle = tokio::spawn(async move {
166            loop {
167                match crate::server::accept_bounded::accept_bounded(
168                    &listener,
169                    &limiter,
170                    CONNECTION_CAP_REFUSAL,
171                    "BitTorrent Peer",
172                    Some(&status_tx),
173                )
174                .await
175                {
176                    Ok((stream, peer_addr, permit)) => {
177                        let connection_id =
178                            ConnectionId::new(app_state.get_next_unified_id().await);
179                        let llm_clone = llm_client.clone();
180                        let state_clone = app_state.clone();
181                        let status_clone = status_tx.clone();
182                        let protocol_clone = protocol.clone();
183
184                        Log::new(Some(&status_clone)).debug(format!(
185                            "BitTorrent Peer accepted connection from {}",
186                            peer_addr
187                        ));
188
189                        // Split stream for read/write
190                        let (read_half, write_half) = tokio::io::split(stream);
191                        let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
192
193                        // Add connection to ServerInstance
194                        use crate::state::server::{
195                            ConnectionState as ServerConnectionState, ConnectionStatus,
196                            ProtocolConnectionInfo,
197                        };
198                        let now = crate::utils::clock::Instant::now();
199                        let conn_state = ServerConnectionState {
200                            id: connection_id,
201                            remote_addr: peer_addr,
202                            local_addr,
203                            bytes_sent: 0,
204                            bytes_received: 0,
205                            packets_sent: 0,
206                            packets_received: 0,
207                            last_activity: now,
208                            status: ConnectionStatus::Active,
209                            status_changed_at: now,
210                            protocol_info: ProtocolConnectionInfo::empty(),
211                        };
212                        state_clone
213                            .add_connection_to_server(server_id, conn_state)
214                            .await;
215                        let _ = status_clone.send("__UPDATE_UI__".to_string());
216
217                        // Tracked, not detached: stop_server must abort this task too.
218                        let task_owner = app_state.clone();
219                        task_owner
220                            .spawn_server_task(server_id, async move {
221                                // Held for the life of the connection, so the cap counts live
222                                // peers rather than accepts.
223                                let _permit = permit;
224                                if let Err(e) = Self::handle_connection(
225                                    read_half,
226                                    write_half,
227                                    peer_addr,
228                                    local_addr,
229                                    connection_id,
230                                    llm_clone,
231                                    state_clone,
232                                    status_clone,
233                                    server_id,
234                                    protocol_clone,
235                                    deadlines,
236                                )
237                                .await
238                                {
239                                    error!("BitTorrent Peer connection error: {}", e);
240                                }
241                            })
242                            .await;
243                    }
244                    Err(e) => {
245                        Log::new(Some(&status_tx))
246                            .error(format!("BitTorrent Peer accept error: {}", e));
247                    }
248                }
249            }
250        });
251
252        task_registrar
253            .register_server_task(server_id, accept_handle)
254            .await;
255
256        Ok(local_addr)
257    }
258
259    #[allow(clippy::too_many_arguments)]
260    async fn handle_connection(
261        mut read_half: tokio::io::ReadHalf<tokio::net::TcpStream>,
262        write_half: Arc<tokio::sync::Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
263        peer_addr: SocketAddr,
264        _local_addr: SocketAddr,
265        connection_id: ConnectionId,
266        llm_client: OllamaClient,
267        app_state: Arc<AppState>,
268        status_tx: mpsc::UnboundedSender<String>,
269        server_id: crate::state::ServerId,
270        protocol: Arc<TorrentPeerProtocol>,
271        deadlines: ReadDeadlines,
272    ) -> Result<()> {
273        use tokio::io::AsyncReadExt;
274
275        // Register the peer handle so the dashboard can "message this peer" /
276        // "disconnect this peer" through the same write half the reader uses. All
277        // wire verbs return `ActionResult::Output`, so the generic peer command
278        // task covers the full vocabulary with nothing protocol-specific. The
279        // handle is removed on every exit path via the single cleanup below.
280        let peer_rx = crate::server::peer_support::register_peer_channel(
281            &app_state,
282            server_id,
283            connection_id.as_u32(),
284        )
285        .await;
286        crate::server::peer_support::spawn_peer_command_task(
287            peer_rx,
288            protocol.clone(),
289            app_state.clone(),
290            server_id,
291            connection_id.as_u32(),
292            write_half.clone(),
293            status_tx.clone(),
294        );
295
296        let result: Result<()> = async {
297            let mut chunk = vec![0u8; 16384];
298            // The peer wire protocol is length-prefixed over a byte stream, so a `read()` is
299            // not a message. Previously each read was parsed as exactly one frame, which meant
300            // the bitfield a client sends in the same segment as its handshake was discarded,
301            // two coalesced messages became one, and a message split across two reads was
302            // dropped and left the stream misaligned for everything after it. Accumulate and
303            // drain complete frames instead.
304            let mut pending: Vec<u8> = Vec::new();
305            let mut handshake_complete = false;
306
307            'read: loop {
308                // The deadline wraps this `read()` alone. The LLM round-trip and a `manual`
309                // rule parking a message for a human (`src/state/intercepts.rs`, 300s by
310                // default) both run further down this loop body, after a read has already
311                // returned, so neither can be cut short by it — what is bounded is only a peer
312                // holding the connection while sending nothing.
313                let read_timeout = if handshake_complete {
314                    deadlines.idle
315                } else {
316                    deadlines.first_byte
317                };
318                let n = match tokio::time::timeout(read_timeout, read_half.read(&mut chunk)).await {
319                    Ok(read) => read?,
320                    Err(_) => {
321                        Log::new(Some(&status_tx)).info(format!(
322                            "BitTorrent Peer {} sent nothing for {}s; closing idle connection",
323                            peer_addr,
324                            read_timeout.as_secs()
325                        ));
326                        break;
327                    }
328                };
329                if n == 0 {
330                    Log::new(Some(&status_tx)).debug("BitTorrent Peer connection closed by peer");
331                    break;
332                }
333
334                // Refresh inbound counters (and last_activity) so the rail shows ↓ move.
335                app_state
336                    .update_connection_stats(
337                        server_id,
338                        connection_id,
339                        Some(n as u64),
340                        None,
341                        Some(1),
342                        None,
343                    )
344                    .await;
345
346                Log::new(Some(&status_tx)).debug(format!(
347                    "BitTorrent Peer received {} bytes from {}",
348                    n, peer_addr
349                ));
350
351                // TRACE: Log full payload
352                Log::new(Some(&status_tx)).trace(format!(
353                    "BitTorrent Peer data (hex): {}",
354                    hex::encode(&chunk[..n])
355                ));
356
357                pending.extend_from_slice(&chunk[..n]);
358
359                if pending.len() > MAX_PENDING {
360                    Log::new(Some(&status_tx)).warn(format!(
361                        "BitTorrent Peer {} buffered {} bytes without a complete message, closing",
362                        peer_addr,
363                        pending.len()
364                    ));
365                    break;
366                }
367
368                loop {
369                    if !handshake_complete {
370                        if pending.len() < 68 {
371                            break;
372                        }
373                        let handshake: Vec<u8> = pending.drain(..68).collect();
374                        match Self::parse_handshake(&handshake) {
375                            Ok((info_hash, peer_id, peer_id_hex)) => {
376                                Log::new(Some(&status_tx)).debug(format!(
377                                    "BitTorrent Peer handshake: info_hash={}, peer_id={}",
378                                    info_hash, peer_id
379                                ));
380
381                                handshake_complete = true;
382
383                                let event = Event::new(
384                                    &actions::PEER_HANDSHAKE_EVENT,
385                                    serde_json::json!({
386                                        "info_hash": info_hash,
387                                        "peer_id": peer_id,
388                                        "peer_id_hex": peer_id_hex,
389                                    }),
390                                );
391
392                                let disposition = Self::dispatch_event(
393                                    event,
394                                    &write_half,
395                                    peer_addr,
396                                    connection_id,
397                                    &llm_client,
398                                    &app_state,
399                                    &status_tx,
400                                    server_id,
401                                    &protocol,
402                                )
403                                .await?;
404                                if disposition == Disposition::Close {
405                                    break 'read;
406                                }
407                            }
408                            Err(e) => {
409                                Log::new(Some(&status_tx))
410                                    .error(format!("Failed to parse handshake: {}", e));
411                                break 'read;
412                            }
413                        }
414                        continue;
415                    }
416
417                    // Keep-alive and regular messages are both `<4-byte length><body>`.
418                    if pending.len() < 4 {
419                        break;
420                    }
421                    let length =
422                        u32::from_be_bytes([pending[0], pending[1], pending[2], pending[3]])
423                            as usize;
424                    if pending.len() < 4 + length {
425                        break;
426                    }
427                    let frame: Vec<u8> = pending.drain(..4 + length).collect();
428
429                    match Self::parse_message(&frame) {
430                        Ok((message_type, message_data)) => {
431                            Log::new(Some(&status_tx))
432                                .debug(format!("BitTorrent Peer message type: {}", message_type));
433
434                            let event_type = match message_type.as_str() {
435                                // One event covers the four payload-free state messages; they
436                                // share a reply vocabulary and carry `message_type` so a
437                                // handler can still tell them apart.
438                                "choke" | "unchoke" | "interested" | "not_interested" => {
439                                    &actions::PEER_CHOKE_MESSAGE_EVENT
440                                }
441                                "request" => &actions::PEER_REQUEST_MESSAGE_EVENT,
442                                "bitfield" => &actions::PEER_BITFIELD_MESSAGE_EVENT,
443                                // have / piece / cancel / keepalive / unrecognised ids used to
444                                // be announced to the model as "peer_choke_message", which is
445                                // simply false.
446                                _ => &actions::PEER_MESSAGE_EVENT,
447                            };
448                            let event = Event::new(event_type, message_data);
449
450                            Log::new(Some(&status_tx)).debug(format!(
451                                "BitTorrent Peer calling LLM for {} message",
452                                message_type
453                            ));
454
455                            let disposition = Self::dispatch_event(
456                                event,
457                                &write_half,
458                                peer_addr,
459                                connection_id,
460                                &llm_client,
461                                &app_state,
462                                &status_tx,
463                                server_id,
464                                &protocol,
465                            )
466                            .await?;
467                            if disposition == Disposition::Close {
468                                break 'read;
469                            }
470                        }
471                        Err(e) => {
472                            Log::new(Some(&status_tx))
473                                .warn(format!("Failed to parse peer message: {}", e));
474                        }
475                    }
476                }
477            }
478
479            Ok(())
480        }
481        .await;
482
483        // Every exit path (EOF, read error, handshake/parse abort, buffer cap) lands
484        // here: drop the peer handle so the dashboard stops offering to message or
485        // disconnect a dead connection.
486        app_state
487            .remove_peer_handle(server_id, connection_id.as_u32())
488            .await;
489        result
490    }
491
492    /// Call the LLM for one event and write whatever it produced back to the peer.
493    ///
494    /// On a backend failure the peer is *answered*, not left hanging. BitTorrent has no error
495    /// message, so the answer is a `choke` — the protocol's own "I will not serve you right
496    /// now" — and the two failure categories are told apart by what follows it:
497    ///
498    /// * [`WireFailure::Overloaded`] — choke alone. The connection stays up, so a peer that
499    ///   honours choke simply stops requesting and waits for an `unchoke`; nothing is
500    ///   recorded as a permanent fault and a retry costs it no new handshake.
501    /// * [`WireFailure::Unavailable`] — choke, then half-close the write half so the peer's
502    ///   next read returns EOF and it moves on to another peer.
503    ///
504    /// Nothing derived from the error reaches the socket; a choke frame is five fixed bytes
505    /// and has nowhere to put text even if it were wanted. The error goes to the log and the
506    /// status stream, tagged `decision=fail_closed_llm_error`.
507    #[allow(clippy::too_many_arguments)]
508    async fn dispatch_event(
509        event: Event,
510        write_half: &Arc<tokio::sync::Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
511        peer_addr: SocketAddr,
512        connection_id: ConnectionId,
513        llm_client: &OllamaClient,
514        app_state: &Arc<AppState>,
515        status_tx: &mpsc::UnboundedSender<String>,
516        server_id: crate::state::ServerId,
517        protocol: &Arc<TorrentPeerProtocol>,
518    ) -> Result<Disposition> {
519        match call_llm(
520            llm_client,
521            app_state,
522            server_id,
523            Some(connection_id),
524            &event,
525            protocol.as_ref(),
526        )
527        .await
528        {
529            Ok(execution_result) => {
530                Self::process_llm_response(
531                    execution_result,
532                    write_half,
533                    peer_addr,
534                    connection_id,
535                    app_state,
536                    server_id,
537                    status_tx,
538                )
539                .await?;
540                Ok(Disposition::Continue)
541            }
542            Err(e) => {
543                use tokio::io::AsyncWriteExt;
544                let failure = WireFailure::classify(&e);
545                let log = Log::new(Some(&status_tx));
546                // The full error belongs here and only here.
547                log.error(format!(
548                    "BitTorrent Peer {} conn={} decision=fail_closed_llm_error category={} LLM error: {}",
549                    peer_addr,
550                    connection_id.as_u32(),
551                    if failure.is_overloaded() { "overloaded" } else { "unavailable" },
552                    e
553                ));
554
555                let write_result = {
556                    let mut write = write_half.lock().await;
557                    let sent = write.write_all(&CHOKE_FRAME).await;
558                    if sent.is_ok() && !failure.is_overloaded() {
559                        // No text to send and nothing more to say: FIN is the honest signal.
560                        let _ = write.shutdown().await;
561                    }
562                    sent
563                };
564
565                match write_result {
566                    Ok(()) => {
567                        app_state
568                            .update_connection_stats(
569                                server_id,
570                                connection_id,
571                                None,
572                                Some(CHOKE_FRAME.len() as u64),
573                                None,
574                                Some(1),
575                            )
576                            .await;
577                    }
578                    Err(write_err) => {
579                        log.warn(format!(
580                            "BitTorrent Peer could not send choke to {}: {}",
581                            peer_addr, write_err
582                        ));
583                        return Ok(Disposition::Close);
584                    }
585                }
586
587                if failure.is_overloaded() {
588                    log.warn(format!(
589                        "BitTorrent Peer choked {} (transient); connection kept open",
590                        peer_addr
591                    ));
592                    Ok(Disposition::Continue)
593                } else {
594                    log.info(format!(
595                        "BitTorrent Peer choked and half-closed {} after LLM error",
596                        peer_addr
597                    ));
598                    Ok(Disposition::Close)
599                }
600            }
601        }
602    }
603
604    #[allow(clippy::too_many_arguments)]
605    async fn process_llm_response(
606        execution_result: crate::llm::actions::executor::ExecutionResult,
607        write_half: &Arc<tokio::sync::Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
608        peer_addr: SocketAddr,
609        connection_id: ConnectionId,
610        app_state: &Arc<AppState>,
611        server_id: crate::state::ServerId,
612        status_tx: &mpsc::UnboundedSender<String>,
613    ) -> Result<()> {
614        use tokio::io::AsyncWriteExt;
615
616        // Display messages from LLM
617        for message in &execution_result.messages {
618            Log::new(Some(&status_tx)).info(format!("{}", message));
619        }
620
621        Log::new(Some(&status_tx)).debug(format!(
622            "BitTorrent Peer got {} protocol results",
623            execution_result.protocol_results.len()
624        ));
625
626        // Three outcomes must stay apart in the log: the model refused, the model said
627        // nothing, and the backend failed (tagged `decision=fail_closed_llm_error` in
628        // `dispatch_event`). Silence here is not a refusal and is not a grant — the peer
629        // simply gets no frame — so it must not be recorded as either.
630        let model_refused = execution_result
631            .raw_actions
632            .iter()
633            .any(|a| a.get("type").and_then(|t| t.as_str()) == Some("send_choke"));
634        let decision = if model_refused {
635            "model_reject"
636        } else if execution_result.protocol_results.is_empty() {
637            "model_no_action"
638        } else {
639            "model_answer"
640        };
641        Log::new(Some(&status_tx)).debug(format!(
642            "BitTorrent Peer {} conn={} decision={}",
643            peer_addr,
644            connection_id.as_u32(),
645            decision
646        ));
647
648        // Send responses. Every output is written, in order: a handshake reply is
649        // routinely followed by a bitfield and an unchoke, and dropping all but the first
650        // would leave the peer waiting.
651        for protocol_result in execution_result.protocol_results {
652            for output_data in protocol_result.get_all_output() {
653                let mut write = write_half.lock().await;
654                write.write_all(&output_data).await?;
655                drop(write);
656
657                // Refresh outbound counters (and last_activity) so the rail shows ↑ move.
658                app_state
659                    .update_connection_stats(
660                        server_id,
661                        connection_id,
662                        None,
663                        Some(output_data.len() as u64),
664                        None,
665                        Some(1),
666                    )
667                    .await;
668
669                Log::new(Some(&status_tx)).debug(format!(
670                    "BitTorrent Peer sent {} bytes to {}",
671                    output_data.len(),
672                    peer_addr
673                ));
674
675                // TRACE: Log full response
676                let hex_str = hex::encode(&output_data);
677                Log::new(Some(&status_tx))
678                    .trace(format!("BitTorrent Peer sent (hex): {}", hex_str));
679            }
680        }
681
682        Ok(())
683    }
684
685    /// Parse the fixed 68-byte handshake.
686    ///
687    /// Returns `(info_hash_hex, peer_id_text, peer_id_hex)`. The peer ID is 20 arbitrary
688    /// bytes: most clients use printable ASCII but the trailing random section frequently
689    /// is not, so the lossy text form can contain replacement characters and must not be
690    /// echoed back. `peer_id_hex` is the faithful form.
691    fn parse_handshake(data: &[u8]) -> Result<(String, String, String)> {
692        // Handshake format: <pstrlen><pstr><reserved><info_hash><peer_id>
693        // pstrlen = 19, pstr = "BitTorrent protocol"
694
695        if data.len() < 68 {
696            return Err(anyhow::anyhow!("Handshake too short"));
697        }
698
699        let pstrlen = data[0] as usize;
700        if pstrlen != 19 {
701            return Err(anyhow::anyhow!("Invalid pstrlen"));
702        }
703
704        let pstr = &data[1..20];
705        if pstr != b"BitTorrent protocol" {
706            return Err(anyhow::anyhow!("Invalid protocol string"));
707        }
708
709        // reserved = 8 bytes (bytes 20-27)
710        let info_hash = hex::encode(&data[28..48]);
711        let peer_id = String::from_utf8_lossy(&data[48..68]).to_string();
712        let peer_id_hex = hex::encode(&data[48..68]);
713
714        Ok((info_hash, peer_id, peer_id_hex))
715    }
716
717    fn parse_message(data: &[u8]) -> Result<(String, serde_json::Value)> {
718        if data.is_empty() {
719            return Ok((
720                "keepalive".to_string(),
721                serde_json::json!({"message_type": "keepalive"}),
722            ));
723        }
724
725        if data.len() < 4 {
726            return Err(anyhow::anyhow!("Message too short"));
727        }
728
729        let length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
730
731        if length == 0 {
732            return Ok((
733                "keepalive".to_string(),
734                serde_json::json!({"message_type": "keepalive"}),
735            ));
736        }
737
738        if data.len() < 4 + length {
739            return Err(anyhow::anyhow!("Incomplete message"));
740        }
741
742        let message_id = data[4];
743        let payload = &data[5..4 + length];
744
745        let (message_type, message_data) = match message_id {
746            0 => ("choke", serde_json::json!({})),
747            1 => ("unchoke", serde_json::json!({})),
748            2 => ("interested", serde_json::json!({})),
749            3 => ("not_interested", serde_json::json!({})),
750            4 => {
751                if payload.len() >= 4 {
752                    let piece_index =
753                        u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
754                    ("have", serde_json::json!({"piece_index": piece_index}))
755                } else {
756                    ("have", serde_json::json!({}))
757                }
758            }
759            5 => {
760                // Bitfield message
761                (
762                    "bitfield",
763                    serde_json::json!({"bitfield": hex::encode(payload)}),
764                )
765            }
766            6 => {
767                if payload.len() >= 12 {
768                    let index =
769                        u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
770                    let begin =
771                        u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]);
772                    let length =
773                        u32::from_be_bytes([payload[8], payload[9], payload[10], payload[11]]);
774                    (
775                        "request",
776                        serde_json::json!({"index": index, "begin": begin, "length": length}),
777                    )
778                } else {
779                    ("request", serde_json::json!({}))
780                }
781            }
782            7 => {
783                if payload.len() >= 8 {
784                    let index =
785                        u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
786                    let begin =
787                        u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]);
788                    let block = hex::encode(&payload[8..]);
789                    (
790                        "piece",
791                        serde_json::json!({"index": index, "begin": begin, "block_hex": block}),
792                    )
793                } else {
794                    ("piece", serde_json::json!({}))
795                }
796            }
797            8 => {
798                if payload.len() >= 12 {
799                    let index =
800                        u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
801                    let begin =
802                        u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]);
803                    let length =
804                        u32::from_be_bytes([payload[8], payload[9], payload[10], payload[11]]);
805                    (
806                        "cancel",
807                        serde_json::json!({"index": index, "begin": begin, "length": length}),
808                    )
809                } else {
810                    ("cancel", serde_json::json!({}))
811                }
812            }
813            _ => (
814                "unknown",
815                serde_json::json!({"id": message_id, "payload_hex": hex::encode(payload)}),
816            ),
817        };
818
819        // Several message types share one event, so the event payload has to say which
820        // one actually arrived; without it a handler on peer_choke_message cannot tell a
821        // choke from an "interested".
822        let mut message_data = message_data;
823        if let Some(obj) = message_data.as_object_mut() {
824            obj.insert("message_type".to_string(), serde_json::json!(message_type));
825        }
826
827        Ok((message_type.to_string(), message_data))
828    }
829}