Skip to main content

netget/server/dict/
mod.rs

1//! DICT (RFC 2229) server — the model is the dictionary.
2//!
3//! The server greets with `220`, then reads one command per line. The commands whose answer is
4//! *content* — DEFINE, MATCH, SHOW DB/STRAT/INFO/SERVER — raise an event and the model answers
5//! with a structured action. Everything whose answer is fixed by the protocol — CLIENT, STATUS,
6//! HELP, OPTION, QUIT, AUTH, and any command this server does not know — is answered here
7//! without consulting anyone, so a stranger typing garbage costs no model call.
8//!
9//! Four properties worth knowing before changing the loop:
10//!
11//! 1. **A command is a line, and a line is bounded.** RFC 2229 caps a command line at 1024
12//!    bytes including CRLF ([`wire::MAX_LINE_BYTES`]). A longer one is refused with `500` and
13//!    the connection closes — there is no way to resynchronise with a peer that ignored a
14//!    MUST, and buffering toward its newline is a memory hole.
15//! 2. **The model cannot write framing.** Its actions are rendered by [`wire`], and the loop
16//!    then checks that the reply's status code fits the command it answers (a `152` to a
17//!    DEFINE is refused, not sent). A reply that does not fit, no reply, and a backend failure
18//!    all end the same way: `420 Server temporarily unavailable`, then close.
19//! 3. **OPTION MIME is connection state the executor cannot see.** The action executor is
20//!    stateless, so the loop applies [`wire::apply_mime`] to what the executor rendered.
21//!    Replies injected from the dashboard (`[ message ]`) do not pass through the loop and are
22//!    written without the MIME preface.
23//! 4. **The deadlines wrap the read, not the answer.** A command parked for a human under a
24//!    `manual` rule is never closed by `first_byte_timeout_secs` or `idle_timeout_secs`.
25pub mod actions;
26pub mod wire;
27
28use crate::llm::action_helper::call_llm;
29use crate::llm::actions::protocol_trait::ActionResult;
30use crate::llm::ollama_client::OllamaClient;
31use crate::logging::emit::Log;
32use crate::protocol::{Event, EventType};
33use crate::server::connection::ConnectionId;
34use crate::state::app_state::AppState;
35use anyhow::Result;
36use std::net::SocketAddr;
37use std::sync::Arc;
38use std::time::Duration;
39use tokio::io::{AsyncReadExt, AsyncWriteExt};
40use tokio::net::TcpListener;
41use tokio::sync::{mpsc, Mutex};
42
43pub use wire::MAX_LINE_BYTES;
44
45/// How long a peer that has been greeted may take to send its first command.
46///
47/// DICT is server-speaks-first, so a real client answers the banner at once (`dict(1)` sends
48/// `CLIENT` within the same millisecond). 300 seconds is not about real clients: it is the
49/// window a `manual` rule gives a human, and the peer may be NetGet's own TCP client whose
50/// banner event is parked for its operator. A listener exposed to strangers should lower it
51/// through `first_byte_timeout_secs`.
52const FIRST_COMMAND_TIMEOUT: Duration = Duration::from_secs(300);
53
54/// How long the server waits for the next command after answering one.
55///
56/// A DICT session is several lookups down one connection when a person drives it by hand, and
57/// `dict(1)` sends QUIT the moment it has its answer, so this bound only ever matters for a peer
58/// that has gone quiet. `idle_timeout_secs` overrides it.
59const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
60
61/// Concurrent connections admitted before new ones are refused — the house default. A DICT
62/// connection holds a [`MAX_LINE_BYTES`] buffer and one task, and each deadline above is 300
63/// seconds, so without a cap one peer could hold an unbounded number of them.
64const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
65
66/// What a peer over [`MAX_CONNECTIONS`] is told: RFC 2229 §3.1 lists `420 Server temporarily
67/// unavailable` as a greeting the server may send instead of `220`, which is exactly this.
68const CONNECTION_CAP_REFUSAL: &[u8] = b"420 Server temporarily unavailable\r\n";
69
70/// The peer-visible answer when the model could not or did not answer. Both categories are
71/// `420` — DICT has no separate "overloaded" code — and the text says which, from fixed
72/// literals, so nothing derived from the error can reach the wire.
73const UNAVAILABLE_REPLY: &[u8] = b"420 Server temporarily unavailable\r\n";
74const OVERLOADED_REPLY: &[u8] = b"420 Server temporarily unavailable, backend at capacity\r\n";
75
76/// Fixed HELP text (113). It lists what this server implements, which only NetGet knows.
77const HELP_LINES: &[&str] = &[
78    "DEFINE database word         -- look up word in database",
79    "MATCH database strategy word -- match word in database using strategy",
80    "SHOW DB                      -- list all accessible databases",
81    "SHOW DATABASES               -- list all accessible databases",
82    "SHOW STRAT                   -- list available matching strategies",
83    "SHOW STRATEGIES              -- list available matching strategies",
84    "SHOW INFO database           -- provide information about the database",
85    "SHOW SERVER                  -- provide site-specific information",
86    "OPTION MIME                  -- use MIME headers",
87    "CLIENT info                  -- identify client to server",
88    "STATUS                       -- display timing information",
89    "HELP                         -- display this help information",
90    "QUIT                         -- terminate connection",
91];
92
93pub struct DictServer;
94
95impl DictServer {
96    pub async fn spawn_with_llm_actions(
97        listen_addr: SocketAddr,
98        llm_client: OllamaClient,
99        app_state: Arc<AppState>,
100        status_tx: mpsc::UnboundedSender<String>,
101        server_id: crate::state::ServerId,
102        first_byte_timeout_secs: Option<u64>,
103        idle_timeout_secs: Option<u64>,
104    ) -> Result<SocketAddr> {
105        let first_timeout = first_byte_timeout_secs
106            .map(Duration::from_secs)
107            .unwrap_or(FIRST_COMMAND_TIMEOUT);
108        let idle_timeout = idle_timeout_secs
109            .map(Duration::from_secs)
110            .unwrap_or(IDLE_TIMEOUT);
111        let listener = TcpListener::bind(listen_addr).await?;
112        let local_addr = listener.local_addr()?;
113
114        Log::new(Some(&status_tx)).info(format!("DICT server listening on {}", local_addr));
115
116        let protocol = Arc::new(actions::DictProtocol::new());
117        let task_registrar = app_state.clone();
118        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
119        let accept_handle = tokio::spawn(async move {
120            loop {
121                match crate::server::accept_bounded::accept_bounded(
122                    &listener,
123                    &limiter,
124                    CONNECTION_CAP_REFUSAL,
125                    "DICT",
126                    Some(&status_tx),
127                )
128                .await
129                {
130                    Ok((socket, peer_addr, permit)) => {
131                        let connection_id =
132                            ConnectionId::new(app_state.get_next_unified_id().await);
133                        use crate::state::server::{
134                            ConnectionState as ServerConnectionState, ConnectionStatus,
135                            ProtocolConnectionInfo,
136                        };
137                        let now = crate::utils::clock::Instant::now();
138                        app_state
139                            .add_connection_to_server(
140                                server_id,
141                                ServerConnectionState {
142                                    id: connection_id,
143                                    remote_addr: peer_addr,
144                                    local_addr,
145                                    bytes_sent: 0,
146                                    bytes_received: 0,
147                                    packets_sent: 0,
148                                    packets_received: 0,
149                                    last_activity: now,
150                                    status: ConnectionStatus::Active,
151                                    status_changed_at: now,
152                                    protocol_info: ProtocolConnectionInfo::empty(),
153                                },
154                            )
155                            .await;
156                        let _ = status_tx.send("__UPDATE_UI__".to_string());
157                        Log::new(Some(&status_tx))
158                            .info(format!("DICT client connected from {}", peer_addr));
159
160                        let session = Session {
161                            peer_addr,
162                            llm_client: llm_client.clone(),
163                            app_state: app_state.clone(),
164                            status_tx: status_tx.clone(),
165                            server_id,
166                            protocol: protocol.clone(),
167                            connection_id,
168                            first_timeout,
169                            idle_timeout,
170                        };
171                        // Tracked, not detached: stop_server must abort this task too.
172                        let task_owner = app_state.clone();
173                        task_owner
174                            .spawn_server_task(server_id, async move {
175                                // Released when the connection ends; this task is the whole
176                                // connection, so MAX_CONNECTIONS caps live connections.
177                                let _permit = permit;
178                                session.run(socket).await
179                            })
180                            .await;
181                    }
182                    Err(e) => {
183                        Log::new(Some(&status_tx)).error(format!("DICT accept error: {}", e));
184                        break;
185                    }
186                }
187            }
188        });
189
190        task_registrar
191            .register_server_task(server_id, accept_handle)
192            .await;
193        Ok(local_addr)
194    }
195}
196
197/// One parsed command line.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum Command {
200    Define {
201        database: String,
202        word: String,
203    },
204    Match {
205        database: String,
206        strategy: String,
207        word: String,
208    },
209    ShowDatabases,
210    ShowStrategies,
211    ShowInfo {
212        database: String,
213    },
214    ShowServer,
215    Client,
216    Status,
217    Help,
218    OptionMime,
219    /// `OPTION` with anything other than MIME.
220    OptionOther,
221    Quit,
222    /// AUTH, SASLAUTH, SASLRESP: recognised, not implemented.
223    NotImplemented,
224    /// A known command with the wrong parameters.
225    BadParameters,
226    /// Not a DICT command.
227    Unknown,
228}
229
230/// Parse one command line (without its line terminator). Commands are case-insensitive.
231pub fn parse_command(line: &str) -> Command {
232    let Ok(args) = wire::split_args(line) else {
233        return Command::BadParameters;
234    };
235    let Some(verb) = args.first() else {
236        return Command::Unknown;
237    };
238    let rest = &args[1..];
239    match verb.to_ascii_uppercase().as_str() {
240        "DEFINE" => match rest {
241            [database, word] => Command::Define {
242                database: database.clone(),
243                word: word.clone(),
244            },
245            _ => Command::BadParameters,
246        },
247        "MATCH" => match rest {
248            [database, strategy, word] => Command::Match {
249                database: database.clone(),
250                strategy: strategy.clone(),
251                word: word.clone(),
252            },
253            _ => Command::BadParameters,
254        },
255        "SHOW" => {
256            let sub = rest.first().map(|s| s.to_ascii_uppercase());
257            match (sub.as_deref(), rest.len()) {
258                (Some("DB") | Some("DATABASES"), 1) => Command::ShowDatabases,
259                (Some("STRAT") | Some("STRATEGIES"), 1) => Command::ShowStrategies,
260                (Some("INFO"), 2) => Command::ShowInfo {
261                    database: rest[1].clone(),
262                },
263                (Some("SERVER"), 1) => Command::ShowServer,
264                _ => Command::BadParameters,
265            }
266        }
267        "CLIENT" => Command::Client,
268        "STATUS" => Command::Status,
269        "HELP" => Command::Help,
270        "OPTION" => match rest {
271            [opt] if opt.eq_ignore_ascii_case("MIME") => Command::OptionMime,
272            [] => Command::BadParameters,
273            _ => Command::OptionOther,
274        },
275        "QUIT" => Command::Quit,
276        "AUTH" | "SASLAUTH" | "SASLRESP" => Command::NotImplemented,
277        _ => Command::Unknown,
278    }
279}
280
281impl Command {
282    /// The event this command raises and its data, for the commands the model answers.
283    fn event(&self) -> Option<(&'static EventType, serde_json::Value)> {
284        use actions::{DICT_DEFINE_EVENT, DICT_MATCH_EVENT, DICT_SHOW_EVENT};
285        let (event, data) = match self {
286            Command::Define { database, word } => (
287                &*DICT_DEFINE_EVENT,
288                serde_json::json!({"database": database, "word": word}),
289            ),
290            Command::Match {
291                database,
292                strategy,
293                word,
294            } => (
295                &*DICT_MATCH_EVENT,
296                serde_json::json!({"database": database, "strategy": strategy, "word": word}),
297            ),
298            Command::ShowDatabases => (&*DICT_SHOW_EVENT, serde_json::json!({"what": "databases"})),
299            Command::ShowStrategies => {
300                (&*DICT_SHOW_EVENT, serde_json::json!({"what": "strategies"}))
301            }
302            Command::ShowInfo { database } => (
303                &*DICT_SHOW_EVENT,
304                serde_json::json!({"what": "info", "database": database}),
305            ),
306            Command::ShowServer => (&*DICT_SHOW_EVENT, serde_json::json!({"what": "server"})),
307            _ => return None,
308        };
309        Some((event, data))
310    }
311
312    /// The success code a model reply to this command must carry. A 5xx is always acceptable.
313    fn success_code(&self) -> Option<u16> {
314        Some(match self {
315            Command::Define { .. } => 150,
316            Command::Match { .. } => 152,
317            Command::ShowDatabases => 110,
318            Command::ShowStrategies => 111,
319            Command::ShowInfo { .. } => 112,
320            Command::ShowServer => 114,
321            _ => return None,
322        })
323    }
324
325    /// NetGet's own reply to a command the model is not asked about, and whether to close.
326    fn fixed_reply(&self) -> Option<(String, bool)> {
327        let reply = match self {
328            Command::Client | Command::OptionMime => "250 ok\r\n".to_string(),
329            Command::Status => "210 status [netget]\r\n".to_string(),
330            Command::Help => {
331                let lines: Vec<String> = HELP_LINES.iter().map(|s| s.to_string()).collect();
332                let mut out = wire::text_block("113 help text follows", &lines);
333                out.push_str("250 ok\r\n");
334                out
335            }
336            Command::Quit => return Some(("221 Closing Connection\r\n".to_string(), true)),
337            Command::OptionOther => "503 Command parameter not implemented\r\n".to_string(),
338            Command::NotImplemented => "502 Command not implemented\r\n".to_string(),
339            Command::BadParameters => "501 Syntax error, illegal parameters\r\n".to_string(),
340            Command::Unknown => "500 Syntax error, command not recognized\r\n".to_string(),
341            _ => return None,
342        };
343        Some((reply, false))
344    }
345}
346
347struct Session {
348    peer_addr: SocketAddr,
349    llm_client: OllamaClient,
350    app_state: Arc<AppState>,
351    status_tx: mpsc::UnboundedSender<String>,
352    server_id: crate::state::ServerId,
353    protocol: Arc<actions::DictProtocol>,
354    connection_id: ConnectionId,
355    first_timeout: Duration,
356    idle_timeout: Duration,
357}
358
359/// What handling one command decided about the connection.
360enum Next {
361    Continue,
362    Close,
363}
364
365impl Session {
366    async fn run(self, socket: tokio::net::TcpStream) {
367        let (mut reader, write_half) = tokio::io::split(socket);
368        let write_half = Arc::new(Mutex::new(write_half));
369
370        // Registered before the banner, so the operator can reach this connection while a
371        // command is parked for them.
372        let peer_rx = crate::server::peer_support::register_peer_channel(
373            &self.app_state,
374            self.server_id,
375            self.connection_id.as_u32(),
376        )
377        .await;
378        crate::server::peer_support::spawn_peer_command_task(
379            peer_rx,
380            self.protocol.clone(),
381            self.app_state.clone(),
382            self.server_id,
383            self.connection_id.as_u32(),
384            write_half.clone(),
385            self.status_tx.clone(),
386        );
387
388        self.session(&mut reader, &write_half).await;
389
390        self.app_state
391            .remove_peer_handle(self.server_id, self.connection_id.as_u32())
392            .await;
393        let _ = write_half.lock().await.shutdown().await;
394        linger(&mut reader).await;
395        self.app_state
396            .update_connection_status(
397                self.server_id,
398                self.connection_id,
399                crate::state::server::ConnectionStatus::Closed,
400            )
401            .await;
402        let _ = self.status_tx.send("__UPDATE_UI__".to_string());
403    }
404
405    async fn write<W>(&self, write_half: &Arc<Mutex<W>>, data: &[u8]) -> std::io::Result<()>
406    where
407        W: tokio::io::AsyncWrite + Unpin,
408    {
409        {
410            let mut writer = write_half.lock().await;
411            writer.write_all(data).await?;
412            writer.flush().await?;
413        }
414        self.app_state
415            .update_connection_stats(
416                self.server_id,
417                self.connection_id,
418                None,
419                Some(data.len() as u64),
420                None,
421                Some(1),
422            )
423            .await;
424        Ok(())
425    }
426
427    async fn session<R, W>(&self, reader: &mut R, write_half: &Arc<Mutex<W>>)
428    where
429        R: tokio::io::AsyncRead + Unpin,
430        W: tokio::io::AsyncWrite + Unpin,
431    {
432        let log = Log::new(Some(&self.status_tx));
433        let banner = format!(
434            "220 netget DICT server <mime> <{}.{}.{}@netget>\r\n",
435            self.connection_id.as_u32(),
436            crate::utils::clock::process_id(),
437            crate::utils::clock::SystemTime::now()
438                .duration_since(crate::utils::clock::UNIX_EPOCH)
439                .map(|d| d.as_secs())
440                .unwrap_or(0)
441        );
442        if self.write(write_half, banner.as_bytes()).await.is_err() {
443            return;
444        }
445
446        let mut lines = LineReader::new(reader);
447        let mut mime = false;
448        let mut answered_one = false;
449
450        loop {
451            let timeout = if answered_one {
452                self.idle_timeout
453            } else {
454                self.first_timeout
455            };
456            let (line, n) = match lines.next_line(timeout).await {
457                LineRead::Line(line, n) => (line, n),
458                LineRead::Eof => {
459                    log.info(format!("DICT client {} disconnected", self.peer_addr));
460                    return;
461                }
462                LineRead::TimedOut => {
463                    log.info(format!(
464                        "DICT client {} sent no command within {}s; closing",
465                        self.peer_addr,
466                        timeout.as_secs()
467                    ));
468                    return;
469                }
470                LineRead::TooLong => {
471                    log.warn(format!(
472                        "DICT command from {} exceeded {} bytes decision=fail_closed_line_too_long",
473                        self.peer_addr, MAX_LINE_BYTES
474                    ));
475                    let _ = self
476                        .write(write_half, b"500 Syntax error, command line too long\r\n")
477                        .await;
478                    return;
479                }
480                LineRead::Failed(e) => {
481                    log.error(format!("DICT read error from {}: {}", self.peer_addr, e));
482                    return;
483                }
484            };
485            self.app_state
486                .update_connection_stats(
487                    self.server_id,
488                    self.connection_id,
489                    Some(n as u64),
490                    None,
491                    Some(1),
492                    None,
493                )
494                .await;
495            log.trace(format!("DICT command from {}: {:?}", self.peer_addr, line));
496
497            if line.trim().is_empty() {
498                continue;
499            }
500            answered_one = true;
501            let command = parse_command(&line);
502
503            if let Some((reply, close)) = command.fixed_reply() {
504                if matches!(command, Command::OptionMime) {
505                    mime = true;
506                }
507                if matches!(command, Command::Client) {
508                    log.debug(format!(
509                        "DICT client {} identified: {}",
510                        self.peer_addr, line
511                    ));
512                }
513                if self.write(write_half, reply.as_bytes()).await.is_err() || close {
514                    return;
515                }
516                continue;
517            }
518
519            match self.answer_with_model(&command, write_half, mime).await {
520                Next::Continue => {}
521                Next::Close => return,
522            }
523        }
524    }
525
526    /// Raise the command's event, and write what the model decided — or `420` and close.
527    async fn answer_with_model<W>(
528        &self,
529        command: &Command,
530        write_half: &Arc<Mutex<W>>,
531        mime: bool,
532    ) -> Next
533    where
534        W: tokio::io::AsyncWrite + Unpin,
535    {
536        let log = Log::new(Some(&self.status_tx));
537        let (Some((event_type, data)), Some(success)) = (command.event(), command.success_code())
538        else {
539            return Next::Continue;
540        };
541        let event = Event::new(event_type, data);
542
543        let result = match call_llm(
544            &self.llm_client,
545            &self.app_state,
546            self.server_id,
547            Some(self.connection_id),
548            &event,
549            self.protocol.as_ref(),
550        )
551        .await
552        {
553            Ok(result) => result,
554            Err(e) => {
555                let (category, reply) = match crate::utils::WireFailure::classify(&e) {
556                    crate::utils::WireFailure::Overloaded => ("overloaded", OVERLOADED_REPLY),
557                    crate::utils::WireFailure::Unavailable => ("unavailable", UNAVAILABLE_REPLY),
558                };
559                log.warn(format!(
560                    "DICT {:?} from {} decision=fail_closed_llm_error category={}",
561                    command, self.peer_addr, category
562                ));
563                log.debug(format!("DICT LLM call failed: {}", e));
564                let _ = self.write(write_half, reply).await;
565                return Next::Close;
566            }
567        };
568        for message in &result.messages {
569            log.info(message);
570        }
571
572        let mut replies: Vec<Vec<u8>> = Vec::new();
573        let mut close = false;
574        let mut stack = result.protocol_results;
575        stack.reverse();
576        while let Some(item) = stack.pop() {
577            match item {
578                ActionResult::Output(bytes) => replies.push(bytes),
579                ActionResult::CloseConnection => close = true,
580                ActionResult::Multiple(items) => stack.extend(items.into_iter().rev()),
581                _ => {}
582            }
583        }
584
585        let Some(reply) = replies.first() else {
586            log.warn(format!(
587                "DICT {:?} from {} decision=model_silent ({} failed action(s)); answering 420",
588                command,
589                self.peer_addr,
590                result.failures.len()
591            ));
592            let _ = self.write(write_half, UNAVAILABLE_REPLY).await;
593            return Next::Close;
594        };
595
596        let code = wire::leading_code(reply);
597        let fits = matches!(code, Some(c) if c == success || (500..600).contains(&c));
598        if !fits {
599            log.warn(format!(
600                "DICT {:?} from {} decision=fail_closed_mismatched_reply code={:?} (expected {} \
601                 or 5xx); answering 420",
602                command, self.peer_addr, code, success
603            ));
604            let _ = self.write(write_half, UNAVAILABLE_REPLY).await;
605            return Next::Close;
606        }
607        if replies.len() > 1 {
608            log.warn(format!(
609                "DICT {:?}: the model produced {} replies to one command; sending the first",
610                command,
611                replies.len()
612            ));
613        }
614
615        let decision = if code == Some(success) {
616            "model_answer"
617        } else {
618            "model_reject"
619        };
620        log.info(format!(
621            "DICT {:?} from {} decision={} code={}",
622            command,
623            self.peer_addr,
624            decision,
625            code.unwrap_or(0)
626        ));
627
628        let bytes = if mime {
629            wire::apply_mime(reply)
630        } else {
631            reply.clone()
632        };
633        if self.write(write_half, &bytes).await.is_err() || close {
634            return Next::Close;
635        }
636        Next::Continue
637    }
638}
639
640/// How long, and how much, the server keeps reading after it has sent its last reply and
641/// half-closed.
642///
643/// Closing a socket that still has unread input makes the kernel send RST instead of FIN, and
644/// an RST can overtake the reply the peer has not read yet — so a peer refused for a 1025-byte
645/// line, or told `420` with pipelined commands still in flight, would see "connection reset"
646/// instead of the refusal. Reading and discarding what is already in flight turns the close into
647/// a FIN. Bounded both ways so a peer that keeps sending cannot hold the task open.
648const LINGER_TIME: Duration = Duration::from_secs(2);
649const LINGER_BYTES: usize = 64 * 1024;
650
651async fn linger<R: tokio::io::AsyncRead + Unpin>(reader: &mut R) {
652    let deadline = tokio::time::Instant::now() + LINGER_TIME;
653    let mut sink = [0u8; 4096];
654    let mut drained = 0usize;
655    while drained < LINGER_BYTES {
656        match tokio::time::timeout_at(deadline, reader.read(&mut sink)).await {
657            Ok(Ok(n)) if n > 0 => drained += n,
658            _ => break,
659        }
660    }
661}
662
663enum LineRead {
664    /// A line without its terminator, and the wire bytes it consumed.
665    Line(String, usize),
666    Eof,
667    TimedOut,
668    /// The line exceeded [`MAX_LINE_BYTES`] including its CRLF.
669    TooLong,
670    Failed(std::io::Error),
671}
672
673/// Accumulates reads into lines, keeping anything after a newline for the next command —
674/// RFC 2229 permits pipelining, and `dict(1)` does it.
675struct LineReader<'a, R> {
676    reader: &'a mut R,
677    pending: Vec<u8>,
678    chunk: Vec<u8>,
679}
680
681impl<'a, R: tokio::io::AsyncRead + Unpin> LineReader<'a, R> {
682    fn new(reader: &'a mut R) -> Self {
683        Self {
684            reader,
685            pending: Vec::new(),
686            chunk: vec![0u8; 1024],
687        }
688    }
689
690    /// `timeout` bounds the wait for *more bytes*, not the whole line.
691    async fn next_line(&mut self, timeout: Duration) -> LineRead {
692        loop {
693            if let Some(idx) = self.pending.iter().position(|b| *b == b'\n') {
694                let consumed = idx + 1;
695                if consumed > MAX_LINE_BYTES {
696                    return LineRead::TooLong;
697                }
698                let line: Vec<u8> = self.pending.drain(..consumed).collect();
699                let text = String::from_utf8_lossy(&line[..idx]);
700                return LineRead::Line(text.trim_end_matches('\r').to_string(), consumed);
701            }
702            // No newline in MAX_LINE_BYTES bytes: even if the next byte is one, the line is
703            // longer than the RFC allows.
704            if self.pending.len() >= MAX_LINE_BYTES {
705                return LineRead::TooLong;
706            }
707            let read = match tokio::time::timeout(timeout, self.reader.read(&mut self.chunk)).await
708            {
709                Err(_) => return LineRead::TimedOut,
710                Ok(read) => read,
711            };
712            match read {
713                Ok(0) => {
714                    if self.pending.is_empty() {
715                        return LineRead::Eof;
716                    }
717                    let line = std::mem::take(&mut self.pending);
718                    let consumed = line.len();
719                    let text = String::from_utf8_lossy(&line);
720                    return LineRead::Line(text.trim_end_matches('\r').to_string(), consumed);
721                }
722                Ok(n) => self.pending.extend_from_slice(&self.chunk[..n]),
723                Err(e) => return LineRead::Failed(e),
724            }
725        }
726    }
727}