Skip to main content

netget/server/hls/
mod.rs

1//! HLS server (HTTP Live Streaming, RFC 8216).
2//!
3//! Serves an `.m3u8` playlist and its media segments over HTTP/1.1. The model decides the playlist
4//! (variants and the segment list — structurally, or as verbatim m3u8 text) and what each segment
5//! contains. Real MPEG-TS is binary, so a segment body is either model-supplied text or, for
6//! genuine binary, an explicit `encoding: "hex"` field this server decodes for real — never
7//! sniffed, never base64-guessed.
8//!
9//! A self-contained minimal HTTP/1.1 request reader lives here rather than sharing the hyper-based
10//! `http` server: HLS needs nothing more than method + path routing, and the `http` server's
11//! event/response model is a single `http_request` event, not the two distinct playlist/segment
12//! events HLS wants. The framing written here is standard HTTP a real client (curl, ffplay) reads.
13
14pub mod actions;
15
16use anyhow::Result;
17use std::net::SocketAddr;
18use std::sync::Arc;
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21use tokio::sync::mpsc;
22use tracing::{error, warn};
23
24use crate::llm::action_helper::call_llm;
25use crate::llm::ollama_client::OllamaClient;
26use crate::logging::emit::Log;
27use crate::protocol::Event;
28use crate::server::connection::ConnectionId;
29use crate::server::HlsProtocol;
30use crate::state::app_state::AppState;
31use crate::utils::WireFailure;
32use crate::{console_error, console_trace};
33use actions::{HLS_PLAYLIST_EVENT, HLS_SEGMENT_EVENT};
34
35/// One HTTP response, assembled before it is framed.
36///
37/// `retry_after` exists so the two failure categories stay distinguishable on the wire: an
38/// overloaded backend is transient and a player should back off and re-request, while anything
39/// else is a hard 500 it should not hammer.
40struct HlsResponse {
41    status: u16,
42    content_type: String,
43    body: Vec<u8>,
44    retry_after: bool,
45}
46
47impl HlsResponse {
48    fn new(status: u16, content_type: impl Into<String>, body: Vec<u8>) -> Self {
49        Self {
50            status,
51            content_type: content_type.into(),
52            body,
53            retry_after: false,
54        }
55    }
56
57    /// The peer-visible reply for an internal failure.
58    ///
59    /// The body is [`WireFailure::prefixed_text`] — a `&'static str` category, never the error.
60    /// The error goes to `tracing` and the status stream, which is where an operator looks.
61    /// `Overloaded` becomes 503 + `Retry-After` and `Unavailable` becomes 500, so a client backs
62    /// off on a transient saturation instead of recording a permanent fault.
63    fn failure(failure: WireFailure) -> Self {
64        Self {
65            status: if failure.is_overloaded() { 503 } else { 500 },
66            content_type: "text/plain; charset=utf-8".to_string(),
67            body: failure.prefixed_text().as_bytes().to_vec(),
68            retry_after: failure.is_overloaded(),
69        }
70    }
71}
72
73/// How long a peer may take to finish sending its request headers.
74///
75/// HLS clients send one short GET and nothing else, so this is generous for every legitimate
76/// case and still bounds a connection that sends a byte at a time.
77const HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
78
79/// Longest request path carried into the log, the status stream and the model's prompt.
80const MAX_PATH_LEN: usize = 512;
81
82/// The largest request head — request line, headers and the blank line ending them — this
83/// server buffers, and its declared `max_inbound_bytes`.
84///
85/// The head is the only thing an HLS peer sends that is read: HLS clients send bodiless GETs,
86/// and a request that declares a body is refused with 413 before any of it is read (see
87/// [`declares_body`]). The read loop never asks for more than one byte past this, so a head
88/// that has not ended within it is refused with 431 and the buffer never grows further.
89pub const MAX_REQUEST_HEAD_BYTES: usize = 64 * 1024;
90
91/// How long the unread remainder of a refused request is read and discarded before the socket
92/// drops. Closing with bytes unread makes the kernel reset the connection, which can destroy
93/// the refusal before the client reads it; the bytes go into the reused read chunk, so this is
94/// bounded in time and allocates nothing.
95const LINGER_AFTER_REFUSAL: std::time::Duration = std::time::Duration::from_secs(2);
96
97/// Body of the 431 for a request head over [`MAX_REQUEST_HEAD_BYTES`].
98const HEAD_TOO_LARGE_BODY: &[u8] = b"netget: request head too large\n";
99
100/// Body of the 413 for a request that declares a body. Fixed text: the declared length stays
101/// in the log.
102const BODY_NOT_ACCEPTED_BODY: &[u8] = b"netget: this server accepts no request body\n";
103
104/// Concurrent connections this server admits before it starts refusing.
105///
106/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. [`HEADER_READ_TIMEOUT`] bounds
107/// how long one peer can hold a slot saying nothing; only a cap bounds how many of them there
108/// can be at once. HLS is one GET per connection with `Connection: close`, so a player's
109/// working set is a handful of sockets and the house default is far above anything real
110/// playback produces.
111const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
112
113/// The body of [`CONNECTION_CAP_REFUSAL`].
114const CONNECTION_CAP_BODY: &[u8] = b"Too many connections, try again later.\n";
115
116/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
117///
118/// HLS rides HTTP/1.1, and 503 + `Retry-After` is HTTP's own vocabulary for exactly this —
119/// the same pair this server already sends when the model backend is saturated, for the same
120/// reason given at [`build_http_response`]: it tells a player to back off and re-request
121/// rather than treat the stream as dead. Built through this server's own response builder so
122/// the `Content-Length` cannot drift from the body, which a hand-written literal invites.
123///
124/// It is written before any request has been read, which is deliberate and safe: HTTP/1.1
125/// §3.3 allows a server to send a final response before the request is complete, and the
126/// response says `Connection: close`, so a client that was still sending stops and reads.
127static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> =
128    std::sync::LazyLock::new(|| build_http_response(503, "text/plain", CONNECTION_CAP_BODY, true));
129
130/// Why the peer got what it got, for the log only.
131///
132/// The three failure cases a reader of `netget.log` must be able to tell apart: the model
133/// deliberately answered with an error status, the model answered nothing usable, and the LLM
134/// call itself failed. Collapsing them makes an outage look like a policy decision.
135const DECISION_MODEL_ANSWER: &str = "model_answer";
136const DECISION_MODEL_REJECT: &str = "model_reject";
137const DECISION_NO_ANSWER: &str = "fail_closed_no_answer";
138const DECISION_BAD_ACTION: &str = "fail_closed_bad_action";
139const DECISION_LLM_ERROR: &str = "fail_closed_llm_error";
140const DECISION_LLM_OVERLOADED: &str = "fail_closed_llm_overloaded";
141
142/// `model_reject` when the model chose a 4xx/5xx itself, `model_answer` otherwise.
143fn decision_for_model_status(status: u16) -> &'static str {
144    if status >= 400 {
145        DECISION_MODEL_REJECT
146    } else {
147        DECISION_MODEL_ANSWER
148    }
149}
150
151pub struct HlsServer;
152
153impl HlsServer {
154    /// Spawn the HLS server. Awaits the TCP bind so failure is reported as `Err`, and registers
155    /// the accept loop so `stop_server` can abort it.
156    pub async fn spawn_with_llm_actions(
157        listen_addr: SocketAddr,
158        llm_client: OllamaClient,
159        app_state: Arc<AppState>,
160        status_tx: mpsc::UnboundedSender<String>,
161        server_id: crate::state::ServerId,
162    ) -> Result<SocketAddr> {
163        let listener =
164            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
165        let local_addr = listener.local_addr()?;
166        Log::new(Some(&status_tx)).info(format!("HLS server listening on {}", local_addr));
167
168        let protocol = Arc::new(HlsProtocol::new());
169        let task_registrar = app_state.clone();
170
171        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
172        let accept_handle = tokio::spawn(async move {
173            loop {
174                match crate::server::accept_bounded::accept_bounded(
175                    &listener,
176                    &limiter,
177                    &CONNECTION_CAP_REFUSAL,
178                    "HLS",
179                    Some(&status_tx),
180                )
181                .await
182                {
183                    Ok((stream, remote_addr, permit)) => {
184                        let connection_id =
185                            ConnectionId::new(app_state.get_next_unified_id().await);
186                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
187
188                        use crate::state::server::{
189                            ConnectionState as ServerConnectionState, ConnectionStatus,
190                            ProtocolConnectionInfo,
191                        };
192                        let now = crate::utils::clock::Instant::now();
193                        let conn_state = ServerConnectionState {
194                            id: connection_id,
195                            remote_addr,
196                            local_addr: local_addr_conn,
197                            bytes_sent: 0,
198                            bytes_received: 0,
199                            packets_sent: 0,
200                            packets_received: 0,
201                            last_activity: now,
202                            status: ConnectionStatus::Active,
203                            status_changed_at: now,
204                            protocol_info: ProtocolConnectionInfo::empty(),
205                        };
206                        app_state
207                            .add_connection_to_server(server_id, conn_state)
208                            .await;
209                        let _ = status_tx.send("__UPDATE_UI__".to_string());
210
211                        let llm = llm_client.clone();
212                        let state = app_state.clone();
213                        let stx = status_tx.clone();
214                        let proto = protocol.clone();
215                        // Tracked, not detached: stop_server must abort this task too.
216                        let task_owner = app_state.clone();
217                        task_owner
218                            .spawn_server_task(server_id, async move {
219                                // Released when this task ends, and this task is the whole of
220                                // the connection — HLS spawns nothing else per peer — so
221                                // `MAX_CONNECTIONS` caps live connections, not accepts.
222                                let _permit = permit;
223                                if let Err(e) = Self::handle_connection(
224                                    stream,
225                                    remote_addr,
226                                    connection_id,
227                                    server_id,
228                                    llm,
229                                    state,
230                                    stx.clone(),
231                                    proto,
232                                )
233                                .await
234                                {
235                                    Log::new(Some(&stx))
236                                        .debug(format!("HLS connection ended: {}", e));
237                                }
238                            })
239                            .await;
240                    }
241                    Err(e) => {
242                        console_error!(status_tx, "HLS accept error: {}", e);
243                    }
244                }
245            }
246        });
247
248        task_registrar
249            .register_server_task(server_id, accept_handle)
250            .await;
251        Ok(local_addr)
252    }
253
254    #[allow(clippy::too_many_arguments)]
255    async fn handle_connection(
256        stream: TcpStream,
257        remote_addr: SocketAddr,
258        connection_id: ConnectionId,
259        server_id: crate::state::ServerId,
260        llm: OllamaClient,
261        state: Arc<AppState>,
262        status_tx: mpsc::UnboundedSender<String>,
263        protocol: Arc<HlsProtocol>,
264    ) -> Result<()> {
265        let (mut read_half, mut write_half) = tokio::io::split(stream);
266        let mut buffer = Vec::new();
267        let mut chunk = [0u8; 8192];
268
269        // Read one HTTP request (headers up to the blank line; HLS clients send GETs with no body).
270        //
271        // Bounded by a deadline as well as by size. Without it a peer that connects and sends
272        // one byte — or nothing at all — parks this task and its socket for as long as it cares
273        // to keep the connection open, which is the whole of slowloris. 64 KiB caps how much a
274        // peer can make us buffer; the deadline caps how long it can make us wait.
275        //
276        // Each read asks for at most one byte past `MAX_REQUEST_HEAD_BYTES`, so the buffer can
277        // never exceed the bound by more than that byte, and a head that ends inside the bound
278        // is told apart exactly from one that does not.
279        let deadline = tokio::time::Instant::now() + HEADER_READ_TIMEOUT;
280        let (method, path) = loop {
281            let want = (MAX_REQUEST_HEAD_BYTES + 1 - buffer.len()).min(chunk.len());
282            let n =
283                match tokio::time::timeout_at(deadline, read_half.read(&mut chunk[..want])).await {
284                    Ok(r) => r?,
285                    Err(_) => anyhow::bail!(
286                        "HLS request headers not complete within {}s",
287                        HEADER_READ_TIMEOUT.as_secs()
288                    ),
289                };
290            if n == 0 {
291                return Ok(());
292            }
293            buffer.extend_from_slice(&chunk[..n]);
294            match head_len(&buffer) {
295                Some(len) if len <= MAX_REQUEST_HEAD_BYTES => {
296                    let head = &buffer[..len];
297                    if let Some(declared) = declares_body(head) {
298                        warn!(
299                            "HLS {} declared a request body ({}) decision=fail_closed_body_not_accepted; \
300                             answering 413 without reading it",
301                            remote_addr, declared
302                        );
303                        refuse(
304                            &mut read_half,
305                            &mut write_half,
306                            &mut chunk,
307                            413,
308                            BODY_NOT_ACCEPTED_BODY,
309                        )
310                        .await;
311                        return Ok(());
312                    }
313                    if let Some(req) = parse_request_line(head) {
314                        break req;
315                    }
316                    // A complete head whose request line is unusable: nothing to route.
317                    warn!(
318                        "HLS {} sent an unparseable request line; closing",
319                        remote_addr
320                    );
321                    refuse(&mut read_half, &mut write_half, &mut chunk, 400, b"").await;
322                    return Ok(());
323                }
324                Some(_) => {
325                    warn!(
326                        "HLS {} request head over {} bytes decision=fail_closed_head_too_large",
327                        remote_addr, MAX_REQUEST_HEAD_BYTES
328                    );
329                    refuse(
330                        &mut read_half,
331                        &mut write_half,
332                        &mut chunk,
333                        431,
334                        HEAD_TOO_LARGE_BODY,
335                    )
336                    .await;
337                    return Ok(());
338                }
339                None if buffer.len() > MAX_REQUEST_HEAD_BYTES => {
340                    warn!(
341                        "HLS {} request head over {} bytes decision=fail_closed_head_too_large",
342                        remote_addr, MAX_REQUEST_HEAD_BYTES
343                    );
344                    refuse(
345                        &mut read_half,
346                        &mut write_half,
347                        &mut chunk,
348                        431,
349                        HEAD_TOO_LARGE_BODY,
350                    )
351                    .await;
352                    return Ok(());
353                }
354                None => {}
355            }
356        };
357        // The path is peer-supplied and can be the better part of 64 KiB. It reaches the log,
358        // the status stream and — as event data — the model's prompt, so it is bounded once
359        // here rather than at each of those.
360        let path = crate::utils::truncate_for_log(&path, MAX_PATH_LEN);
361        console_trace!(status_tx, "HLS {} {}", method, path);
362
363        // Refresh connection stats (bytes/packets in) so the dashboard rail shows real traffic
364        // and a fresh last_activity rather than ↓0 ↑0. This is a one-shot HTTP request/response
365        // connection (`Connection: close`): exactly one read of the request, one write of the
366        // response, then the connection returns and closes.
367        state
368            .update_connection_stats(
369                server_id,
370                connection_id,
371                Some(buffer.len() as u64),
372                None,
373                Some(1),
374                None,
375            )
376            .await;
377
378        let is_playlist = path.contains(".m3u8");
379        let base = serde_json::json!({
380            "peer_addr": remote_addr.to_string(),
381            "connection_id": connection_id.to_string(),
382            "path": path,
383            "method": method,
384        });
385        let event = if is_playlist {
386            Event {
387                event_type: &HLS_PLAYLIST_EVENT,
388                data: base,
389            }
390        } else {
391            Event {
392                event_type: &HLS_SEGMENT_EVENT,
393                data: base,
394            }
395        };
396
397        let (reply, decision): (HlsResponse, &str) = match call_llm(
398            &llm,
399            &state,
400            server_id,
401            Some(connection_id),
402            &event,
403            protocol.as_ref(),
404        )
405        .await
406        {
407            Ok(result) => {
408                let action = result.raw_actions.into_iter().next();
409                if is_playlist {
410                    render_playlist(&action)
411                } else {
412                    render_segment(&action)
413                }
414            }
415            Err(e) => {
416                // Fail closed. The peer gets a category and a status it can act on; the error
417                // itself — backend URL, model name, anyhow chain — stays in the log.
418                let failure = WireFailure::classify(&e);
419                let decision = if failure.is_overloaded() {
420                    DECISION_LLM_OVERLOADED
421                } else {
422                    DECISION_LLM_ERROR
423                };
424                error!("HLS {} decision={} error={}", path, decision, e);
425                Log::new(Some(&status_tx)).error(format!(
426                    "HLS fail-closed for {} (decision={}): {}",
427                    path, decision, e
428                ));
429                (HlsResponse::failure(failure), decision)
430            }
431        };
432
433        let HlsResponse {
434            status,
435            content_type,
436            body,
437            retry_after,
438        } = reply;
439        let response = build_http_response(status, &content_type, &body, retry_after);
440        write_half.write_all(&response).await?;
441        write_half.flush().await?;
442
443        state
444            .update_connection_stats(
445                server_id,
446                connection_id,
447                None,
448                Some(response.len() as u64),
449                None,
450                Some(1),
451            )
452            .await;
453        let _ = status_tx.send(format!(
454            "→ HLS {} {} ({} bytes, decision={})",
455            status,
456            path,
457            body.len(),
458            decision
459        ));
460        Ok(())
461    }
462}
463
464/// Render an `.m3u8` playlist response from the model's action.
465///
466/// Accepts either a verbatim `playlist` string, or a structured `segments` array
467/// (`[{"uri":"seg0.ts","duration":6.0}, …]`) plus optional `target_duration`/`version` which are
468/// assembled into a valid media playlist here.
469fn render_playlist(action: &Option<serde_json::Value>) -> (HlsResponse, &'static str) {
470    let ct = "application/vnd.apple.mpegurl".to_string();
471    let Some(action) = action else {
472        warn!(
473            "HLS playlist decision={} (no action returned)",
474            DECISION_NO_ANSWER
475        );
476        return (
477            HlsResponse::failure(WireFailure::Unavailable),
478            DECISION_NO_ANSWER,
479        );
480    };
481    let status = action
482        .get("status_code")
483        .and_then(|v| v.as_u64())
484        .unwrap_or(200) as u16;
485
486    if let Some(playlist) = action.get("playlist").and_then(|v| v.as_str()) {
487        return (
488            HlsResponse::new(status, ct, playlist.as_bytes().to_vec()),
489            decision_for_model_status(status),
490        );
491    }
492
493    if let Some(segments) = action.get("segments").and_then(|v| v.as_array()) {
494        let version = action.get("version").and_then(|v| v.as_u64()).unwrap_or(3);
495        let target = action
496            .get("target_duration")
497            .and_then(|v| v.as_u64())
498            .unwrap_or_else(|| {
499                segments
500                    .iter()
501                    .filter_map(|s| s.get("duration").and_then(|d| d.as_f64()))
502                    .fold(0.0_f64, f64::max)
503                    .ceil() as u64
504            })
505            .max(1);
506        let media_sequence = action
507            .get("media_sequence")
508            .and_then(|v| v.as_u64())
509            .unwrap_or(0);
510        let mut m = String::new();
511        m.push_str("#EXTM3U\n");
512        m.push_str(&format!("#EXT-X-VERSION:{}\n", version));
513        m.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", target));
514        m.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", media_sequence));
515        for seg in segments {
516            let dur = seg
517                .get("duration")
518                .and_then(|d| d.as_f64())
519                .unwrap_or(target as f64);
520            let uri = seg
521                .get("uri")
522                .and_then(|u| u.as_str())
523                .unwrap_or("segment.ts");
524            m.push_str(&format!("#EXTINF:{:.3},\n{}\n", dur, uri));
525        }
526        let ended = action
527            .get("ended")
528            .and_then(|v| v.as_bool())
529            .unwrap_or(true);
530        if ended {
531            m.push_str("#EXT-X-ENDLIST\n");
532        }
533        return (
534            HlsResponse::new(status, ct, m.into_bytes()),
535            decision_for_model_status(status),
536        );
537    }
538
539    // No usable field: fail closed rather than fabricate a playlist. The peer gets a category;
540    // what the action actually lacked is a log line for the operator, not for a stranger.
541    warn!(
542        "HLS playlist decision={} (action lacked both 'playlist' and 'segments')",
543        DECISION_NO_ANSWER
544    );
545    (
546        HlsResponse::failure(WireFailure::Unavailable),
547        DECISION_NO_ANSWER,
548    )
549}
550
551/// Render a media segment response. Body is either model text (`content`, utf8) or explicit
552/// hex-encoded binary (`encoding: "hex"`, `data`) which is decoded here.
553fn render_segment(action: &Option<serde_json::Value>) -> (HlsResponse, &'static str) {
554    let Some(action) = action else {
555        warn!(
556            "HLS segment decision={} (no action returned)",
557            DECISION_NO_ANSWER
558        );
559        return (
560            HlsResponse::failure(WireFailure::Unavailable),
561            DECISION_NO_ANSWER,
562        );
563    };
564    let status = action
565        .get("status_code")
566        .and_then(|v| v.as_u64())
567        .unwrap_or(200) as u16;
568    let content_type = action
569        .get("content_type")
570        .and_then(|v| v.as_str())
571        .unwrap_or("video/mp2t")
572        .to_string();
573
574    // Explicit binary path: decode hex for real (the send_tcp_data lesson).
575    if let Some(data) = action.get("data").and_then(|v| v.as_str()) {
576        let encoding = action
577            .get("encoding")
578            .and_then(|v| v.as_str())
579            .unwrap_or("utf8");
580        match encoding {
581            "hex" => match hex::decode(data.trim()) {
582                Ok(bytes) => {
583                    return (
584                        HlsResponse::new(status, content_type, bytes),
585                        decision_for_model_status(status),
586                    )
587                }
588                Err(e) => {
589                    // The decoder's message names offsets in the model's own string; the peer
590                    // gets the category, the operator gets the detail.
591                    warn!(
592                        "HLS segment decision={} (hex decode failed): {}",
593                        DECISION_BAD_ACTION, e
594                    );
595                    return (
596                        HlsResponse::failure(WireFailure::Unavailable),
597                        DECISION_BAD_ACTION,
598                    );
599                }
600            },
601            "utf8" => {
602                return (
603                    HlsResponse::new(status, content_type, data.as_bytes().to_vec()),
604                    decision_for_model_status(status),
605                )
606            }
607            other => {
608                warn!(
609                    "HLS segment decision={} (unknown encoding {:?}; expected \"utf8\" or \"hex\")",
610                    DECISION_BAD_ACTION, other
611                );
612                return (
613                    HlsResponse::failure(WireFailure::Unavailable),
614                    DECISION_BAD_ACTION,
615                );
616            }
617        }
618    }
619
620    if let Some(content) = action.get("content").and_then(|v| v.as_str()) {
621        return (
622            HlsResponse::new(status, content_type, content.as_bytes().to_vec()),
623            decision_for_model_status(status),
624        );
625    }
626
627    // An action carrying neither `data` nor `content` is the model answering nothing usable.
628    // Serving it as 200 with an empty body would be fail-open: a player accepts a zero-length
629    // segment as a valid one and the stream silently plays nothing.
630    warn!(
631        "HLS segment decision={} (action carried neither 'data' nor 'content')",
632        DECISION_NO_ANSWER
633    );
634    (
635        HlsResponse::failure(WireFailure::Unavailable),
636        DECISION_NO_ANSWER,
637    )
638}
639
640/// Build an HTTP/1.1 response. `Connection: close` keeps this a clean one-request-per-connection
641/// server, which HLS clients (a new GET per playlist/segment) handle fine.
642fn build_http_response(status: u16, content_type: &str, body: &[u8], retry_after: bool) -> Vec<u8> {
643    let reason = reason_phrase(status);
644    // Only a 503 from the overload path carries Retry-After: it is the signal that tells a
645    // player to back off and re-request rather than treat the stream as dead.
646    let retry_header = if retry_after {
647        "Retry-After: 5\r\n"
648    } else {
649        ""
650    };
651    let mut resp = format!(
652        "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n{}Cache-Control: no-cache\r\nConnection: close\r\n\r\n",
653        status,
654        reason,
655        content_type,
656        body.len(),
657        retry_header
658    )
659    .into_bytes();
660    resp.extend_from_slice(body);
661    resp
662}
663
664/// Reason phrase for a status code.
665///
666/// The fallback is class-correct rather than a blanket `"OK"`: a model picking 403 used to be
667/// framed as `HTTP/1.1 403 OK`, which is a contradiction on the wire.
668fn reason_phrase(status: u16) -> &'static str {
669    match status {
670        200 => "OK",
671        204 => "No Content",
672        206 => "Partial Content",
673        301 => "Moved Permanently",
674        302 => "Found",
675        304 => "Not Modified",
676        400 => "Bad Request",
677        401 => "Unauthorized",
678        403 => "Forbidden",
679        404 => "Not Found",
680        410 => "Gone",
681        413 => "Content Too Large",
682        416 => "Range Not Satisfiable",
683        429 => "Too Many Requests",
684        431 => "Request Header Fields Too Large",
685        500 => "Internal Server Error",
686        502 => "Bad Gateway",
687        503 => "Service Unavailable",
688        504 => "Gateway Timeout",
689        100..=199 => "Informational",
690        200..=299 => "Success",
691        300..=399 => "Redirection",
692        400..=499 => "Client Error",
693        500..=599 => "Server Error",
694        _ => "Unknown",
695    }
696}
697
698/// Length of the request head — through the `\r\n\r\n` that ends it — once it has arrived.
699fn head_len(buf: &[u8]) -> Option<usize> {
700    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
701}
702
703/// The `Content-Length` / `Transfer-Encoding` header a request uses to declare a body, if any.
704///
705/// An HLS client sends bodiless GETs. A request that declares a body is refused rather than
706/// answered: its body would be left unread on the socket, and the model would be asked about a
707/// request whose content it never saw. `Content-Length: 0` declares no body and is accepted.
708fn declares_body(head: &[u8]) -> Option<String> {
709    let head = String::from_utf8_lossy(head);
710    for line in head.split("\r\n").skip(1) {
711        let Some((name, value)) = line.split_once(':') else {
712            continue;
713        };
714        let name = name.trim();
715        let value = value.trim();
716        if name.eq_ignore_ascii_case("transfer-encoding") {
717            return Some(format!(
718                "Transfer-Encoding: {}",
719                crate::utils::truncate_for_log(value, 64)
720            ));
721        }
722        if name.eq_ignore_ascii_case("content-length") && value != "0" {
723            return Some(format!(
724                "Content-Length: {}",
725                crate::utils::truncate_for_log(value, 32)
726            ));
727        }
728    }
729    None
730}
731
732/// Answer a request that will not reach the model, then close.
733///
734/// The response goes out first; whatever the peer is still sending is then read into the reused
735/// chunk and discarded for at most [`LINGER_AFTER_REFUSAL`], so the close does not become a
736/// reset that destroys the response before the peer reads it.
737async fn refuse<R, W>(
738    read_half: &mut R,
739    write_half: &mut W,
740    chunk: &mut [u8],
741    status: u16,
742    body: &[u8],
743) where
744    R: tokio::io::AsyncRead + Unpin,
745    W: tokio::io::AsyncWrite + Unpin,
746{
747    let response = build_http_response(status, "text/plain; charset=utf-8", body, false);
748    if write_half.write_all(&response).await.is_err() {
749        return;
750    }
751    let _ = write_half.flush().await;
752    let _ = write_half.shutdown().await;
753    let deadline = tokio::time::Instant::now() + LINGER_AFTER_REFUSAL;
754    while let Ok(Ok(n)) = tokio::time::timeout_at(deadline, read_half.read(chunk)).await {
755        if n == 0 {
756            break;
757        }
758    }
759}
760
761/// Parse the method and path from a partial HTTP request. Returns None until the request line and
762/// header terminator (`\r\n\r\n`) are present.
763fn parse_request_line(buf: &[u8]) -> Option<(String, String)> {
764    // Terminator located on the bytes, and only the request line decoded. Running
765    // `from_utf8` over the whole buffer rejected a request whose headers were valid because
766    // something later in the buffer was not — including a read that merely happened to stop in
767    // the middle of a multi-byte character — and the loop then waited for bytes that would
768    // never make it valid. The request line itself must still be UTF-8; a path that is not is
769    // a request this server cannot route.
770    let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n")?;
771    let head = std::str::from_utf8(&buf[..header_end]).ok()?;
772    let first = head.lines().next()?;
773    let mut parts = first.split_whitespace();
774    let method = parts.next()?.to_string();
775    let path = parts.next()?.to_string();
776    Some((method, path))
777}