lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 99310085626a159e66c27875399c92cc3634f1e0
parent 0fbd0a5084beab1c7a67ad2afcdb813f84676aba
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 05:33:39 +0000

service-host: add operations server

- serve only passive live, ready, and metrics routes
- bound raw headers, connections, responses, and deadlines
- drain active connections under explicit cancellation
- cover routing, limits, bind failure, and shutdown behavior

Diffstat:
Mcrates/service_host/Cargo.toml | 2+-
Mcrates/service_host/src/lib.rs | 16+++++++++-------
Mcrates/service_host/src/operations/mod.rs | 5+++++
Acrates/service_host/src/operations/server.rs | 873+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_host/tests/package_boundary.rs | 17++++++++---------
5 files changed, 896 insertions(+), 17 deletions(-)

diff --git a/crates/service_host/Cargo.toml b/crates/service_host/Cargo.toml @@ -23,7 +23,7 @@ radroots_runtime_paths = { workspace = true } rustix = { workspace = true } serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true, features = ["raw_value", "std"] } -tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] } +tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "sync", "time"] } tokio-util = { workspace = true } [dev-dependencies] diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs @@ -50,15 +50,17 @@ pub use lifecycle::{ TaskRegistrationError, TaskSupervisor, UnfinishedWork, }; pub use operations::{ - BoundedMetricsSnapshot, CommonMetricGroup, LIVEZ_PATH, METRICS_CONTENT_TYPE, - METRICS_MAX_DESCRIPTORS, METRICS_MAX_LABELS_PER_SAMPLE, METRICS_MAX_RENDER_UTF8_BYTES, - METRICS_MAX_SAMPLES, MetricComponentId, MetricDescriptor, MetricHealthState, MetricKind, - MetricLabel, MetricLabelKey, MetricName, MetricSample, MetricTaskOutcome, MetricValue, - MetricsContractError, MetricsRenderError, OPERATIONS_HEALTH_CONTENT_TYPE, OperationsBindPolicy, + BoundOperationsServer, BoundedMetricsSnapshot, CommonMetricGroup, LIVEZ_PATH, + METRICS_CONTENT_TYPE, METRICS_MAX_DESCRIPTORS, METRICS_MAX_LABELS_PER_SAMPLE, + METRICS_MAX_RENDER_UTF8_BYTES, METRICS_MAX_SAMPLES, METRICS_PATH, MetricComponentId, + MetricDescriptor, MetricHealthState, MetricKind, MetricLabel, MetricLabelKey, MetricName, + MetricSample, MetricTaskOutcome, MetricValue, MetricsContractError, MetricsRenderError, + OPERATIONS_HEALTH_CONTENT_TYPE, OPERATIONS_HTTP_MIN_HEADER_BYTES, OperationsBindPolicy, OperationsConfigError, OperationsConfigField, OperationsHealthResponse, OperationsListenAddress, OperationsListenAddressError, OperationsListenerConfig, - OperationsTransportLimitField, OperationsTransportLimitValues, OperationsTransportLimits, - OperationsTransportLimitsError, READYZ_PATH, StableRelayId, livez, readyz, + OperationsServer, OperationsServerError, OperationsTransportLimitField, + OperationsTransportLimitValues, OperationsTransportLimits, OperationsTransportLimitsError, + READYZ_PATH, StableRelayId, livez, readyz, }; pub use status::{ CONFIGURATION_SCHEMA_VERSION, CachedServiceState, CachedServiceStatePublisher, diff --git a/crates/service_host/src/operations/mod.rs b/crates/service_host/src/operations/mod.rs @@ -3,6 +3,7 @@ mod config; mod health; mod metrics; +mod server; pub use config::{ OperationsBindPolicy, OperationsConfigError, OperationsConfigField, OperationsListenAddress, @@ -20,3 +21,7 @@ pub use metrics::{ MetricLabelKey, MetricName, MetricSample, MetricTaskOutcome, MetricValue, MetricsContractError, MetricsRenderError, StableRelayId, }; +pub use server::{ + BoundOperationsServer, METRICS_PATH, OPERATIONS_HTTP_MIN_HEADER_BYTES, OperationsServer, + OperationsServerError, +}; diff --git a/crates/service_host/src/operations/server.rs b/crates/service_host/src/operations/server.rs @@ -0,0 +1,873 @@ +//! Bounded HTTP/1.1 operations server with an exact passive route inventory. + +use core::fmt; +use std::convert::Infallible; +use std::error::Error; +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use bytes::Bytes; +use http::header::{CACHE_CONTROL, CONTENT_TYPE, HeaderValue}; +use http::{Method, Request, Response, StatusCode, Version}; +use http_body_util::Full; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper_util::rt::{TokioIo, TokioTimer}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Semaphore; +use tokio::task::JoinSet; + +use super::{ + BoundedMetricsSnapshot, LIVEZ_PATH, METRICS_CONTENT_TYPE, OPERATIONS_HEALTH_CONTENT_TYPE, + OperationsListenerConfig, OperationsTransportLimits, READYZ_PATH, livez, readyz, +}; +use crate::{CachedServiceStateReader, CancellationToken, MonotonicClock, SystemMonotonicClock}; + +pub const METRICS_PATH: &str = "/metrics"; + +pub const OPERATIONS_HTTP_MIN_HEADER_BYTES: u32 = 8 * 1024; +const NOT_FOUND_BODY: &[u8] = b"not found\n"; +const VERSION_UNSUPPORTED_BODY: &[u8] = b"HTTP/1.1 required\n"; +const HEADERS_TOO_LARGE_BODY: &[u8] = b"request headers too large\n"; +const METRICS_UNAVAILABLE_BODY: &[u8] = b"metrics unavailable\n"; +const REQUEST_TIMEOUT_BODY: &[u8] = b"request timeout\n"; +const NO_STORE: HeaderValue = HeaderValue::from_static("no-store"); + +/// Safe runtime failure for the operations server. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OperationsServerError { + Disabled, + HeaderLimitBelowParserFloor, + Bind { kind: io::ErrorKind }, + LocalAddress { kind: io::ErrorKind }, + Accept { kind: io::ErrorKind }, + ConnectionTaskPanicked, +} + +impl fmt::Display for OperationsServerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("bounded operations server failed") + } +} + +impl Error for OperationsServerError {} + +struct OperationsServerState { + cache: CachedServiceStateReader<BoundedMetricsSnapshot>, + limits: OperationsTransportLimits, + clock: Arc<dyn MonotonicClock>, +} + +/// An unbound operations server with no route-registration extension point. +pub struct OperationsServer { + listen: SocketAddr, + state: Arc<OperationsServerState>, +} + +impl fmt::Debug for OperationsServer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OperationsServer") + .field("listen", &"[redacted]") + .field("limits", &self.state.limits) + .finish() + } +} + +impl OperationsServer { + pub fn new( + config: OperationsListenerConfig, + cache: CachedServiceStateReader<BoundedMetricsSnapshot>, + ) -> Result<Self, OperationsServerError> { + Self::new_with_clock(config, cache, SystemMonotonicClock::new()) + } + + pub fn new_with_clock<C>( + config: OperationsListenerConfig, + cache: CachedServiceStateReader<BoundedMetricsSnapshot>, + clock: C, + ) -> Result<Self, OperationsServerError> + where + C: MonotonicClock + 'static, + { + let listen = config + .listen() + .ok_or(OperationsServerError::Disabled)? + .socket_addr(); + let limits = config.limits().ok_or(OperationsServerError::Disabled)?; + if limits.header_bytes() < OPERATIONS_HTTP_MIN_HEADER_BYTES { + return Err(OperationsServerError::HeaderLimitBelowParserFloor); + } + Ok(Self { + listen, + state: Arc::new(OperationsServerState { + cache, + limits, + clock: Arc::new(clock), + }), + }) + } + + /// Binds the exact validated socket address without starting admission. + pub async fn bind(self) -> Result<BoundOperationsServer, OperationsServerError> { + let listener = TcpListener::bind(self.listen) + .await + .map_err(|error| OperationsServerError::Bind { kind: error.kind() })?; + let local_address = listener + .local_addr() + .map_err(|error| OperationsServerError::LocalAddress { kind: error.kind() })?; + Ok(BoundOperationsServer { + listener, + local_address, + state: self.state, + }) + } +} + +/// One successfully bound listener ready for explicit cancellation-owned service. +pub struct BoundOperationsServer { + listener: TcpListener, + local_address: SocketAddr, + state: Arc<OperationsServerState>, +} + +impl fmt::Debug for BoundOperationsServer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundOperationsServer") + .field("local_address", &"[redacted]") + .field("limits", &self.state.limits) + .finish() + } +} + +impl BoundOperationsServer { + #[must_use] + pub const fn local_address(&self) -> SocketAddr { + self.local_address + } + + /// Stops admission on cancellation and drains every bounded connection task. + pub async fn serve(self, cancellation: CancellationToken) -> Result<(), OperationsServerError> { + let permits = Arc::new(Semaphore::new( + self.state.limits.concurrent_connections() as usize + )); + let mut tasks = JoinSet::new(); + + let mut result = loop { + tokio::select! { + biased; + () = cancellation.cancelled() => break Ok(()), + joined = tasks.join_next(), if !tasks.is_empty() => { + if joined.is_some_and(|result| result.is_err()) { + cancellation.cancel(); + break Err(OperationsServerError::ConnectionTaskPanicked); + } + } + accepted = self.listener.accept() => { + let (stream, _) = match accepted { + Ok(accepted) => accepted, + Err(error) => { + cancellation.cancel(); + break Err(OperationsServerError::Accept { kind: error.kind() }); + } + }; + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { + drop(stream); + continue; + }; + let state = Arc::clone(&self.state); + let connection_cancellation = cancellation.clone(); + tasks.spawn(async move { + let _permit = permit; + serve_connection(stream, state, connection_cancellation).await; + }); + } + } + }; + + while let Some(joined) = tasks.join_next().await { + if joined.is_err() { + result = Err(OperationsServerError::ConnectionTaskPanicked); + } + } + result + } +} + +async fn serve_connection( + mut stream: TcpStream, + state: Arc<OperationsServerState>, + cancellation: CancellationToken, +) { + let admitted = tokio::select! { + biased; + () = cancellation.cancelled() => return, + admitted = tokio::time::timeout( + state.limits.idle_timeout(), + read_request_head(&mut stream, state.limits.header_bytes() as usize), + ) => admitted, + }; + let prefix = match admitted { + Ok(Ok(prefix)) => prefix, + Ok(Err(RequestHeadError::TooLarge)) => { + let _ = tokio::time::timeout( + state.limits.request_deadline(), + write_header_limit_response(&mut stream, state.limits), + ) + .await; + return; + } + Ok(Err(RequestHeadError::Incomplete | RequestHeadError::Read)) | Err(_) => return, + }; + + let service_state = Arc::clone(&state); + let service = service_fn(move |request| { + let state = Arc::clone(&service_state); + async move { Ok::<_, Infallible>(serve_request(request, state).await) } + }); + + let mut builder = http1::Builder::new(); + builder + .keep_alive(false) + .auto_date_header(false) + .max_headers(state.limits.header_count() as usize) + .max_buf_size(state.limits.header_bytes() as usize) + .header_read_timeout(state.limits.idle_timeout()) + .timer(TokioTimer::new()); + + let connection_deadline = state + .limits + .request_deadline() + .saturating_add(state.limits.idle_timeout()); + let stream = PrefixedTcpStream::new(prefix, stream); + let mut connection = Box::pin(builder.serve_connection(TokioIo::new(stream), service)); + tokio::select! { + biased; + () = cancellation.cancelled() => { + connection.as_mut().graceful_shutdown(); + let _ = tokio::time::timeout(connection_deadline, connection).await; + } + _ = tokio::time::timeout(connection_deadline, &mut connection) => {} + } +} + +async fn serve_request( + request: Request<Incoming>, + state: Arc<OperationsServerState>, +) -> Response<Full<Bytes>> { + let deadline = match state.clock.deadline_after(state.limits.request_deadline()) { + Ok(deadline) => deadline, + Err(_) => { + return fixed_response( + StatusCode::GATEWAY_TIMEOUT, + OPERATIONS_HEALTH_CONTENT_TYPE, + REQUEST_TIMEOUT_BODY, + state.limits, + ); + } + }; + if deadline.is_reached_at(state.clock.now_monotonic()) { + return fixed_response( + StatusCode::GATEWAY_TIMEOUT, + OPERATIONS_HEALTH_CONTENT_TYPE, + REQUEST_TIMEOUT_BODY, + state.limits, + ); + } + let response = process_request(request, Arc::clone(&state)); + if deadline.is_reached_at(state.clock.now_monotonic()) { + fixed_response( + StatusCode::GATEWAY_TIMEOUT, + OPERATIONS_HEALTH_CONTENT_TYPE, + REQUEST_TIMEOUT_BODY, + state.limits, + ) + } else { + response + } +} + +fn process_request( + request: Request<Incoming>, + state: Arc<OperationsServerState>, +) -> Response<Full<Bytes>> { + if request.version() != Version::HTTP_11 { + return fixed_response( + StatusCode::HTTP_VERSION_NOT_SUPPORTED, + OPERATIONS_HEALTH_CONTENT_TYPE, + VERSION_UNSUPPORTED_BODY, + state.limits, + ); + } + if request.headers().len() > state.limits.header_count() as usize { + return fixed_response( + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE, + OPERATIONS_HEALTH_CONTENT_TYPE, + HEADERS_TOO_LARGE_BODY, + state.limits, + ); + } + if request.method() != Method::GET || request.uri().query().is_some() { + return not_found(state.limits); + } + + match request.uri().path() { + LIVEZ_PATH => { + let response = livez(&state.cache); + fixed_response( + response.status(), + response.content_type(), + response.body(), + state.limits, + ) + } + READYZ_PATH => { + let response = readyz(&state.cache); + fixed_response( + response.status(), + response.content_type(), + response.body(), + state.limits, + ) + } + METRICS_PATH => { + let snapshot = state.cache.snapshot(); + match snapshot + .metrics() + .render(state.limits.response_body_utf8_bytes() as usize) + { + Ok(body) => response(StatusCode::OK, METRICS_CONTENT_TYPE, body), + Err(_) => fixed_response( + StatusCode::SERVICE_UNAVAILABLE, + OPERATIONS_HEALTH_CONTENT_TYPE, + METRICS_UNAVAILABLE_BODY, + state.limits, + ), + } + } + _ => not_found(state.limits), + } +} + +fn not_found(limits: OperationsTransportLimits) -> Response<Full<Bytes>> { + fixed_response( + StatusCode::NOT_FOUND, + OPERATIONS_HEALTH_CONTENT_TYPE, + NOT_FOUND_BODY, + limits, + ) +} + +fn fixed_response( + status: StatusCode, + content_type: &'static str, + body: &'static [u8], + limits: OperationsTransportLimits, +) -> Response<Full<Bytes>> { + if body.len() > limits.response_body_utf8_bytes() as usize { + return response( + StatusCode::SERVICE_UNAVAILABLE, + OPERATIONS_HEALTH_CONTENT_TYPE, + Vec::new(), + ); + } + response(status, content_type, body.to_vec()) +} + +fn response( + status: StatusCode, + content_type: &'static str, + body: Vec<u8>, +) -> Response<Full<Bytes>> { + let mut response = Response::new(Full::new(Bytes::from(body))); + *response.status_mut() = status; + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); + response.headers_mut().insert(CACHE_CONTROL, NO_STORE); + response +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RequestHeadError { + TooLarge, + Incomplete, + Read, +} + +async fn read_request_head( + stream: &mut TcpStream, + maximum: usize, +) -> Result<Vec<u8>, RequestHeadError> { + let allocation = maximum.checked_add(1).ok_or(RequestHeadError::TooLarge)?; + let mut head = Vec::with_capacity(allocation); + let mut scan_from = 0; + loop { + if let Some(index) = head[scan_from..] + .windows(4) + .position(|window| window == b"\r\n\r\n") + { + let end = scan_from + index + 4; + return if end <= maximum { + Ok(head) + } else { + Err(RequestHeadError::TooLarge) + }; + } + if head.len() > maximum { + return Err(RequestHeadError::TooLarge); + } + scan_from = head.len().saturating_sub(3); + let remaining = allocation.saturating_sub(head.len()); + if remaining == 0 { + return Err(RequestHeadError::TooLarge); + } + let mut chunk = [0_u8; 1024]; + let chunk_limit = remaining.min(chunk.len()); + let read = stream + .read(&mut chunk[..chunk_limit]) + .await + .map_err(|_| RequestHeadError::Read)?; + if read == 0 { + return Err(RequestHeadError::Incomplete); + } + head.extend_from_slice(&chunk[..read]); + } +} + +async fn write_header_limit_response( + stream: &mut TcpStream, + limits: OperationsTransportLimits, +) -> io::Result<()> { + let body = if HEADERS_TOO_LARGE_BODY.len() <= limits.response_body_utf8_bytes() as usize { + HEADERS_TOO_LARGE_BODY + } else { + &[] + }; + let head = format!( + concat!( + "HTTP/1.1 431 Request Header Fields Too Large\r\n", + "content-type: text/plain; charset=utf-8\r\n", + "cache-control: no-store\r\n", + "connection: close\r\n", + "content-length: {}\r\n\r\n" + ), + body.len() + ); + stream.write_all(head.as_bytes()).await?; + stream.write_all(body).await?; + stream.shutdown().await +} + +struct PrefixedTcpStream { + prefix: Vec<u8>, + offset: usize, + stream: TcpStream, +} + +impl PrefixedTcpStream { + fn new(prefix: Vec<u8>, stream: TcpStream) -> Self { + Self { + prefix, + offset: 0, + stream, + } + } +} + +impl AsyncRead for PrefixedTcpStream { + fn poll_read( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll<io::Result<()>> { + if self.offset < self.prefix.len() { + let count = output + .remaining() + .min(self.prefix.len().saturating_sub(self.offset)); + output.put_slice(&self.prefix[self.offset..self.offset + count]); + self.offset += count; + if self.offset == self.prefix.len() { + self.prefix = Vec::new(); + self.offset = 0; + } + Poll::Ready(Ok(())) + } else { + Pin::new(&mut self.stream).poll_read(context, output) + } + } +} + +impl AsyncWrite for PrefixedTcpStream { + fn poll_write( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &[u8], + ) -> Poll<Result<usize, io::Error>> { + Pin::new(&mut self.stream).poll_write(context, buffer) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll<Result<(), io::Error>> { + Pin::new(&mut self.stream).poll_flush(context) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll<Result<(), io::Error>> { + Pin::new(&mut self.stream).poll_shutdown(context) + } +} + +#[cfg(test)] +mod tests { + use core::time::Duration; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::*; + use crate::{ + CachedServiceState, CommonMetricGroup, MetricDescriptor, MetricKind, MetricLabel, + MetricLabelKey, MetricName, MetricSample, MetricValue, MonotonicTime, OperationsBindPolicy, + OperationsListenAddress, OperationsTransportLimitValues, Readiness, ReasonCodes, + ServiceOperationalState, ServicePhase, cached_service_state, + }; + + fn limits() -> OperationsTransportLimits { + OperationsTransportLimits::new(OperationsTransportLimitValues { + header_count: 16, + header_bytes: OPERATIONS_HTTP_MIN_HEADER_BYTES, + response_body_utf8_bytes: 4096, + concurrent_connections: 4, + request_deadline: Duration::from_millis(200), + idle_timeout: Duration::from_millis(200), + }) + .unwrap() + } + + fn snapshot( + phase: ServicePhase, + readiness: Readiness, + ) -> CachedServiceStateReader<BoundedMetricsSnapshot> { + let descriptor = MetricDescriptor::new( + CommonMetricGroup::Phase, + MetricName::new("radroots_phase").unwrap(), + "current phase", + MetricKind::Gauge, + [MetricLabelKey::Phase], + ) + .unwrap(); + let sample = MetricSample::new( + MetricName::new("radroots_phase").unwrap(), + MetricValue::Gauge(1), + [MetricLabel::phase(phase)], + ) + .unwrap(); + let metrics = BoundedMetricsSnapshot::new([descriptor], [sample]).unwrap(); + let operational = + ServiceOperationalState::new(phase, readiness, ReasonCodes::empty()).unwrap(); + cached_service_state(CachedServiceState::new(operational, metrics)).1 + } + + fn config(address: SocketAddr, limits: OperationsTransportLimits) -> OperationsListenerConfig { + OperationsListenerConfig::enabled( + OperationsListenAddress::new(address).unwrap(), + OperationsBindPolicy::LoopbackOnly, + limits, + ) + .unwrap() + } + + async fn bound(limits: OperationsTransportLimits) -> BoundOperationsServer { + let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9); + OperationsServer::new( + config(address, limits), + snapshot(ServicePhase::Ready, Readiness::READY), + ) + .unwrap() + .bind_with_ephemeral_port_for_test() + .await + .unwrap() + } + + impl OperationsServer { + async fn bind_with_ephemeral_port_for_test( + self, + ) -> Result<BoundOperationsServer, OperationsServerError> { + let listener = TcpListener::bind(SocketAddr::new(self.listen.ip(), 0)) + .await + .map_err(|error| OperationsServerError::Bind { kind: error.kind() })?; + let local_address = listener + .local_addr() + .map_err(|error| OperationsServerError::LocalAddress { kind: error.kind() })?; + Ok(BoundOperationsServer { + listener, + local_address, + state: self.state, + }) + } + } + + async fn raw_request(address: SocketAddr, request: &[u8]) -> Vec<u8> { + let mut stream = TcpStream::connect(address).await.unwrap(); + stream.write_all(request).await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + response + } + + fn response_text(response: &[u8]) -> &str { + std::str::from_utf8(response).unwrap() + } + + fn padded_request_head(total_bytes: usize) -> Vec<u8> { + const PREFIX: &[u8] = b"GET /livez HTTP/1.1\r\nx-pad: "; + const SUFFIX: &[u8] = b"\r\n\r\n"; + assert!(total_bytes >= PREFIX.len() + SUFFIX.len()); + let mut request = Vec::with_capacity(total_bytes); + request.extend_from_slice(PREFIX); + request.resize(total_bytes - SUFFIX.len(), b'a'); + request.extend_from_slice(SUFFIX); + assert_eq!(request.len(), total_bytes); + request + } + + #[tokio::test] + async fn serves_only_exact_passive_routes_with_exact_content_types() { + let server = bound(limits()).await; + let address = server.local_address(); + let cancellation = CancellationToken::new(); + let serve_cancel = cancellation.clone(); + let task = tokio::spawn(server.serve(serve_cancel)); + + let live = raw_request(address, b"GET /livez HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + let ready = raw_request(address, b"GET /readyz HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + let metrics = + raw_request(address, b"GET /metrics HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + assert!( + response_text(&live).starts_with("HTTP/1.1 200 OK\r\n"), + "{}", + response_text(&live) + ); + assert!(response_text(&live).contains("content-type: text/plain; charset=utf-8\r\n")); + assert!(response_text(&live).ends_with("live\n")); + assert!(response_text(&ready).ends_with("ready\n")); + assert!( + response_text(&metrics) + .contains("content-type: text/plain; version=0.0.4; charset=utf-8\r\n") + ); + assert!(response_text(&metrics).contains("# TYPE radroots_phase gauge\n")); + + for request in [ + &b"GET /status HTTP/1.1\r\nhost: localhost\r\n\r\n"[..], + &b"POST /readyz HTTP/1.1\r\nhost: localhost\r\ncontent-length: 0\r\n\r\n"[..], + &b"GET /readyz?probe=1 HTTP/1.1\r\nhost: localhost\r\n\r\n"[..], + &b"GET /v1/status HTTP/1.1\r\nhost: localhost\r\n\r\n"[..], + ] { + let rejected = raw_request(address, request).await; + assert!(response_text(&rejected).starts_with("HTTP/1.1 404 Not Found\r\n")); + assert!(response_text(&rejected).ends_with("not found\n")); + } + + cancellation.cancel(); + assert_eq!(task.await.unwrap(), Ok(())); + } + + #[tokio::test] + async fn enforces_http_header_and_metrics_response_limits() { + let mut values = limits().values(); + values.response_body_utf8_bytes = 32; + let server = bound(OperationsTransportLimits::new(values).unwrap()).await; + let address = server.local_address(); + let cancellation = CancellationToken::new(); + let task = tokio::spawn(server.serve(cancellation.clone())); + + let exact_head = padded_request_head(OPERATIONS_HTTP_MIN_HEADER_BYTES as usize); + let exact = raw_request(address, &exact_head).await; + assert!(response_text(&exact).starts_with("HTTP/1.1 200 OK\r\n")); + let over_head = padded_request_head(OPERATIONS_HTTP_MIN_HEADER_BYTES as usize + 1); + let headers = raw_request(address, &over_head).await; + assert!( + response_text(&headers).starts_with("HTTP/1.1 431 Request Header Fields Too Large\r\n"), + "{}", + response_text(&headers) + ); + let metrics = raw_request(address, b"GET /metrics HTTP/1.1\r\nh: x\r\n\r\n").await; + assert!(response_text(&metrics).starts_with("HTTP/1.1 503 Service Unavailable\r\n")); + assert!(response_text(&metrics).ends_with("metrics unavailable\n")); + let version = raw_request(address, b"GET /livez HTTP/1.0\r\n\r\n").await; + assert!( + response_text(&version).starts_with("HTTP/1.0 505 HTTP Version Not Supported\r\n"), + "{}", + response_text(&version) + ); + + cancellation.cancel(); + assert_eq!(task.await.unwrap(), Ok(())); + } + + #[tokio::test] + async fn bind_failure_and_disabled_configuration_are_typed() { + assert_eq!( + OperationsServer::new( + OperationsListenerConfig::disabled(), + snapshot(ServicePhase::Ready, Readiness::READY), + ) + .unwrap_err(), + OperationsServerError::Disabled + ); + + let mut below_floor = limits().values(); + below_floor.header_bytes = OPERATIONS_HTTP_MIN_HEADER_BYTES - 1; + let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9); + assert_eq!( + OperationsServer::new( + config( + address, + OperationsTransportLimits::new(below_floor).unwrap() + ), + snapshot(ServicePhase::Ready, Readiness::READY), + ) + .unwrap_err(), + OperationsServerError::HeaderLimitBelowParserFloor + ); + + let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let address = occupied.local_addr().unwrap(); + let error = OperationsServer::new( + config(address, limits()), + snapshot(ServicePhase::Ready, Readiness::READY), + ) + .unwrap() + .bind() + .await + .unwrap_err(); + assert_eq!( + error, + OperationsServerError::Bind { + kind: io::ErrorKind::AddrInUse + } + ); + } + + #[tokio::test] + async fn cancellation_stops_admission_and_drains_partial_connections() { + let mut values = limits().values(); + values.request_deadline = Duration::from_millis(20); + values.idle_timeout = Duration::from_millis(20); + let server = bound(OperationsTransportLimits::new(values).unwrap()).await; + let address = server.local_address(); + let cancellation = CancellationToken::new(); + let task = tokio::spawn(server.serve(cancellation.clone())); + let mut partial = TcpStream::connect(address).await.unwrap(); + partial.write_all(b"GET /livez HTTP/1.1\r\n").await.unwrap(); + + cancellation.cancel(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap(), + Ok(()) + ); + assert!(TcpStream::connect(address).await.is_err()); + } + + #[tokio::test] + async fn connection_saturation_sheds_and_recovers_without_queueing() { + let mut values = limits().values(); + values.concurrent_connections = 1; + values.request_deadline = Duration::from_millis(500); + values.idle_timeout = Duration::from_millis(500); + let server = bound(OperationsTransportLimits::new(values).unwrap()).await; + let address = server.local_address(); + let cancellation = CancellationToken::new(); + let task = tokio::spawn(server.serve(cancellation.clone())); + + let mut occupied = TcpStream::connect(address).await.unwrap(); + occupied + .write_all(b"GET /livez HTTP/1.1\r\n") + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + + let mut shed_stream = TcpStream::connect(address).await.unwrap(); + shed_stream + .write_all(b"GET /livez HTTP/1.1\r\nhost: localhost\r\n\r\n") + .await + .unwrap(); + let mut shed = Vec::new(); + let shed_result = shed_stream.read_to_end(&mut shed).await; + assert!(shed.is_empty()); + assert!( + shed_result.is_ok() + || shed_result.is_err_and(|error| error.kind() == io::ErrorKind::ConnectionReset) + ); + drop(occupied); + tokio::time::sleep(Duration::from_millis(20)).await; + + let recovered = + raw_request(address, b"GET /livez HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + assert!(response_text(&recovered).starts_with("HTTP/1.1 200 OK\r\n")); + + cancellation.cancel(); + assert_eq!(task.await.unwrap(), Ok(())); + } + + struct PostRenderDeadlineClock { + calls: AtomicUsize, + } + + impl MonotonicClock for PostRenderDeadlineClock { + fn now_monotonic(&self) -> MonotonicTime { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + let elapsed = if call < 2 { + Duration::ZERO + } else { + Duration::from_millis(2) + }; + MonotonicTime::from_duration_since_origin(elapsed) + } + } + + #[tokio::test] + async fn synchronous_render_cannot_return_success_after_request_deadline() { + let mut values = limits().values(); + values.request_deadline = Duration::from_millis(1); + let limits = OperationsTransportLimits::new(values).unwrap(); + let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9); + let server = OperationsServer::new_with_clock( + config(address, limits), + snapshot(ServicePhase::Ready, Readiness::READY), + PostRenderDeadlineClock { + calls: AtomicUsize::new(0), + }, + ) + .unwrap() + .bind_with_ephemeral_port_for_test() + .await + .unwrap(); + let address = server.local_address(); + let cancellation = CancellationToken::new(); + let task = tokio::spawn(server.serve(cancellation.clone())); + + let response = + raw_request(address, b"GET /metrics HTTP/1.1\r\nhost: localhost\r\n\r\n").await; + assert!(response_text(&response).starts_with("HTTP/1.1 504 Gateway Timeout\r\n")); + assert!(response_text(&response).ends_with("request timeout\n")); + + cancellation.cancel(); + assert_eq!(task.await.unwrap(), Ok(())); + } +} diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -25,12 +25,16 @@ const LIFECYCLE_SOURCE: &str = concat!( include_str!("../src/lifecycle/supervisor.rs"), include_str!("../src/lifecycle/task.rs"), ); -const OPERATIONS_SOURCE: &str = concat!( +const OPERATIONS_PRIMITIVES_SOURCE: &str = concat!( include_str!("../src/operations/mod.rs"), include_str!("../src/operations/config.rs"), include_str!("../src/operations/health.rs"), include_str!("../src/operations/metrics.rs"), ); +const OPERATIONS_SOURCE: &str = concat!( + include_str!("../src/operations/mod.rs"), + include_str!("../src/operations/server.rs"), +); #[test] fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { @@ -82,15 +86,10 @@ fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { for forbidden in ["tokio::signal", "ctrl_c", "signal_hook"] { assert!(!LIFECYCLE_SOURCE.contains(forbidden)); } - for forbidden in [ - "TcpListener", - "TcpStream", - "/status", - "process::exit", - "tokio::spawn", - ] { - assert!(!OPERATIONS_SOURCE.contains(forbidden)); + for forbidden in ["TcpListener", "TcpStream", "tokio::spawn"] { + assert!(!OPERATIONS_PRIMITIVES_SOURCE.contains(forbidden)); } + assert!(!OPERATIONS_SOURCE.contains("process::exit")); } fn public_modules(root: &str) -> BTreeSet<&str> {