pub mod actions;
use anyhow::Result;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tracing::{error, warn};
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::connection::ConnectionId;
use crate::server::HlsProtocol;
use crate::state::app_state::AppState;
use crate::utils::WireFailure;
use crate::{console_error, console_trace};
use actions::{HLS_PLAYLIST_EVENT, HLS_SEGMENT_EVENT};
struct HlsResponse {
status: u16,
content_type: String,
body: Vec<u8>,
retry_after: bool,
}
impl HlsResponse {
fn new(status: u16, content_type: impl Into<String>, body: Vec<u8>) -> Self {
Self {
status,
content_type: content_type.into(),
body,
retry_after: false,
}
}
fn failure(failure: WireFailure) -> Self {
Self {
status: if failure.is_overloaded() { 503 } else { 500 },
content_type: "text/plain; charset=utf-8".to_string(),
body: failure.prefixed_text().as_bytes().to_vec(),
retry_after: failure.is_overloaded(),
}
}
}
const HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const MAX_PATH_LEN: usize = 512;
pub const MAX_REQUEST_HEAD_BYTES: usize = 64 * 1024;
const LINGER_AFTER_REFUSAL: std::time::Duration = std::time::Duration::from_secs(2);
const HEAD_TOO_LARGE_BODY: &[u8] = b"netget: request head too large\n";
const BODY_NOT_ACCEPTED_BODY: &[u8] = b"netget: this server accepts no request body\n";
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_BODY: &[u8] = b"Too many connections, try again later.\n";
static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> =
std::sync::LazyLock::new(|| build_http_response(503, "text/plain", CONNECTION_CAP_BODY, true));
const DECISION_MODEL_ANSWER: &str = "model_answer";
const DECISION_MODEL_REJECT: &str = "model_reject";
const DECISION_NO_ANSWER: &str = "fail_closed_no_answer";
const DECISION_BAD_ACTION: &str = "fail_closed_bad_action";
const DECISION_LLM_ERROR: &str = "fail_closed_llm_error";
const DECISION_LLM_OVERLOADED: &str = "fail_closed_llm_overloaded";
fn decision_for_model_status(status: u16) -> &'static str {
if status >= 400 {
DECISION_MODEL_REJECT
} else {
DECISION_MODEL_ANSWER
}
}
pub struct HlsServer;
impl HlsServer {
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
) -> Result<SocketAddr> {
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
Log::new(Some(&status_tx)).info(format!("HLS server listening on {}", local_addr));
let protocol = Arc::new(HlsProtocol::new());
let task_registrar = app_state.clone();
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
&CONNECTION_CAP_REFUSAL,
"HLS",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr,
local_addr: local_addr_conn,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
let llm = llm_client.clone();
let state = app_state.clone();
let stx = status_tx.clone();
let proto = protocol.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
if let Err(e) = Self::handle_connection(
stream,
remote_addr,
connection_id,
server_id,
llm,
state,
stx.clone(),
proto,
)
.await
{
Log::new(Some(&stx))
.debug(format!("HLS connection ended: {}", e));
}
})
.await;
}
Err(e) => {
console_error!(status_tx, "HLS accept error: {}", e);
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
#[allow(clippy::too_many_arguments)]
async fn handle_connection(
stream: TcpStream,
remote_addr: SocketAddr,
connection_id: ConnectionId,
server_id: crate::state::ServerId,
llm: OllamaClient,
state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<HlsProtocol>,
) -> Result<()> {
let (mut read_half, mut write_half) = tokio::io::split(stream);
let mut buffer = Vec::new();
let mut chunk = [0u8; 8192];
let deadline = tokio::time::Instant::now() + HEADER_READ_TIMEOUT;
let (method, path) = loop {
let want = (MAX_REQUEST_HEAD_BYTES + 1 - buffer.len()).min(chunk.len());
let n =
match tokio::time::timeout_at(deadline, read_half.read(&mut chunk[..want])).await {
Ok(r) => r?,
Err(_) => anyhow::bail!(
"HLS request headers not complete within {}s",
HEADER_READ_TIMEOUT.as_secs()
),
};
if n == 0 {
return Ok(());
}
buffer.extend_from_slice(&chunk[..n]);
match head_len(&buffer) {
Some(len) if len <= MAX_REQUEST_HEAD_BYTES => {
let head = &buffer[..len];
if let Some(declared) = declares_body(head) {
warn!(
"HLS {} declared a request body ({}) decision=fail_closed_body_not_accepted; \
answering 413 without reading it",
remote_addr, declared
);
refuse(
&mut read_half,
&mut write_half,
&mut chunk,
413,
BODY_NOT_ACCEPTED_BODY,
)
.await;
return Ok(());
}
if let Some(req) = parse_request_line(head) {
break req;
}
warn!(
"HLS {} sent an unparseable request line; closing",
remote_addr
);
refuse(&mut read_half, &mut write_half, &mut chunk, 400, b"").await;
return Ok(());
}
Some(_) => {
warn!(
"HLS {} request head over {} bytes decision=fail_closed_head_too_large",
remote_addr, MAX_REQUEST_HEAD_BYTES
);
refuse(
&mut read_half,
&mut write_half,
&mut chunk,
431,
HEAD_TOO_LARGE_BODY,
)
.await;
return Ok(());
}
None if buffer.len() > MAX_REQUEST_HEAD_BYTES => {
warn!(
"HLS {} request head over {} bytes decision=fail_closed_head_too_large",
remote_addr, MAX_REQUEST_HEAD_BYTES
);
refuse(
&mut read_half,
&mut write_half,
&mut chunk,
431,
HEAD_TOO_LARGE_BODY,
)
.await;
return Ok(());
}
None => {}
}
};
let path = crate::utils::truncate_for_log(&path, MAX_PATH_LEN);
console_trace!(status_tx, "HLS {} {}", method, path);
state
.update_connection_stats(
server_id,
connection_id,
Some(buffer.len() as u64),
None,
Some(1),
None,
)
.await;
let is_playlist = path.contains(".m3u8");
let base = serde_json::json!({
"peer_addr": remote_addr.to_string(),
"connection_id": connection_id.to_string(),
"path": path,
"method": method,
});
let event = if is_playlist {
Event {
event_type: &HLS_PLAYLIST_EVENT,
data: base,
}
} else {
Event {
event_type: &HLS_SEGMENT_EVENT,
data: base,
}
};
let (reply, decision): (HlsResponse, &str) = match call_llm(
&llm,
&state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await
{
Ok(result) => {
let action = result.raw_actions.into_iter().next();
if is_playlist {
render_playlist(&action)
} else {
render_segment(&action)
}
}
Err(e) => {
let failure = WireFailure::classify(&e);
let decision = if failure.is_overloaded() {
DECISION_LLM_OVERLOADED
} else {
DECISION_LLM_ERROR
};
error!("HLS {} decision={} error={}", path, decision, e);
Log::new(Some(&status_tx)).error(format!(
"HLS fail-closed for {} (decision={}): {}",
path, decision, e
));
(HlsResponse::failure(failure), decision)
}
};
let HlsResponse {
status,
content_type,
body,
retry_after,
} = reply;
let response = build_http_response(status, &content_type, &body, retry_after);
write_half.write_all(&response).await?;
write_half.flush().await?;
state
.update_connection_stats(
server_id,
connection_id,
None,
Some(response.len() as u64),
None,
Some(1),
)
.await;
let _ = status_tx.send(format!(
"→ HLS {} {} ({} bytes, decision={})",
status,
path,
body.len(),
decision
));
Ok(())
}
}
fn render_playlist(action: &Option<serde_json::Value>) -> (HlsResponse, &'static str) {
let ct = "application/vnd.apple.mpegurl".to_string();
let Some(action) = action else {
warn!(
"HLS playlist decision={} (no action returned)",
DECISION_NO_ANSWER
);
return (
HlsResponse::failure(WireFailure::Unavailable),
DECISION_NO_ANSWER,
);
};
let status = action
.get("status_code")
.and_then(|v| v.as_u64())
.unwrap_or(200) as u16;
if let Some(playlist) = action.get("playlist").and_then(|v| v.as_str()) {
return (
HlsResponse::new(status, ct, playlist.as_bytes().to_vec()),
decision_for_model_status(status),
);
}
if let Some(segments) = action.get("segments").and_then(|v| v.as_array()) {
let version = action.get("version").and_then(|v| v.as_u64()).unwrap_or(3);
let target = action
.get("target_duration")
.and_then(|v| v.as_u64())
.unwrap_or_else(|| {
segments
.iter()
.filter_map(|s| s.get("duration").and_then(|d| d.as_f64()))
.fold(0.0_f64, f64::max)
.ceil() as u64
})
.max(1);
let media_sequence = action
.get("media_sequence")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let mut m = String::new();
m.push_str("#EXTM3U\n");
m.push_str(&format!("#EXT-X-VERSION:{}\n", version));
m.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", target));
m.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", media_sequence));
for seg in segments {
let dur = seg
.get("duration")
.and_then(|d| d.as_f64())
.unwrap_or(target as f64);
let uri = seg
.get("uri")
.and_then(|u| u.as_str())
.unwrap_or("segment.ts");
m.push_str(&format!("#EXTINF:{:.3},\n{}\n", dur, uri));
}
let ended = action
.get("ended")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if ended {
m.push_str("#EXT-X-ENDLIST\n");
}
return (
HlsResponse::new(status, ct, m.into_bytes()),
decision_for_model_status(status),
);
}
warn!(
"HLS playlist decision={} (action lacked both 'playlist' and 'segments')",
DECISION_NO_ANSWER
);
(
HlsResponse::failure(WireFailure::Unavailable),
DECISION_NO_ANSWER,
)
}
fn render_segment(action: &Option<serde_json::Value>) -> (HlsResponse, &'static str) {
let Some(action) = action else {
warn!(
"HLS segment decision={} (no action returned)",
DECISION_NO_ANSWER
);
return (
HlsResponse::failure(WireFailure::Unavailable),
DECISION_NO_ANSWER,
);
};
let status = action
.get("status_code")
.and_then(|v| v.as_u64())
.unwrap_or(200) as u16;
let content_type = action
.get("content_type")
.and_then(|v| v.as_str())
.unwrap_or("video/mp2t")
.to_string();
if let Some(data) = action.get("data").and_then(|v| v.as_str()) {
let encoding = action
.get("encoding")
.and_then(|v| v.as_str())
.unwrap_or("utf8");
match encoding {
"hex" => match hex::decode(data.trim()) {
Ok(bytes) => {
return (
HlsResponse::new(status, content_type, bytes),
decision_for_model_status(status),
)
}
Err(e) => {
warn!(
"HLS segment decision={} (hex decode failed): {}",
DECISION_BAD_ACTION, e
);
return (
HlsResponse::failure(WireFailure::Unavailable),
DECISION_BAD_ACTION,
);
}
},
"utf8" => {
return (
HlsResponse::new(status, content_type, data.as_bytes().to_vec()),
decision_for_model_status(status),
)
}
other => {
warn!(
"HLS segment decision={} (unknown encoding {:?}; expected \"utf8\" or \"hex\")",
DECISION_BAD_ACTION, other
);
return (
HlsResponse::failure(WireFailure::Unavailable),
DECISION_BAD_ACTION,
);
}
}
}
if let Some(content) = action.get("content").and_then(|v| v.as_str()) {
return (
HlsResponse::new(status, content_type, content.as_bytes().to_vec()),
decision_for_model_status(status),
);
}
warn!(
"HLS segment decision={} (action carried neither 'data' nor 'content')",
DECISION_NO_ANSWER
);
(
HlsResponse::failure(WireFailure::Unavailable),
DECISION_NO_ANSWER,
)
}
fn build_http_response(status: u16, content_type: &str, body: &[u8], retry_after: bool) -> Vec<u8> {
let reason = reason_phrase(status);
let retry_header = if retry_after {
"Retry-After: 5\r\n"
} else {
""
};
let mut resp = format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n{}Cache-Control: no-cache\r\nConnection: close\r\n\r\n",
status,
reason,
content_type,
body.len(),
retry_header
)
.into_bytes();
resp.extend_from_slice(body);
resp
}
fn reason_phrase(status: u16) -> &'static str {
match status {
200 => "OK",
204 => "No Content",
206 => "Partial Content",
301 => "Moved Permanently",
302 => "Found",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
410 => "Gone",
413 => "Content Too Large",
416 => "Range Not Satisfiable",
429 => "Too Many Requests",
431 => "Request Header Fields Too Large",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
504 => "Gateway Timeout",
100..=199 => "Informational",
200..=299 => "Success",
300..=399 => "Redirection",
400..=499 => "Client Error",
500..=599 => "Server Error",
_ => "Unknown",
}
}
fn head_len(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
}
fn declares_body(head: &[u8]) -> Option<String> {
let head = String::from_utf8_lossy(head);
for line in head.split("\r\n").skip(1) {
let Some((name, value)) = line.split_once(':') else {
continue;
};
let name = name.trim();
let value = value.trim();
if name.eq_ignore_ascii_case("transfer-encoding") {
return Some(format!(
"Transfer-Encoding: {}",
crate::utils::truncate_for_log(value, 64)
));
}
if name.eq_ignore_ascii_case("content-length") && value != "0" {
return Some(format!(
"Content-Length: {}",
crate::utils::truncate_for_log(value, 32)
));
}
}
None
}
async fn refuse<R, W>(
read_half: &mut R,
write_half: &mut W,
chunk: &mut [u8],
status: u16,
body: &[u8],
) where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let response = build_http_response(status, "text/plain; charset=utf-8", body, false);
if write_half.write_all(&response).await.is_err() {
return;
}
let _ = write_half.flush().await;
let _ = write_half.shutdown().await;
let deadline = tokio::time::Instant::now() + LINGER_AFTER_REFUSAL;
while let Ok(Ok(n)) = tokio::time::timeout_at(deadline, read_half.read(chunk)).await {
if n == 0 {
break;
}
}
}
fn parse_request_line(buf: &[u8]) -> Option<(String, String)> {
let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n")?;
let head = std::str::from_utf8(&buf[..header_end]).ok()?;
let first = head.lines().next()?;
let mut parts = first.split_whitespace();
let method = parts.next()?.to_string();
let path = parts.next()?.to_string();
Some((method, path))
}