Skip to main content

netget/client/torrent_tracker/
mod.rs

1//! BitTorrent Tracker client implementation
2pub mod actions;
3
4pub use actions::TorrentTrackerClientProtocol;
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::future::Future;
9use std::net::SocketAddr;
10use std::pin::Pin;
11use std::sync::Arc;
12use tokio::sync::mpsc;
13use tracing::{error, info, trace, warn};
14
15use crate::client::http_fetch::FetchClient;
16use crate::client::llm_budget::call_llm_for_client;
17use crate::client::torrent_tracker::actions::{
18    TRACKER_ANNOUNCE_RESPONSE_EVENT, TRACKER_SCRAPE_RESPONSE_EVENT,
19};
20use crate::llm::actions::client_trait::{Client, ClientActionResult};
21use crate::llm::actions::protocol_trait::Protocol;
22use crate::llm::ollama_client::OllamaClient;
23use crate::llm::ClientLlmResult;
24use crate::protocol::Event;
25use crate::state::app_state::AppState;
26use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
27use crate::state::{AccessLogOwner, ClientId, ClientStatus};
28
29/// BitTorrent tracker response (announce)
30#[derive(Debug, Deserialize, Serialize)]
31struct TrackerResponse {
32    #[serde(rename = "failure reason")]
33    failure_reason: Option<String>,
34    #[serde(rename = "warning message")]
35    warning_message: Option<String>,
36    interval: Option<i64>,
37    #[serde(rename = "min interval")]
38    min_interval: Option<i64>,
39    #[serde(rename = "tracker id")]
40    tracker_id: Option<String>,
41    complete: Option<i64>,
42    incomplete: Option<i64>,
43    peers: Option<serde_bencode::value::Value>,
44}
45
46/// BitTorrent tracker scrape response
47#[derive(Debug, Deserialize, Serialize)]
48struct ScrapeResponse {
49    files: Option<serde_bencode::value::Value>,
50}
51
52/// How many LLM turns one announce/scrape chain may take before it is cut.
53///
54/// The cycle is real: an announce produces `tracker_announce_response`, whose answer may be
55/// another announce. Executing the answer (which this client did not used to do) is what makes
56/// the recursion possible, so it is bounded rather than avoided by staying silent.
57const MAX_FOLLOWUP_DEPTH: u8 = 6;
58
59/// Ceiling on a tracker reply before it is refused, applied while the body streams in.
60///
61/// A tracker answer is a small bencoded dictionary — a compact peer list of a thousand peers
62/// is six kilobytes. `bytes()` would have read whatever the far end chose to send, with no
63/// cap at all, so a hostile or broken tracker could hand this client gigabytes and the first
64/// sign of it would be the allocator.
65const MAX_TRACKER_BODY_BYTES: usize = 1024 * 1024;
66
67/// Wall-clock bound on one announce or scrape.
68///
69/// `reqwest::get` applies no timeout of any kind, so a tracker that accepts the connection
70/// and then says nothing parks the action — and with it whatever injected `[ send ]` is
71/// waiting on its outcome — for as long as the peer cares to hold it open.
72const TRACKER_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
73
74#[cfg(not(target_arch = "wasm32"))]
75use std::collections::HashMap;
76#[cfg(not(target_arch = "wasm32"))]
77use std::sync::{LazyLock, Mutex};
78
79/// One `reqwest::Client` per tracker URL, built once and reused.
80///
81/// Three things here, all of which the root `CLAUDE.md` records as having cost real debugging
82/// time:
83///
84/// * **Build it once.** `reqwest::get` built a fresh client per request.
85/// * **Build it off the runtime.** `Client::builder().build()` sets up the rustls stack and
86///   loads the platform root store, which on macOS reads the keychain through
87///   Security.framework — synchronously, and serialised across processes. On an async worker
88///   that parks the whole thread, so it goes through `spawn_blocking`.
89/// * **Key it by URL.** `reqwest` hands even a dotted quad to `getaddrinfo`, which serialises
90///   through mDNSResponder on macOS and was measured at 8.25s under load.
91///   [`client_for_endpoint_with_timeout`] installs the literal-IP bypass and needs the URL to
92///   decide whether to, so a single global client would not do.
93///
94/// The lock is **never** held across the build. A first version did exactly that — the
95/// blocking keychain read happened inside `or_insert_with` while holding this `Mutex` — which
96/// parked a worker *and* serialised every tracker client in the process behind it. Two
97/// callers racing for the same URL may now both build, and the loser's client is dropped;
98/// that is far cheaper than what it replaces.
99#[cfg(not(target_arch = "wasm32"))]
100static TRACKER_CLIENTS: LazyLock<Mutex<HashMap<String, reqwest::Client>>> =
101    LazyLock::new(|| Mutex::new(HashMap::new()));
102
103/// Lock, look, clone, drop. A poisoned mutex means another thread panicked mid-insert; the map
104/// is still structurally sound and building a fresh client is always correct, so it is taken
105/// rather than propagated.
106#[cfg(not(target_arch = "wasm32"))]
107fn cached_tracker_client(tracker_url: &str) -> Option<reqwest::Client> {
108    match TRACKER_CLIENTS.lock() {
109        Ok(cache) => cache.get(tracker_url).cloned(),
110        Err(poisoned) => poisoned.into_inner().get(tracker_url).cloned(),
111    }
112}
113
114#[cfg(not(target_arch = "wasm32"))]
115async fn tracker_reqwest_client(tracker_url: &str) -> reqwest::Client {
116    if let Some(client) = cached_tracker_client(tracker_url) {
117        return client;
118    }
119
120    let url = tracker_url.to_string();
121    let timeout = TRACKER_REQUEST_TIMEOUT;
122    let built = tokio::task::spawn_blocking(move || {
123        crate::llm::ollama_client::client_for_endpoint_with_timeout(&url, timeout)
124    })
125    .await
126    // The closure cannot panic and the task is never aborted, but a default client is a
127    // correct answer rather than a reason to fail the announce.
128    .unwrap_or_else(|_| reqwest::Client::new());
129
130    let mut cache = match TRACKER_CLIENTS.lock() {
131        Ok(guard) => guard,
132        Err(poisoned) => poisoned.into_inner(),
133    };
134    // Whoever inserted first wins, so every later request shares one client.
135    cache
136        .entry(tracker_url.to_string())
137        .or_insert(built)
138        .clone()
139}
140
141/// The HTTP client for one tracker: the cached reqwest client natively
142/// ([`tracker_reqwest_client`]); in the browser build the hyper HTTP/1.1 transport over the
143/// page's virtual loopback ([`crate::client::http_fetch`]), bounded by the same
144/// [`TRACKER_REQUEST_TIMEOUT`] and reading at most [`MAX_TRACKER_BODY_BYTES`].
145async fn tracker_http_client(tracker_url: &str) -> FetchClient {
146    #[cfg(not(target_arch = "wasm32"))]
147    let client = FetchClient::from_reqwest(tracker_reqwest_client(tracker_url).await);
148    #[cfg(target_arch = "wasm32")]
149    let client = {
150        let _ = tracker_url;
151        FetchClient::transport(TRACKER_REQUEST_TIMEOUT).with_max_body(MAX_TRACKER_BODY_BYTES)
152    };
153    client
154}
155
156/// A tracker URL the requests can be built on. A bare `host:port` — what the dashboard's
157/// `[ + BitTorrent Tracker client ]` and a model asked to "announce to 127.0.0.1:6969" produce —
158/// becomes `http://host:port/announce`: HTTP trackers are plain HTTP by convention, and the
159/// announce path is what BEP 3 tracker URLs name. Anything with a scheme is left as given.
160/// Without this, the announce URL was `127.0.0.1:6969?info_hash=…`, which is not an absolute
161/// URL and failed every request.
162pub fn resolve_tracker_url(remote_addr: &str) -> String {
163    let trimmed = remote_addr.trim();
164    if trimmed.contains("://") {
165        return trimmed.to_string();
166    }
167    if trimmed.contains('/') {
168        format!("http://{trimmed}")
169    } else {
170        format!("http://{trimmed}/announce")
171    }
172}
173
174/// GET `url` and return at most [`MAX_TRACKER_BODY_BYTES`] of body, refusing anything longer
175/// *while it arrives* rather than after it has all been buffered.
176async fn fetch_tracker_body(tracker_url: &str, url: &str) -> Result<Vec<u8>> {
177    let mut response = tracker_http_client(tracker_url)
178        .await
179        .get(url)
180        .send()
181        .await?;
182
183    // Content-Length is a hint, not a promise — it may be absent, or a lie — so it is used
184    // only to refuse early, and the streaming check below is what actually holds.
185    if let Some(len) = response.content_length() {
186        if len > MAX_TRACKER_BODY_BYTES as u64 {
187            return Err(anyhow::anyhow!(
188                "tracker declared a {} byte reply, over the {} byte limit",
189                len,
190                MAX_TRACKER_BODY_BYTES
191            ));
192        }
193    }
194
195    let mut body = Vec::new();
196    while let Some(chunk) = response.chunk().await? {
197        if body.len() + chunk.len() > MAX_TRACKER_BODY_BYTES {
198            return Err(anyhow::anyhow!(
199                "tracker reply exceeded the {} byte limit",
200                MAX_TRACKER_BODY_BYTES
201            ));
202        }
203        body.extend_from_slice(&chunk);
204    }
205    Ok(body)
206}
207
208/// Percent-encode bytes for a query-parameter value, escaping everything but RFC 3986's
209/// unreserved set.
210fn percent_encode(bytes: &[u8]) -> String {
211    let mut out = String::with_capacity(bytes.len() * 3);
212    for byte in bytes {
213        match byte {
214            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
215                out.push(*byte as char)
216            }
217            other => out.push_str(&format!("%{:02X}", other)),
218        }
219    }
220    out
221}
222
223/// Undo percent-encoding. A stray `%` that is not followed by two hex digits is kept as a
224/// literal `%`, which is what every lenient decoder does and what re-encoding then escapes.
225/// Works on bytes throughout rather than slicing the `&str`: `&value[i + 1..i + 3]` would
226/// panic if a `%` were followed by a multi-byte UTF-8 character, and this value comes from
227/// the model. That is the byte-index-slicing defect `crate::utils::truncate` exists for.
228fn percent_decode(value: &str) -> Vec<u8> {
229    let bytes = value.as_bytes();
230    let mut out = Vec::with_capacity(bytes.len());
231    let mut i = 0;
232    while i < bytes.len() {
233        if bytes[i] == b'%' && i + 2 < bytes.len() {
234            let hi = (bytes[i + 1] as char).to_digit(16);
235            let lo = (bytes[i + 2] as char).to_digit(16);
236            if let (Some(hi), Some(lo)) = (hi, lo) {
237                out.push((hi * 16 + lo) as u8);
238                i += 3;
239                continue;
240            }
241        }
242        out.push(bytes[i]);
243        i += 1;
244    }
245    out
246}
247
248/// Normalise a model-supplied `info_hash` or `peer_id` into the percent-encoded form BEP 3
249/// carries in the query string.
250///
251/// These are twenty raw bytes, and their value comes from the model. They used to be
252/// interpolated into the URL with `format!` and nothing else, so an `&` or a `#` in one
253/// inserted **extra query parameters** into the announce: a model told to announce one
254/// info_hash could append its own `&event=completed`, overwrite `port`, or truncate the whole
255/// query with a fragment. The parameter description told the model to send the value already
256/// URL-encoded — which is not a thing to leave to the component being constrained.
257///
258/// Three input spellings are accepted because all three are what a model actually produces,
259/// and each is reduced to the same twenty bytes before being encoded exactly once:
260///
261/// * 40 hex characters — the form NetGet's own tracker *server* reports an inbound
262///   `info_hash`/`peer_id` in, so it is what a model echoing an event will send.
263/// * already percent-encoded (`%12%34…`) — decoded and re-encoded, which normalises it and
264///   strips any separator that was sitting in it unescaped.
265/// * anything else — raw text, e.g. a literal peer id like `-TR2940-abcdefghijkl`.
266///
267/// Blind escaping would have been wrong for the second case: `%12` would become `%2512` and
268/// the tracker would read the three characters `%12` rather than the byte `0x12`.
269fn encode_binary_query_value(value: &str) -> String {
270    let bytes = if value.len() == 40 && value.bytes().all(|b| b.is_ascii_hexdigit()) {
271        hex::decode(value).unwrap_or_else(|_| value.as_bytes().to_vec())
272    } else if value.contains('%') {
273        percent_decode(value)
274    } else {
275        value.as_bytes().to_vec()
276    };
277    percent_encode(&bytes)
278}
279
280/// Refuse a bencoded tracker reply whose nesting would recurse `serde_bencode` off the stack.
281///
282/// `serde_bencode` counts no depth, and a derived struct is no safer than a raw `Value`
283/// because serde skips unknown fields through `IgnoredAny`, which lands back in
284/// `deserialize_any`. A reply body of `l` bytes therefore recurses once per byte; a stack
285/// overflow is `SIGSEGV`, so it aborts the whole netget process rather than failing this one
286/// action. The tracker is whatever address the operator or the model pointed this client at.
287fn screen_tracker_body(body: &[u8]) -> Result<()> {
288    crate::utils::bencode::check_bencode_structure(body)
289        .map_err(|e| anyhow::anyhow!("tracker reply refused before decoding: {}", e))
290}
291
292/// BitTorrent Tracker client
293pub struct TorrentTrackerClient;
294
295impl TorrentTrackerClient {
296    /// Connect to a BitTorrent tracker with integrated LLM actions
297    pub async fn connect_with_llm_actions(
298        remote_addr: String,
299        llm_client: OllamaClient,
300        app_state: Arc<AppState>,
301        status_tx: mpsc::UnboundedSender<String>,
302        client_id: ClientId,
303    ) -> Result<SocketAddr> {
304        // BitTorrent tracker is HTTP-based, so we don't maintain a persistent connection
305        // We'll just track the tracker URL and make HTTP requests as needed
306        let remote_addr = resolve_tracker_url(&remote_addr);
307        // In the browser build the transport has no TLS: an `https://` tracker is refused
308        // here, with the reason, rather than on the first announce.
309        crate::client::http_fetch::check_url(&remote_addr)?;
310
311        info!(
312            "BitTorrent Tracker client {} initialized for {}",
313            client_id, remote_addr
314        );
315
316        // Update client state
317        app_state
318            .update_client_status(client_id, ClientStatus::Connected)
319            .await;
320        let _ = status_tx.send(format!(
321            "[CLIENT] BitTorrent Tracker client {} connected to {}",
322            client_id, remote_addr
323        ));
324        let _ = status_tx.send("__UPDATE_UI__".to_string());
325
326        // Command channel for injected actions (the dashboard's [ send ] row).
327        // Registered - and already being drained by its own task - BEFORE the
328        // connected-event LLM call, which a manual `*` rule can park for minutes: the
329        // operator must be able to reach the client while it waits. This is also what
330        // keeps the client reachable at all, since a tracker client has no read loop.
331        let command_rx =
332            crate::client::command_support::register_command_channel(&app_state, client_id).await;
333        let cmd_task = tokio::spawn(Self::command_loop(
334            command_rx,
335            client_id,
336            remote_addr.clone(),
337            app_state.clone(),
338            llm_client.clone(),
339            status_tx.clone(),
340        ));
341        app_state.register_client_task(client_id, cmd_task).await;
342
343        // Call LLM with connected event
344        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
345            let protocol = Arc::new(
346                crate::client::torrent_tracker::actions::TorrentTrackerClientProtocol::new(),
347            );
348            let event = Event::new(
349                &TRACKER_ANNOUNCE_RESPONSE_EVENT,
350                serde_json::json!({
351                    "tracker_url": remote_addr,
352                    "status": "connected",
353                }),
354            );
355
356            let memory = app_state
357                .get_memory_for_client(client_id)
358                .await
359                .unwrap_or_default();
360
361            // Execute LLM call in background
362            let app_state_clone = app_state.clone();
363            let status_tx_clone = status_tx.clone();
364            // Registered with AppState so stop_client can abort this task —
365            // dropping a JoinHandle only detaches it in Tokio.
366            let task_registrar = app_state.clone();
367            let task_handle = tokio::spawn(async move {
368                match call_llm_for_client(
369                    &llm_client,
370                    &app_state_clone,
371                    client_id.to_string(),
372                    &instruction,
373                    &memory,
374                    Some(&event),
375                    protocol.as_ref(),
376                    &status_tx_clone,
377                )
378                .await
379                {
380                    Ok(ClientLlmResult {
381                        actions,
382                        memory_updates,
383                    }) => {
384                        // Update memory
385                        if let Some(mem) = memory_updates {
386                            app_state_clone.set_memory_for_client(client_id, mem).await;
387                        }
388
389                        // Execute actions
390                        for action in actions {
391                            match protocol.as_ref().execute_action(action.clone()) {
392                                Ok(result) => {
393                                    if let Err(e) = Self::apply_action(
394                                        client_id,
395                                        result,
396                                        Notify::Inline,
397                                        0,
398                                        &remote_addr,
399                                        &app_state_clone,
400                                        &llm_client,
401                                        &status_tx_clone,
402                                    )
403                                    .await
404                                    {
405                                        error!("Failed to execute tracker action: {}", e);
406                                    }
407                                }
408                                Err(e) => {
409                                    error!("Tracker action execution error: {}", e);
410                                }
411                            }
412                        }
413                    }
414                    Err(e) => {
415                        error!("LLM error for Tracker client {}: {}", client_id, e);
416                    }
417                }
418            });
419            task_registrar
420                .register_client_task(client_id, task_handle)
421                .await;
422        }
423
424        // Return a dummy local address (tracker is HTTP-based)
425        Ok("0.0.0.0:0".parse()?)
426    }
427
428    /// Drain injected commands until the channel closes (client removed) or an injected
429    /// `disconnect` ends the session.
430    ///
431    /// `command_support::handle_stream_client_command` cannot serve this client: it writes
432    /// `SendData` to a socket, and both tracker verbs yield `ClientActionResult::Custom`
433    /// that has to become an HTTP GET against the tracker. So the action goes through
434    /// [`Self::apply_action`] - the same function the connected-event path uses - and the
435    /// outcome is recorded and replied exactly the way the generic arm does it.
436    async fn command_loop(
437        mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
438        client_id: ClientId,
439        tracker_url: String,
440        app_state: Arc<AppState>,
441        llm_client: OllamaClient,
442        status_tx: mpsc::UnboundedSender<String>,
443    ) {
444        let protocol = crate::client::torrent_tracker::actions::TorrentTrackerClientProtocol::new();
445
446        while let Some(command) = command_rx.recv().await {
447            let action = command.action.clone();
448            let outcome = match protocol.execute_action(action.clone()) {
449                Err(e) => Ok(ClientSendOutcome::Rejected {
450                    error: e.to_string(),
451                }),
452                // The HTTP GET is awaited, so the reported outcome describes a request
453                // that has actually completed. Notify::Deferred delivers the tracker
454                // response event from its own registered task, so a manual handler parked
455                // for a human's think time cannot wedge this loop or time out the
456                // dashboard's [ send ].
457                Ok(result) => Self::apply_action(
458                    client_id,
459                    result,
460                    Notify::Deferred,
461                    0,
462                    &tracker_url,
463                    &app_state,
464                    &llm_client,
465                    &status_tx,
466                )
467                .await
468                .map(|applied| match applied {
469                    Applied::Disconnect => ClientSendOutcome::Disconnected,
470                    Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
471                }),
472            };
473
474            let outcome_json = match &outcome {
475                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
476                Err(e) => serde_json::json!({"error": e.to_string()}),
477            };
478            app_state
479                .record_access_log(
480                    AccessLogOwner::Client(client_id.as_u32()),
481                    protocol.protocol_name(),
482                    None,
483                    "injected_action",
484                    action,
485                    vec![outcome_json],
486                )
487                .await;
488
489            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
490            if let Err(e) = &outcome {
491                error!("Tracker client {} injected action failed: {}", client_id, e);
492                let _ = status_tx.send(format!(
493                    "[WARN] Client {} injected action failed: {}",
494                    client_id, e
495                ));
496            }
497            let _ = status_tx.send("__UPDATE_UI__".to_string());
498            crate::client::command_support::reply(command, outcome);
499
500            if disconnect {
501                break;
502            }
503        }
504
505        info!("Tracker client {} command loop stopped", client_id);
506        app_state.remove_client_handle(client_id).await;
507        let _ = status_tx.send("__UPDATE_UI__".to_string());
508    }
509
510    /// Deliver a tracker response event to the LLM, inline or from its own registered
511    /// task depending on `notify`.
512    #[allow(clippy::too_many_arguments)]
513    #[allow(clippy::too_many_arguments)]
514    async fn deliver(
515        notify: Notify,
516        client_id: ClientId,
517        event_type: &'static crate::protocol::EventType,
518        event_data: serde_json::Value,
519        depth: u8,
520        tracker_url: &str,
521        app_state: &Arc<AppState>,
522        llm_client: &OllamaClient,
523        status_tx: &mpsc::UnboundedSender<String>,
524    ) {
525        match notify {
526            Notify::Inline => {
527                Self::notify_response(
528                    client_id,
529                    event_type,
530                    event_data,
531                    depth,
532                    tracker_url.to_string(),
533                    app_state,
534                    llm_client,
535                    status_tx,
536                )
537                .await
538            }
539            Notify::Deferred => {
540                let state_clone = app_state.clone();
541                let llm_clone = llm_client.clone();
542                let status_clone = status_tx.clone();
543                let tracker_url = tracker_url.to_string();
544                let notify_handle = tokio::spawn(async move {
545                    TorrentTrackerClient::notify_response(
546                        client_id,
547                        event_type,
548                        event_data,
549                        depth,
550                        tracker_url,
551                        &state_clone,
552                        &llm_clone,
553                        &status_clone,
554                    )
555                    .await;
556                });
557                // Registered so the notification - and the LLM call it makes - is aborted
558                // when the client is stopped.
559                app_state
560                    .register_client_task(client_id, notify_handle)
561                    .await;
562            }
563        }
564    }
565
566    /// Fire one tracker response event at the LLM, apply any memory update, and **carry out
567    /// what it answered with**.
568    ///
569    /// The answer used to be destructured as `Ok(ClientLlmResult { memory_updates, .. })` —
570    /// the actions dropped by the `..`. That cut every chain at one step: the model asked for
571    /// an announce, the tracker's peer list came back, the model was told about it, chose what
572    /// to do next, and was ignored. A tracker client could make exactly one request per
573    /// instruction and then went deaf.
574    ///
575    /// Executing here makes the cycle real — announce → `tracker_announce_response` →
576    /// announce — so it is bounded rather than cut. `depth` counts LLM turns in one chain and
577    /// [`MAX_FOLLOWUP_DEPTH`] stops it; [`Self::apply_action`] returns an explicitly boxed
578    /// future so the three-function cycle's opaque types can be inferred at all.
579    #[allow(clippy::too_many_arguments)]
580    async fn notify_response(
581        client_id: ClientId,
582        event_type: &'static crate::protocol::EventType,
583        event_data: serde_json::Value,
584        depth: u8,
585        tracker_url: String,
586        app_state: &Arc<AppState>,
587        llm_client: &OllamaClient,
588        status_tx: &mpsc::UnboundedSender<String>,
589    ) {
590        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
591            return;
592        };
593        let event = Event::new(event_type, event_data);
594        let memory = app_state
595            .get_memory_for_client(client_id)
596            .await
597            .unwrap_or_default();
598        let protocol =
599            Arc::new(crate::client::torrent_tracker::actions::TorrentTrackerClientProtocol::new());
600
601        match call_llm_for_client(
602            llm_client,
603            app_state,
604            client_id.to_string(),
605            &instruction,
606            &memory,
607            Some(&event),
608            protocol.as_ref(),
609            status_tx,
610        )
611        .await
612        {
613            Ok(ClientLlmResult {
614                actions,
615                memory_updates,
616            }) => {
617                if let Some(mem) = memory_updates {
618                    app_state.set_memory_for_client(client_id, mem).await;
619                }
620
621                if actions.is_empty() {
622                    return;
623                }
624                if depth >= MAX_FOLLOWUP_DEPTH {
625                    warn!(
626                        "Tracker client {} stopped a follow-up chain at depth {}: {} action(s) \
627                         not executed",
628                        client_id,
629                        depth,
630                        actions.len()
631                    );
632                    let _ = status_tx.send(format!(
633                        "[CLIENT] ⚠ tracker client {} hit the follow-up depth limit ({}); \
634                         {} action(s) were not executed",
635                        client_id,
636                        MAX_FOLLOWUP_DEPTH,
637                        actions.len()
638                    ));
639                    return;
640                }
641
642                for action in actions {
643                    match protocol.as_ref().execute_action(action) {
644                        Ok(result) => {
645                            match Self::apply_action(
646                                client_id,
647                                result,
648                                Notify::Inline,
649                                depth + 1,
650                                &tracker_url,
651                                app_state,
652                                llm_client,
653                                status_tx,
654                            )
655                            .await
656                            {
657                                Ok(Applied::Disconnect) => {
658                                    app_state
659                                        .update_client_status(client_id, ClientStatus::Disconnected)
660                                        .await;
661                                    let _ = status_tx.send("__UPDATE_UI__".to_string());
662                                    return;
663                                }
664                                Ok(Applied::Executed(detail)) => {
665                                    trace!("Tracker client {} follow-up: {}", client_id, detail);
666                                }
667                                Err(e) => {
668                                    error!("Tracker client {} follow-up failed: {}", client_id, e);
669                                }
670                            }
671                        }
672                        Err(e) => {
673                            error!(
674                                "Tracker client {} rejected follow-up action: {}",
675                                client_id, e
676                            );
677                        }
678                    }
679                }
680            }
681            Err(e) => {
682                error!("LLM error: {}", e);
683            }
684        }
685    }
686
687    /// Apply one already-executed action result. The single place a tracker announce or
688    /// scrape is issued from, so an injected action behaves exactly like an LLM-produced
689    /// one.
690    #[allow(clippy::too_many_arguments)]
691    /// Returns an explicitly boxed future rather than being an `async fn`, and that is load-
692    /// bearing: the chain is `apply_action` -> `deliver` -> `notify_response` ->
693    /// `apply_action`, and three `async fn`s in a cycle cannot have their opaque return types
694    /// inferred (E0391). Boxing at the *call* site does not help -- coercing to
695    /// `dyn Future` still needs the callee's opaque type. Naming the type here breaks the
696    /// cycle at the definition. `+ Send` is explicit because `Notify::Deferred` awaits this
697    /// inside a `tokio::spawn`.
698    #[allow(clippy::too_many_arguments)]
699    fn apply_action<'a>(
700        client_id: ClientId,
701        result: ClientActionResult,
702        notify: Notify,
703        depth: u8,
704        tracker_url: &'a str,
705        app_state: &'a Arc<AppState>,
706        llm_client: &'a OllamaClient,
707        status_tx: &'a mpsc::UnboundedSender<String>,
708    ) -> Pin<Box<dyn Future<Output = Result<Applied>> + Send + 'a>> {
709        Box::pin(async move {
710            match result {
711                ClientActionResult::Custom { name, data } if name == "tracker_announce" => {
712                    let info_hash = data
713                        .get("info_hash")
714                        .and_then(|v| v.as_str())
715                        .context("Missing info_hash")?;
716                    let peer_id = data
717                        .get("peer_id")
718                        .and_then(|v| v.as_str())
719                        .context("Missing peer_id")?;
720                    let port = data
721                        .get("port")
722                        .and_then(|v| v.as_u64())
723                        .context("Missing port")?;
724                    let port = u16::try_from(port).context("tracker port exceeds u16")?;
725                    let uploaded = data.get("uploaded").and_then(|v| v.as_u64()).unwrap_or(0);
726                    let downloaded = data.get("downloaded").and_then(|v| v.as_u64()).unwrap_or(0);
727                    // `left` is not a neutral counter: BEP 3 defines `left=0` as "I have the
728                    // complete torrent", and trackers use it to decide who is a seeder. A
729                    // model that says nothing about it should not be announcing itself as
730                    // having everything, so the default is the opposite claim. An explicit 0
731                    // still means seeder, which is the point.
732                    let left = data
733                        .get("left")
734                        .and_then(|v| v.as_u64())
735                        .unwrap_or(u64::MAX);
736                    let event_type = data
737                        .get("event")
738                        .and_then(|v| v.as_str())
739                        .unwrap_or("started");
740
741                    // Build announce URL.
742                    //
743                    // `info_hash`, `peer_id` and `event` come from the model and are
744                    // percent-encoded rather than interpolated raw: an `&` in any of them
745                    // used to insert extra query parameters into the announce, so a model
746                    // told to announce one info_hash could append its own `event=completed`
747                    // or overwrite `port`. The numeric fields are `u64` already and cannot
748                    // carry a separator.
749                    let announce_url = format!(
750                        "{}?info_hash={}&peer_id={}&port={}&uploaded={}&downloaded={}&left={}&event={}",
751                        tracker_url,
752                        encode_binary_query_value(info_hash),
753                        encode_binary_query_value(peer_id),
754                        port,
755                        uploaded,
756                        downloaded,
757                        left,
758                        percent_encode(event_type.as_bytes())
759                    );
760
761                    trace!(
762                        "Tracker client {} announcing to: {}",
763                        client_id,
764                        announce_url
765                    );
766
767                    // Bounded fetch: a shared client with a timeout, a body cap applied as
768                    // the bytes arrive, and a nesting screen before serde_bencode sees them.
769                    let body = fetch_tracker_body(tracker_url, &announce_url).await?;
770                    if let Err(e) = screen_tracker_body(&body) {
771                        warn!("Tracker client {} refused announce reply: {}", client_id, e);
772                        return Ok(Applied::Executed(format!(
773                            "tracker_announce sent; the tracker's reply was refused: {e}"
774                        )));
775                    }
776
777                    // Parse bencode response
778                    match serde_bencode::from_bytes::<TrackerResponse>(&body) {
779                        Ok(tracker_resp) => {
780                            trace!("Tracker response: {:?}", tracker_resp);
781
782                            Self::deliver(
783                                notify,
784                                client_id,
785                                &TRACKER_ANNOUNCE_RESPONSE_EVENT,
786                                serde_json::json!({
787                                    "interval": tracker_resp.interval,
788                                    "complete": tracker_resp.complete,
789                                    "incomplete": tracker_resp.incomplete,
790                                    "peers": format!("{:?}", tracker_resp.peers),
791                                }),
792                                depth,
793                                tracker_url,
794                                app_state,
795                                llm_client,
796                                status_tx,
797                            )
798                            .await;
799                        }
800                        Err(e) => {
801                            error!("Failed to parse tracker response: {}", e);
802                            return Ok(Applied::Executed(format!(
803                                "tracker_announce sent; the tracker's reply could not be \
804                                 bdecoded: {e}"
805                            )));
806                        }
807                    }
808                    Ok(Applied::Executed("tracker_announce completed".to_string()))
809                }
810                ClientActionResult::Custom { name, data } if name == "tracker_scrape" => {
811                    let info_hash = data
812                        .get("info_hash")
813                        .and_then(|v| v.as_str())
814                        .context("Missing info_hash")?;
815
816                    // Build scrape URL. Percent-encoded for the same reason as the announce
817                    // above: an `&` in a model-supplied info_hash is otherwise a separator.
818                    let scrape_url = format!(
819                        "{}?info_hash={}",
820                        tracker_url,
821                        encode_binary_query_value(info_hash)
822                    );
823
824                    trace!("Tracker client {} scraping: {}", client_id, scrape_url);
825
826                    // Same bounded fetch and nesting screen as the announce path above.
827                    let body = fetch_tracker_body(tracker_url, &scrape_url).await?;
828                    if let Err(e) = screen_tracker_body(&body) {
829                        warn!("Tracker client {} refused scrape reply: {}", client_id, e);
830                        return Ok(Applied::Executed(format!(
831                            "tracker_scrape sent; the tracker's reply was refused: {e}"
832                        )));
833                    }
834
835                    // Parse bencode response
836                    match serde_bencode::from_bytes::<ScrapeResponse>(&body) {
837                        Ok(scrape_resp) => {
838                            trace!("Scrape response: {:?}", scrape_resp);
839
840                            Self::deliver(
841                                notify,
842                                client_id,
843                                &TRACKER_SCRAPE_RESPONSE_EVENT,
844                                serde_json::json!({
845                                    "files": format!("{:?}", scrape_resp.files),
846                                }),
847                                depth,
848                                tracker_url,
849                                app_state,
850                                llm_client,
851                                status_tx,
852                            )
853                            .await;
854                        }
855                        Err(e) => {
856                            error!("Failed to parse scrape response: {}", e);
857                            return Ok(Applied::Executed(format!(
858                                "tracker_scrape sent; the tracker's reply could not be \
859                                 bdecoded: {e}"
860                            )));
861                        }
862                    }
863                    Ok(Applied::Executed("tracker_scrape completed".to_string()))
864                }
865                ClientActionResult::Disconnect => {
866                    info!("Tracker client {} disconnecting", client_id);
867                    app_state
868                        .update_client_status(client_id, ClientStatus::Disconnected)
869                        .await;
870                    // Every exit path drops the handle so the dashboard stops offering
871                    // [ send ] into a dead client.
872                    app_state.remove_client_handle(client_id).await;
873                    let _ = status_tx.send("__UPDATE_UI__".to_string());
874                    Ok(Applied::Disconnect)
875                }
876                other => Ok(Applied::Executed(format!(
877                    "{other:?} produced no tracker request"
878                ))),
879            }
880        })
881    }
882}
883
884/// When an action's tracker response event is delivered to the LLM.
885///
886/// The announce/scrape GET itself is always awaited; only the notification moves. The
887/// request is already inside a spawned task on the LLM-driven path, so unlike `http` there
888/// is no second "spawn the request" mode here.
889#[derive(Clone, Copy)]
890enum Notify {
891    /// Fire the event before returning. The LLM-driven path.
892    Inline,
893    /// Fire the event from its own registered task and return at once. The
894    /// injected-command path, which must reply to the operator first.
895    Deferred,
896}
897
898/// What [`TorrentTrackerClient::apply_action`] did with one action. A tracker client owns
899/// no socket - each announce/scrape is a one-shot HTTP GET - so there is no honest byte
900/// count to report, only "the request ran" or "the session should end".
901enum Applied {
902    /// The action ran; the string says what, for the injected action's outcome detail.
903    Executed(String),
904    /// The session should end.
905    Disconnect,
906}