diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4cdaf..113ca39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to `locho` are documented here. +## [Unreleased] + +- Adds an optional per-HTTP-service `upstream_timeout_secs` setting, defaulting + to 60 seconds and capped at 300 seconds. +- Adds optional attachment-side HTTP response timeouts through + `http_timeout_secs` and `--http-timeout-secs`, defaulting to 60 seconds. + ## [1.1.1] - 2026-08-06 - Fixes release binaries reporting a dirty checkout when generated cargo-dist diff --git a/Cargo.lock b/Cargo.lock index a389d1d..0892ba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -898,7 +898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1146,9 +1146,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -2766,7 +2766,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3039,7 +3039,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3132,7 +3132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4087,7 +4087,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/FAQ.md b/FAQ.md index 1782f84..c227d6e 100644 --- a/FAQ.md +++ b/FAQ.md @@ -93,10 +93,17 @@ curl http://127.0.0.1:8765/path The host forwards requests to the explicitly configured HTTP upstream. Request and response bodies are streamed and limited to 32 MiB per body. The -upstream request timeout is 30 seconds. WebSocket upgrades are not supported; -use a TCP service when a non-HTTP protocol is required. HTTPS uses normal root -validation by default. A private HTTPS upstream may specify an explicit PEM -`ca_cert` in its service configuration. +upstream request/response timeout defaults to 60 seconds and can be overridden +per HTTP service with `upstream_timeout_secs` from 1 to 300 seconds. WebSocket +upgrades are not supported; use a TCP service when a non-HTTP protocol is +required. HTTPS uses normal root validation by default. A private HTTPS +upstream may specify an explicit PEM `ca_cert` in its service configuration. + +The attachment can independently configure its response timeout with +`http_timeout_secs` in `attachments.toml`, or `--http-timeout-secs` for a +positional attach. It defaults to 60 seconds, applies while waiting for the +response and between response-body chunks, and is separate from the host's +upstream timeout. ## How are TCP services used? @@ -123,8 +130,9 @@ configured endpoint. Ctrl-C stops accepting new local or remote connections and closes active tunnel connections. The process waits up to 10 seconds for active tasks to finish before forcing termination. Tunnel handshakes have a 10-second timeout, HTTP -upstream request/response operations have a 30-second timeout, and TCP sessions -have a 5-minute idle timeout. +upstream request/response operations use the configured timeout, which defaults +to 60 seconds, HTTP attachment response guards default to 60 seconds, and TCP +sessions have a 5-minute idle timeout. ## Does locho create a VPN? diff --git a/README.md b/README.md index 7506f0f..753acf1 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,9 @@ service has an independent attachment capability: name = "api" type = "http" upstream = "https://example.com" +# Optional total upstream request/response timeout in seconds (1-300). +# Defaults to 60 seconds. +# upstream_timeout_secs = 90 # Optional PEM CA certificate for a private HTTPS upstream. # ca_cert = "/path/to/upstream-ca.pem" @@ -145,6 +148,10 @@ Attach one selected service locally using the command generated by `locho share` locho attach api:http: --listen 127.0.0.1:8765 ``` +For a one-off client-side HTTP response timeout, add +`--http-timeout-secs `. The value defaults to 60 seconds and accepts +values from 1 to 300 seconds. + For scripts that need only the token, use `locho secret`: ```sh @@ -201,11 +208,14 @@ curl http://127.0.0.1:8765/path HTTP request and response bodies are streamed through the tunnel. Known-length bodies use length framing; chunked bodies use bounded chunk framing. Individual -bodies are limited to 32 MiB, and HTTP requests have a 30-second upstream -timeout. Hop-by-hop headers are not forwarded. WebSocket upgrades are not -supported. HTTPS certificates are validated against the normal system roots by -default; `ca_cert` may explicitly add a PEM CA certificate for a private -upstream. +bodies are limited to 32 MiB, and HTTP requests use a configurable upstream +request/response timeout that defaults to 60 seconds and accepts values from 1 +to 300 seconds. Hop-by-hop headers are not forwarded. WebSocket upgrades are +not supported. HTTPS certificates are validated against the normal system roots +by default; `ca_cert` may explicitly add a PEM CA certificate for a private +upstream. If an upstream fails after response headers have been forwarded, the +attachment closes the response stream; an HTTP client may observe a truncated +body rather than a replacement gateway status. A TCP service is attached to a local port and used by its native client: @@ -280,6 +290,9 @@ listen_host = "127.0.0.1" # optional; defaults to localhost [[services]] capability = "api:http:" listen_port = 8765 +# Optional attachment response timeout in seconds (1-300). +# Defaults to 60 seconds. +# http_timeout_secs = 90 [[services]] capability = "database:tcp:" @@ -301,8 +314,10 @@ are required. Host and attachment processes handle Ctrl-C gracefully: they stop accepting new connections, close active tunnel connections, and wait up to 10 seconds for active tasks to finish before terminating remaining tasks. Tunnel handshakes -also have a 10-second timeout, and HTTP clients enforce a 30-second upstream -request/response timeout. +also have a 10-second timeout. HTTP hosts enforce the configured upstream +request/response timeout, which defaults to 60 seconds. HTTP attachments enforce +their configured response timeout, which also defaults to 60 seconds and applies +between response chunks. Configuration is loaded and fully validated before the host starts. Service names are unique, limited to letters, numbers, `-`, and `_`, HTTP upstreams diff --git a/src/attach.rs b/src/attach.rs index 3bcf6cb..db835eb 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -55,6 +55,17 @@ struct HttpBodyLeaseGuard { lease: Option, } +#[derive(Debug)] +struct HttpResponseTimeout; + +impl fmt::Display for HttpResponseTimeout { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("HTTP response timed out") + } +} + +impl std::error::Error for HttpResponseTimeout {} + #[derive(Debug)] struct TunnelUnavailable; @@ -140,16 +151,36 @@ impl HttpBodyLeaseGuard { } } +impl Drop for HttpBodyLeaseGuard { + fn drop(&mut self) { + self.invalidate(); + } +} + pub async fn run( host_id: String, capability: String, direct_address: Option, listen: SocketAddr, + http_timeout_secs: Option, ) -> Result<()> { let capability = crate::capability::parse(&capability)?; + let http_timeout = match capability.service_type { + crate::config::ServiceType::Http => attach_config::resolve_http_timeout(http_timeout_secs)?, + crate::config::ServiceType::Tcp => { + if http_timeout_secs.is_some() { + anyhow::bail!("--http-timeout-secs is only supported for HTTP attachments") + } + HTTP_REQUEST_TIMEOUT + } + }; run_attachments( host_id, - vec![attach_config::AttachmentConfig { capability, listen }], + vec![attach_config::AttachmentConfig { + capability, + listen, + http_timeout, + }], direct_address, ) .await @@ -195,16 +226,17 @@ async fn run_attachments( ) })?, attachment.capability.clone(), + attachment.http_timeout, )); } let listeners = listeners .into_iter() - .map(|(listener, capability)| { + .map(|(listener, capability, http_timeout)| { let listen = listener.local_addr()?; - Ok((listener, capability, listen)) + Ok((listener, capability, listen, http_timeout)) }) .collect::>>()?; - for (_, capability, listen) in &listeners { + for (_, capability, listen, _) in &listeners { if matches!(capability.service_type, crate::config::ServiceType::Tcp) { println!( "locho attached\n\nService: {}\nLocal TCP listener: {}", @@ -235,7 +267,7 @@ async fn run_attachments( )); let total_connections = std::sync::Arc::new(Semaphore::new(MAX_TOTAL_CONNECTIONS)); let mut listener_tasks = JoinSet::new(); - for (listener, capability, _listen) in listeners { + for (listener, capability, _listen, http_timeout) in listeners { let connection = connection_state.clone(); let service = capability.service.clone(); let secret = capability.secret.clone(); @@ -262,6 +294,7 @@ async fn run_attachments( service, secret, total_connections, + http_timeout, shutdown, ) .await @@ -536,6 +569,7 @@ async fn run_http_listener( service: String, secret: String, total_connections: std::sync::Arc, + http_timeout: std::time::Duration, shutdown: CancellationToken, ) -> Result<()> { let http_connections = std::sync::Arc::new(Semaphore::new(MAX_HTTP_CONNECTIONS)); @@ -570,8 +604,18 @@ async fn run_http_listener( let connection = connection.clone(); let service = service_name.clone(); let secret = secret.clone(); + let http_timeout = http_timeout; async move { - Ok::<_, Infallible>(handle_request(request, connection, service, secret).await) + Ok::<_, Infallible>( + handle_request( + request, + connection, + service, + secret, + http_timeout, + ) + .await, + ) } }); http1::Builder::new() @@ -759,11 +803,22 @@ fn is_idle_timeout(error: &anyhow::Error) -> bool { .is_some() } +struct HttpTunnelRequest { + service: String, + secret: String, + http_timeout: std::time::Duration, + method: http::Method, + path: String, + headers: Vec<(String, String)>, + body: Incoming, +} + async fn handle_request( request: Request, connection: ConnectionState, service: String, secret: String, + http_timeout: std::time::Duration, ) -> HttpResponse { let method = request.method().clone(); let path = request @@ -779,12 +834,15 @@ async fn handle_request( let headers = http_utils::headers_to_pairs(request.headers()); match tunnel_request( connection, - service, - secret, - method, - path, - headers, - request.into_body(), + HttpTunnelRequest { + service, + secret, + http_timeout, + method, + path, + headers, + body: request.into_body(), + }, ) .await { @@ -793,6 +851,8 @@ async fn handle_request( error!(%error, "tunnel request failed"); if error.downcast_ref::().is_some() { error_response(StatusCode::SERVICE_UNAVAILABLE) + } else if error.downcast_ref::().is_some() { + error_response(StatusCode::GATEWAY_TIMEOUT) } else if error.to_string().contains("403") { error_response(StatusCode::FORBIDDEN) } else if error.to_string().contains("501") { @@ -808,17 +868,13 @@ async fn handle_request( async fn tunnel_request( connection: ConnectionState, - service: String, - secret: String, - method: http::Method, - path: String, - headers: Vec<(String, String)>, - body: Incoming, + request: HttpTunnelRequest, ) -> Result { let lease = acquire_connection(connection).await?; - let result = - tunnel_request_on_lease(&lease, service, secret, method, path, headers, body).await; - if result.as_ref().is_err_and(is_transport_failure) { + let result = tunnel_request_on_lease(&lease, request).await; + if result.as_ref().is_err_and(|error| { + is_transport_failure(error) || error.downcast_ref::().is_some() + }) { lease.invalidate(); } result @@ -826,13 +882,17 @@ async fn tunnel_request( async fn tunnel_request_on_lease( lease: &ConnectionLease, - service: String, - secret: String, - method: http::Method, - path: String, - headers: Vec<(String, String)>, - body: Incoming, + request: HttpTunnelRequest, ) -> Result { + let HttpTunnelRequest { + service, + secret, + http_timeout, + method, + path, + headers, + body, + } = request; let (mut writer, mut reader) = timeout(HANDSHAKE_TIMEOUT, lease.connection.open_bi()) .await .context("HTTP tunnel stream open timed out")? @@ -894,13 +954,11 @@ async fn tunnel_request_on_lease( .await .map_err(transport_failure)?; } - let response: LochoResponseHead = timeout( - HTTP_REQUEST_TIMEOUT + HANDSHAKE_TIMEOUT, - read_json_head(&mut reader, MAX_HEAD_LEN), - ) - .await - .context("HTTP attachment handshake timed out")? - .map_err(transport_failure)?; + let response: LochoResponseHead = + timeout(http_timeout, read_json_head(&mut reader, MAX_HEAD_LEN)) + .await + .map_err(|_| anyhow::Error::new(HttpResponseTimeout))? + .map_err(transport_failure)?; if response.version != PROTOCOL_VERSION { return Err(anyhow!( "unsupported tunnel response version {}", @@ -920,21 +978,32 @@ async fn tunnel_request_on_lease( while remaining > 0 { let count = remaining.min(BODY_CHUNK_LEN as u64) as usize; let read = timeout( - http_response_body_timeout(), + http_timeout, reader.read_exact(&mut buffer[..count]), ) .await - .context("HTTP response body read timed out")?; - read.inspect_err(|_| body_guard.invalidate())?; + .map_err(|_| anyhow::Error::new(HttpResponseTimeout)) + .and_then(|result| result.context("HTTP response body read failed")); + if read.is_err() { + body_guard.invalidate(); + } + read?; yield Frame::data(Bytes::copy_from_slice(&buffer[..count])); remaining -= count as u64; } } else { loop { - let chunk = timeout(http_response_body_timeout(), read_body_chunk(&mut reader)) + let chunk = timeout( + http_timeout, + read_body_chunk(&mut reader), + ) .await - .context("HTTP response body read timed out")? - .inspect_err(|_| body_guard.invalidate())?; + .map_err(|_| anyhow::Error::new(HttpResponseTimeout)) + .and_then(|result| result.context("HTTP response body read failed")); + if chunk.is_err() { + body_guard.invalidate(); + } + let chunk = chunk?; let Some(chunk) = chunk else { break }; yield Frame::data(chunk); } @@ -953,16 +1022,6 @@ async fn tunnel_request_on_lease( Ok(output) } -fn http_response_body_timeout() -> std::time::Duration { - #[cfg(feature = "integration-test")] - if let Some(milliseconds) = std::env::var_os("LOCHO_TEST_HTTP_BODY_TIMEOUT_MS") { - if let Ok(milliseconds) = milliseconds.to_string_lossy().parse::() { - return std::time::Duration::from_millis(milliseconds); - } - } - HTTP_REQUEST_TIMEOUT -} - fn error_response(status: StatusCode) -> HttpResponse { Response::builder() .status(status) @@ -1031,4 +1090,10 @@ mod tests { .await .expect("cancelled retry should not wait for the full backoff"); } + + #[test] + fn response_timeout_has_gateway_status() { + let response = error_response(StatusCode::GATEWAY_TIMEOUT); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + } } diff --git a/src/attach_config.rs b/src/attach_config.rs index e1e7ca6..22fc0df 100644 --- a/src/attach_config.rs +++ b/src/attach_config.rs @@ -1,10 +1,15 @@ -use crate::capability::{self, Capability}; +use crate::{ + capability::{self, Capability}, + config::ServiceType, + protocol::{HTTP_REQUEST_TIMEOUT, MAX_HTTP_REQUEST_TIMEOUT_SECS}, +}; use anyhow::{bail, Context, Result}; use serde::Deserialize; use std::{ collections::HashSet, net::{IpAddr, SocketAddr}, path::Path, + time::Duration, }; const MAX_CONFIGURED_SERVICES: usize = 128; @@ -25,12 +30,26 @@ pub struct AttachConfig { pub struct AttachServiceConfig { pub capability: String, pub listen_port: u16, + #[serde(default)] + pub http_timeout_secs: Option, } #[derive(Debug)] pub struct AttachmentConfig { pub capability: Capability, pub listen: SocketAddr, + pub http_timeout: Duration, +} + +pub(crate) fn resolve_http_timeout(timeout_secs: Option) -> Result { + let timeout_secs = timeout_secs.unwrap_or(HTTP_REQUEST_TIMEOUT.as_secs()); + if timeout_secs == 0 || timeout_secs > MAX_HTTP_REQUEST_TIMEOUT_SECS { + bail!( + "HTTP attachment timeout must be between 1 and {} seconds", + MAX_HTTP_REQUEST_TIMEOUT_SECS + ) + } + Ok(Duration::from_secs(timeout_secs)) } impl AttachConfig { @@ -70,9 +89,22 @@ impl AttachConfig { if !ports.insert(service.listen_port) { bail!("duplicate attachment listen port {}", service.listen_port); } + let http_timeout = match capability.service_type { + ServiceType::Http => resolve_http_timeout(service.http_timeout_secs)?, + ServiceType::Tcp => { + if service.http_timeout_secs.is_some() { + bail!( + "HTTP attachment timeout is only supported for HTTP service {}", + index + 1 + ) + } + HTTP_REQUEST_TIMEOUT + } + }; Ok(AttachmentConfig { capability, listen: SocketAddr::new(self.listen_host, service.listen_port), + http_timeout, }) }) .collect() @@ -115,6 +147,7 @@ mod tests { [[services]] capability = "api:http:api-secret" listen_port = 8765 + http_timeout_secs = 90 [[services]] capability = "database:tcp:db-secret" @@ -127,10 +160,12 @@ mod tests { let attachments = config.attachments().unwrap(); assert_eq!(attachments.len(), 2); assert_eq!(attachments[0].capability.service, "api"); + assert_eq!(attachments[0].http_timeout, Duration::from_secs(90)); assert_eq!( attachments[1].capability.service_type, crate::config::ServiceType::Tcp ); + assert_eq!(attachments[1].http_timeout, HTTP_REQUEST_TIMEOUT); assert_eq!(attachments[1].listen, "127.0.0.1:5432".parse().unwrap()); } @@ -145,6 +180,10 @@ mod tests { "#, ) .unwrap(); + assert_eq!( + config.attachments().unwrap()[0].http_timeout, + HTTP_REQUEST_TIMEOUT + ); assert_eq!(config.listen_host, "127.0.0.1".parse::().unwrap()); } @@ -179,6 +218,46 @@ mod tests { assert!(result.is_err()); } + #[test] + fn rejects_invalid_http_timeout() { + for timeout_secs in [0, MAX_HTTP_REQUEST_TIMEOUT_SECS + 1] { + let config: AttachConfig = toml::from_str(&format!( + "host_id = \"aabb\"\n[[services]]\ncapability = \"api:http:secret\"\nlisten_port = 8765\nhttp_timeout_secs = {timeout_secs}\n" + )) + .unwrap(); + assert!(config.validate().is_err()); + } + } + + #[test] + fn accepts_http_timeout_boundaries() { + for timeout_secs in [1, MAX_HTTP_REQUEST_TIMEOUT_SECS] { + let config: AttachConfig = toml::from_str(&format!( + "host_id = \"aabb\"\n[[services]]\ncapability = \"api:http:secret\"\nlisten_port = 8765\nhttp_timeout_secs = {timeout_secs}\n" + )) + .unwrap(); + assert_eq!( + config.attachments().unwrap()[0].http_timeout, + Duration::from_secs(timeout_secs) + ); + } + } + + #[test] + fn rejects_http_timeout_for_tcp_service() { + let config: AttachConfig = toml::from_str( + r#" + host_id = "aabb" + [[services]] + capability = "database:tcp:secret" + listen_port = 5432 + http_timeout_secs = 90 + "#, + ) + .unwrap(); + assert!(config.validate().is_err()); + } + #[test] fn load_redacts_invalid_capability_from_errors() { let path = std::env::temp_dir().join(format!( diff --git a/src/config.rs b/src/config.rs index 4862523..89b7436 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,8 @@ pub struct ServiceConfig { #[serde(rename = "type")] pub service_type: ServiceType, pub upstream: Option, + #[serde(default)] + pub upstream_timeout_secs: Option, pub ca_cert: Option, pub endpoint: Option, } @@ -88,6 +90,17 @@ impl Config { ) })?; } + if let Some(timeout_secs) = service.upstream_timeout_secs { + if timeout_secs == 0 + || timeout_secs > crate::protocol::MAX_HTTP_REQUEST_TIMEOUT_SECS + { + bail!( + "HTTP service {:?} upstream_timeout_secs must be between 1 and {}", + service.name, + crate::protocol::MAX_HTTP_REQUEST_TIMEOUT_SECS + ) + } + } let local_test_http = cfg!(feature = "integration-test") && upstream.scheme() == "http" && upstream.host_str() == Some("127.0.0.1"); @@ -101,9 +114,12 @@ impl Config { } } ServiceType::Tcp => { - if service.upstream.is_some() || service.ca_cert.is_some() { + if service.upstream.is_some() + || service.upstream_timeout_secs.is_some() + || service.ca_cert.is_some() + { bail!( - "TCP service {:?} cannot define upstream or ca_cert", + "TCP service {:?} cannot define upstream, upstream_timeout_secs, or ca_cert", service.name ) } @@ -143,6 +159,7 @@ mod tests { name: name.into(), service_type: ServiceType::Http, upstream: Some(Url::parse("https://example.com").unwrap()), + upstream_timeout_secs: None, ca_cert: None, endpoint: None, } @@ -157,6 +174,7 @@ mod tests { name: "database".into(), service_type: ServiceType::Tcp, upstream: None, + upstream_timeout_secs: None, ca_cert: None, endpoint: Some("127.0.0.1:5432".parse().unwrap()), }, @@ -182,6 +200,7 @@ mod tests { name: "api".into(), service_type: ServiceType::Http, upstream: Some(Url::parse("http://example.com").unwrap()), + upstream_timeout_secs: None, ca_cert: None, endpoint: None, }], @@ -200,6 +219,7 @@ mod tests { name = "api" type = "http" upstream = "https://example.com" + upstream_timeout_secs = 90 [[services]] name = "database" @@ -210,6 +230,8 @@ mod tests { .unwrap(); assert!(config.validate().is_ok()); assert_eq!(config.services.len(), 2); + assert_eq!(config.services[0].upstream_timeout_secs, Some(90)); + assert_eq!(config.services[1].upstream_timeout_secs, None); } #[test] @@ -233,10 +255,48 @@ mod tests { name: "database".into(), service_type: ServiceType::Tcp, upstream: None, + upstream_timeout_secs: None, ca_cert: None, endpoint: Some("127.0.0.1:0".parse().unwrap()), }], }; assert!(config.validate().is_err()); } + + #[test] + fn rejects_invalid_http_timeout() { + for timeout_secs in [0, crate::protocol::MAX_HTTP_REQUEST_TIMEOUT_SECS + 1] { + let mut config = Config { + services: vec![http("api")], + }; + config.services[0].upstream_timeout_secs = Some(timeout_secs); + assert!(config.validate().is_err()); + } + } + + #[test] + fn accepts_http_timeout_boundaries() { + for timeout_secs in [1, crate::protocol::MAX_HTTP_REQUEST_TIMEOUT_SECS] { + let mut config = Config { + services: vec![http("api")], + }; + config.services[0].upstream_timeout_secs = Some(timeout_secs); + assert!(config.validate().is_ok()); + } + } + + #[test] + fn rejects_timeout_on_tcp_service() { + let config = Config { + services: vec![ServiceConfig { + name: "database".into(), + service_type: ServiceType::Tcp, + upstream: None, + upstream_timeout_secs: Some(90), + ca_cert: None, + endpoint: Some("127.0.0.1:5432".parse().unwrap()), + }], + }; + assert!(config.validate().is_err()); + } } diff --git a/src/host.rs b/src/host.rs index ad485d6..1527fbc 100644 --- a/src/host.rs +++ b/src/host.rs @@ -11,6 +11,7 @@ use iroh::{endpoint::presets, Endpoint}; use reqwest::Client; use std::net::SocketAddr; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::io::AsyncReadExt; use tokio::io::{AsyncRead, AsyncWrite}; @@ -18,7 +19,7 @@ use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::sync::Semaphore; use tokio::task::JoinSet; -use tokio::time::timeout; +use tokio::time::{timeout, timeout_at, Instant}; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; @@ -76,7 +77,8 @@ pub async fn run(config_path: PathBuf, bind_address: Option) -> Resu .iter() .filter(|service| matches!(service.service_type, ServiceType::Http)) .map(|service| { - let mut builder = Client::builder().timeout(test_http_request_timeout()); + let mut builder = + Client::builder().timeout(http_request_timeout(service.upstream_timeout_secs)); if let Some(ca_cert) = ca_certificates.get(&service.name) { builder = builder.add_root_certificate(ca_cert.clone()); } @@ -303,20 +305,47 @@ where (ServiceType::Tcp, _) => return write_error(&mut writer, 501).await, _ => return write_error(&mut writer, 500).await, }; - if let Err(error) = forward_to_upstream(upstream, &client, req, reader, &mut writer).await { - let causes = error.chain().map(ToString::to_string).collect::>(); - error!(%error, ?causes, "upstream request failed"); - let status = error - .downcast_ref::() - .filter(|error| error.is_timeout()) - .map(|_| 504) - .unwrap_or(502); - let status = if error.to_string().contains("body exceeds limit") { - 413 - } else { - status - }; - return write_error(&mut writer, status).await; + let response_started = Arc::new(AtomicBool::new(false)); + let deadline = Instant::now() + http_request_timeout(service.upstream_timeout_secs); + match timeout_at( + deadline, + forward_to_upstream_with_state( + upstream, + &client, + req, + reader, + &mut writer, + Arc::clone(&response_started), + ), + ) + .await + { + Err(_) => { + error!("upstream request timed out"); + if !response_started.load(Ordering::Acquire) { + let _ = timeout_at(deadline, write_error(&mut writer, 504)).await; + } + return Ok(()); + } + Ok(Err(error)) => { + let causes = error.chain().map(ToString::to_string).collect::>(); + error!(%error, ?causes, "upstream request failed"); + if response_started.load(Ordering::Acquire) { + return Ok(()); + } + let status = error + .downcast_ref::() + .filter(|error| error.is_timeout()) + .map(|_| 504) + .unwrap_or(502); + let status = if error.to_string().contains("body exceeds limit") { + 413 + } else { + status + }; + return write_error(&mut writer, status).await; + } + Ok(Ok(())) => {} } Ok(()) } @@ -440,12 +469,36 @@ async fn write_error(writer: &mut W, status: u16) -> Resu write_body(writer, &[]).await } -pub async fn forward_to_upstream( +#[cfg(test)] +async fn forward_to_upstream( + upstream: Url, + client: &Client, + req: LochoRequestHead, + reader: R, + writer: &mut W, +) -> Result<()> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin, +{ + forward_to_upstream_with_state( + upstream, + client, + req, + reader, + writer, + Arc::new(AtomicBool::new(false)), + ) + .await +} + +async fn forward_to_upstream_with_state( upstream: Url, client: &Client, req: LochoRequestHead, mut reader: R, writer: &mut W, + response_started: Arc, ) -> Result<()> where R: AsyncRead + Unpin + Send + 'static, @@ -535,6 +588,7 @@ where headers, body_len, }; + response_started.store(true, Ordering::Release); write_json_head(writer, &head).await?; let mut response_body = response.bytes_stream(); let mut total = 0usize; @@ -543,41 +597,31 @@ where Ok(chunk) => chunk, Err(error) => { error!(%error, "upstream response stream failed after headers"); - return Ok(()); + return Err(error.into()); } }; total += chunk.len(); if total > MAX_BODY_LEN { - error!("upstream response exceeds limit after headers"); - return Ok(()); + bail!("upstream response exceeds limit after headers"); } if body_len.is_some() { - if let Err(error) = write_body(writer, &chunk).await { - error!(%error, "tunnel response write failed after headers"); - return Ok(()); - } + write_body(writer, &chunk).await?; } else if let Err(error) = write_body_chunk(writer, &chunk).await { error!(%error, "tunnel response write failed after headers"); - return Ok(()); + return Err(error); } } if body_len.is_none() { - if let Err(error) = write_body_end(writer).await { - error!(%error, "tunnel response end write failed after headers"); - } + write_body_end(writer).await?; } info!(status, "upstream response"); Ok(()) } -fn test_http_request_timeout() -> std::time::Duration { - #[cfg(feature = "integration-test")] - if let Some(milliseconds) = std::env::var_os("LOCHO_TEST_HTTP_TIMEOUT_MS") { - if let Ok(milliseconds) = milliseconds.to_string_lossy().parse::() { - return std::time::Duration::from_millis(milliseconds); - } - } - HTTP_REQUEST_TIMEOUT +fn http_request_timeout(configured_timeout_secs: Option) -> std::time::Duration { + configured_timeout_secs + .map(std::time::Duration::from_secs) + .unwrap_or(HTTP_REQUEST_TIMEOUT) } struct AbortOnDrop(Option>); @@ -629,6 +673,7 @@ mod tests { name: "api".into(), service_type: ServiceType::Http, upstream: Some(Url::parse("https://example.com").unwrap()), + upstream_timeout_secs: None, ca_cert: None, endpoint: None, }], @@ -647,6 +692,15 @@ mod tests { assert!(validate_bind_address("[::1]:12345".parse().unwrap()).is_err()); } + #[test] + fn http_timeout_defaults_and_accepts_configuration() { + assert_eq!(http_request_timeout(None), HTTP_REQUEST_TIMEOUT); + assert_eq!( + http_request_timeout(Some(90)), + std::time::Duration::from_secs(90) + ); + } + fn tcp_services(endpoint: std::net::SocketAddr) -> Arc { Arc::new(HostServices { config: Config { @@ -654,6 +708,7 @@ mod tests { name: "database".into(), service_type: ServiceType::Tcp, upstream: None, + upstream_timeout_secs: None, ca_cert: None, endpoint: Some(endpoint), }], diff --git a/src/main.rs b/src/main.rs index 10f11cc..b6e8570 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ const VERSION: &str = concat!( env!("LOCHO_GIT_DIRTY"), ")" ); +const DEFAULT_ATTACH_LISTEN: &str = "127.0.0.1:8765"; #[derive(Parser)] #[command( @@ -85,6 +86,8 @@ enum Command { tcp: bool, #[arg(long, default_value = "127.0.0.1:8765")] listen: SocketAddr, + #[arg(long)] + http_timeout_secs: Option, }, } @@ -164,10 +167,20 @@ async fn main() -> Result<()> { direct_address, tcp, listen, + http_timeout_secs, } => { if let Some(config) = config { - if host_id.is_some() || capability.is_some() || legacy_secret.is_some() || tcp { - bail!("--config cannot be combined with positional attach arguments or --tcp"); + if host_id.is_some() + || capability.is_some() + || legacy_secret.is_some() + || tcp + || http_timeout_secs.is_some() + || listen + != DEFAULT_ATTACH_LISTEN + .parse() + .expect("valid default listener") + { + bail!("--config cannot be combined with positional attach arguments, --tcp, --listen, or --http-timeout-secs"); } attach::run_config(config, direct_address).await } else { @@ -175,7 +188,14 @@ async fn main() -> Result<()> { let capability = capability.ok_or_else(|| anyhow::anyhow!("attach requires CAPABILITY"))?; let capability = normalize_capability(&capability, legacy_secret, tcp)?; - attach::run(host_id, capability, direct_address, listen).await + attach::run( + host_id, + capability, + direct_address, + listen, + http_timeout_secs, + ) + .await } } } @@ -252,4 +272,23 @@ mod tests { fn rejects_tcp_flag_with_capability_syntax() { assert!(normalize_capability("database:tcp:secret", None, true).is_err()); } + + #[test] + fn parses_http_timeout_for_positional_attach() { + let cli = Cli::try_parse_from([ + "locho", + "attach", + "host", + "api:http:secret", + "--http-timeout-secs", + "90", + ]) + .unwrap(); + match cli.command { + Command::Attach { + http_timeout_secs, .. + } => assert_eq!(http_timeout_secs, Some(90)), + _ => panic!("parsed the wrong command"), + } + } } diff --git a/src/protocol.rs b/src/protocol.rs index 80030ab..a717aec 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -12,8 +12,9 @@ pub const MAX_TCP_CONNECTIONS: usize = 128; pub const MAX_HTTP_CONNECTIONS: usize = 128; pub const TCP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); pub const TCP_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); -pub const HTTP_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); pub const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +pub const HTTP_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); +pub const MAX_HTTP_REQUEST_TIMEOUT_SECS: u64 = 300; pub const ATTACH_RECONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); pub const RECONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250); pub const RECONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30); diff --git a/tests/integration.rs b/tests/integration.rs index c9c43d7..c86283b 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -304,7 +304,7 @@ fn one_attachment_process_supports_multiple_services() { fs::write( &attach_config, format!( - "host_id = \"{}\"\ndirect_address = \"{direct_address}\"\n\n[[services]]\ncapability = \"{}\"\nlisten_port = {first_port}\n\n[[services]]\ncapability = \"{}\"\nlisten_port = {second_port}\n", + "host_id = \"{}\"\ndirect_address = \"{direct_address}\"\n\n[[services]]\ncapability = \"{}\"\nlisten_port = {first_port}\n\n[[services]]\ncapability = \"{}\"\nlisten_port = {second_port}\nhttp_timeout_secs = 90\n", first_parts[2], first_parts[3], second_parts[3] ), ) @@ -626,19 +626,18 @@ fn http_attachment_reports_upstream_timeout() { let (stream, _) = accept_with_deadline(&upstream_listener); stream.set_read_timeout(Some(TEST_IO_TIMEOUT)).unwrap(); stream.set_write_timeout(Some(TEST_IO_TIMEOUT)).unwrap(); - thread::sleep(Duration::from_millis(500)); + thread::sleep(Duration::from_millis(1_100)); }); let config_path = state_dir.path().join("locho.toml"); fs::write( &config_path, format!( - "[[services]]\nname = \"api\"\ntype = \"http\"\nupstream = \"http://{upstream_address}\"\n" + "[[services]]\nname = \"api\"\ntype = \"http\"\nupstream = \"http://{upstream_address}\"\nupstream_timeout_secs = 1\n" ), ) .unwrap(); let direct_address = format!("127.0.0.1:{}", free_udp_port()); - let mut host = - start_host_with_timeout(state_dir.path(), &config_path, &direct_address, Some("100")); + let mut host = start_host(state_dir.path(), &config_path, &direct_address); host.wait_for("locho direct-address "); let attach_command = host.wait_for_attach(state_dir.path(), &config_path, "api"); let attach_port = free_port(); @@ -657,6 +656,53 @@ fn http_attachment_reports_upstream_timeout() { host.stop(); } +#[cfg(all(feature = "integration-test", unix))] +#[test] +fn host_closes_stalled_response_after_headers() { + let state_dir = TestDir::new(); + let upstream_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let upstream_address = upstream_listener.local_addr().unwrap(); + let upstream_thread = thread::spawn(move || { + let (mut stream, _) = accept_with_deadline(&upstream_listener); + let _ = read_http_message(&mut stream); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1024\r\n\r\nx") + .unwrap(); + thread::sleep(Duration::from_secs(2)); + }); + let config_path = state_dir.path().join("locho.toml"); + fs::write( + &config_path, + format!( + "[[services]]\nname = \"api\"\ntype = \"http\"\nupstream = \"http://{upstream_address}\"\nupstream_timeout_secs = 1\n" + ), + ) + .unwrap(); + let direct_address = format!("127.0.0.1:{}", free_udp_port()); + let mut host = start_host(state_dir.path(), &config_path, &direct_address); + host.wait_for("locho direct-address "); + let attach_command = host.wait_for_attach(state_dir.path(), &config_path, "api"); + let attach_port = free_port(); + let mut attachment = start_http_attachment( + state_dir.path(), + &attach_command, + attach_port, + &direct_address, + ); + attachment.wait_for("Local proxy:"); + + let started = Instant::now(); + let (response, closed) = send_http_request_allowing_truncated_body(attach_port, "/stalled"); + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(closed); + assert_eq!(response.status, 200); + assert!(response.body.len() < 1024); + + assert!(upstream_thread.join().is_ok()); + attachment.stop(); + host.stop(); +} + #[cfg(feature = "integration-test")] #[test] fn diagnose_reports_configuration_without_capabilities() { @@ -746,6 +792,24 @@ fn attach_config_rejects_mixed_cli_arguments() { "api:http:secret", "--config", "attachments.toml", + "--http-timeout-secs", + "90", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--config cannot be combined")); +} + +#[test] +fn attach_config_rejects_listen_override() { + let output = Command::new(locho_binary()) + .args([ + "attach", + "--config", + "attachments.toml", + "--listen", + "127.0.0.1:9000", ]) .output() .unwrap(); @@ -1128,13 +1192,17 @@ fn http_attachment_closes_stalled_response_body() { host.wait_for("locho direct-address "); let attach_command = host.wait_for_attach(state_dir.path(), &config_path, "api"); let attach_port = free_port(); - let mut attachment = start_http_attachment_with_body_timeout( - state_dir.path(), - &attach_command, - attach_port, - &direct_address, - "100", - ); + let (host_id, service, secret) = parse_attach_command(&attach_command); + let attachment_config = state_dir.path().join("attachments.toml"); + fs::write( + &attachment_config, + format!( + "host_id = \"{host_id}\"\ndirect_address = \"{direct_address}\"\n\n[[services]]\ncapability = \"{service}:http:{secret}\"\nlisten_port = {attach_port}\nhttp_timeout_secs = 1\n" + ), + ) + .unwrap(); + let mut attachment = + start_config_attachment(state_dir.path(), &attachment_config, &direct_address); attachment.wait_for("Local proxy:"); attachment.wait_for("transport path: direct("); @@ -1527,15 +1595,6 @@ fn decode_chunked_body(bytes: &[u8]) -> Vec { } fn start_host(state_dir: &Path, config_path: &Path, direct_address: &str) -> ProcessOutput { - start_host_with_timeout(state_dir, config_path, direct_address, None) -} - -fn start_host_with_timeout( - state_dir: &Path, - config_path: &Path, - direct_address: &str, - timeout_milliseconds: Option<&str>, -) -> ProcessOutput { let mut command = Command::new(locho_binary()); command .env("LOCHO_STATE_DIR", state_dir) @@ -1543,9 +1602,6 @@ fn start_host_with_timeout( .arg("host") .arg("--config") .arg(config_path); - if let Some(timeout_milliseconds) = timeout_milliseconds { - command.env("LOCHO_TEST_HTTP_TIMEOUT_MS", timeout_milliseconds); - } ProcessOutput::spawn(command) } @@ -1591,24 +1647,11 @@ fn start_http_attachment( command_line: &str, port: u16, direct_address: &str, -) -> ProcessOutput { - start_http_attachment_with_body_timeout(state_dir, command_line, port, direct_address, "") -} - -fn start_http_attachment_with_body_timeout( - state_dir: &Path, - command_line: &str, - port: u16, - direct_address: &str, - body_timeout_milliseconds: &str, ) -> ProcessOutput { let mut command = Command::new(locho_binary()); command .env("LOCHO_STATE_DIR", state_dir) .env("LOCHO_TEST_DIRECT_ADDR", direct_address); - if !body_timeout_milliseconds.is_empty() { - command.env("LOCHO_TEST_HTTP_BODY_TIMEOUT_MS", body_timeout_milliseconds); - } for argument in command_line .split_whitespace() .skip_while(|argument| *argument == "locho") @@ -1619,6 +1662,21 @@ fn start_http_attachment_with_body_timeout( ProcessOutput::spawn(command) } +#[cfg(unix)] +fn start_config_attachment( + state_dir: &Path, + config_path: &Path, + direct_address: &str, +) -> ProcessOutput { + let mut command = Command::new(locho_binary()); + command + .env("LOCHO_STATE_DIR", state_dir) + .env("LOCHO_TEST_DIRECT_ADDR", direct_address) + .args(["attach", "--config"]) + .arg(config_path); + ProcessOutput::spawn(command) +} + fn start_ready_attachment( state_dir: &Path, command_line: &str, diff --git a/tests/perf/README.md b/tests/perf/README.md index 2a8e0e7..b203f0b 100644 --- a/tests/perf/README.md +++ b/tests/perf/README.md @@ -52,6 +52,14 @@ operation in memory. Container statistics are sampled every five seconds by the collector, including restart count and exit status. No throughput threshold is imposed; compare runs using the same machine and commit. +The soak runner accepts `--http-timeout-secs` and writes that value into the +host configuration. Use a short value only with a deliberately delayed fixture; +the normal HTTPS fixture is expected to complete within the timeout. + +The standard stress baseline stays below Locho's documented TCP connection +limit. Run `--tcp-concurrency "1 10 50 100"` separately when validating the +limit and expected rejection behavior. + ## Soak Runs The soak runner maintains mixed HTTP and TCP traffic, periodically churns diff --git a/tests/perf/scripts/run-soak.sh b/tests/perf/scripts/run-soak.sh index 9f96293..ea47000 100755 --- a/tests/perf/scripts/run-soak.sh +++ b/tests/perf/scripts/run-soak.sh @@ -10,6 +10,7 @@ TCP_CONCURRENCY=10 HTTP_SIZE=1024 HTTP_REQUEST_SIZE=0 TCP_SIZE=256 +HTTP_TIMEOUT_SECS=60 OUTPUT_ROOT="$REPO/artifacts" CHURN_INTERVAL=60 SUCCESS_SAMPLE_RATE=100 @@ -17,7 +18,7 @@ INTERVAL_SECONDS=10 MIN_DURATION=360 usage() { - echo "usage: $0 [--duration 1h] [--http-concurrency N] [--tcp-concurrency N] [--http-size BYTES] [--http-request-size BYTES] [--tcp-size BYTES] [--churn-interval SECONDS] [--success-sample-rate N] [--interval SECONDS] [--output DIR]" + echo "usage: $0 [--duration 1h] [--http-concurrency N] [--tcp-concurrency N] [--http-size BYTES] [--http-request-size BYTES] [--tcp-size BYTES] [--http-timeout-secs SECONDS] [--churn-interval SECONDS] [--success-sample-rate N] [--interval SECONDS] [--output DIR]" } parse_duration() { @@ -37,6 +38,7 @@ while [ $# -gt 0 ]; do --http-size) HTTP_SIZE=$2; shift 2;; --http-request-size) HTTP_REQUEST_SIZE=$2; shift 2;; --tcp-size) TCP_SIZE=$2; shift 2;; + --http-timeout-secs) HTTP_TIMEOUT_SECS=$2; shift 2;; --churn-interval) CHURN_INTERVAL=$2; shift 2;; --success-sample-rate) SUCCESS_SAMPLE_RATE=$2; shift 2;; --interval) INTERVAL_SECONDS=$2; shift 2;; @@ -52,7 +54,7 @@ case "$DURATION" in *.*|*[!0-9]*) echo "duration must be an integer number of se exit 2 } [ "$DURATION" -le 3600 ] || { echo "duration must not exceed 1 hour" >&2; exit 2; } -for value in "$HTTP_CONCURRENCY" "$TCP_CONCURRENCY" "$HTTP_SIZE" "$HTTP_REQUEST_SIZE" "$TCP_SIZE" "$CHURN_INTERVAL" "$SUCCESS_SAMPLE_RATE" "$INTERVAL_SECONDS"; do +for value in "$HTTP_CONCURRENCY" "$TCP_CONCURRENCY" "$HTTP_SIZE" "$HTTP_REQUEST_SIZE" "$HTTP_TIMEOUT_SECS" "$TCP_SIZE" "$CHURN_INTERVAL" "$SUCCESS_SAMPLE_RATE" "$INTERVAL_SECONDS"; do case "$value" in *.*|*[!0-9]*) echo "numeric options must be non-negative integers" >&2; exit 2;; esac done [ "$HTTP_CONCURRENCY" -gt 0 ] || { echo "HTTP concurrency must be positive" >&2; exit 2; } @@ -60,6 +62,7 @@ done [ "$CHURN_INTERVAL" -gt 0 ] || { echo "churn interval must be positive" >&2; exit 2; } [ "$SUCCESS_SAMPLE_RATE" -gt 0 ] || { echo "success sample rate must be positive" >&2; exit 2; } [ "$INTERVAL_SECONDS" -gt 0 ] || { echo "interval must be positive" >&2; exit 2; } +[ "$HTTP_TIMEOUT_SECS" -ge 1 ] && [ "$HTTP_TIMEOUT_SECS" -le 300 ] || { echo "HTTP timeout must be between 1 and 300 seconds" >&2; exit 2; } mkdir -p "$OUTPUT_ROOT" OUTPUT_ROOT=$(CDPATH= cd -- "$OUTPUT_ROOT" && pwd) @@ -110,7 +113,7 @@ cleanup() { printf '%b\n' "$image_metadata" echo "duration_seconds=$DURATION" echo "http_concurrency=$HTTP_CONCURRENCY tcp_concurrency=$TCP_CONCURRENCY" - echo "http_size=$HTTP_SIZE http_request_size=$HTTP_REQUEST_SIZE tcp_size=$TCP_SIZE churn_interval=$CHURN_INTERVAL success_sample_rate=$SUCCESS_SAMPLE_RATE interval_seconds=$INTERVAL_SECONDS" + echo "http_size=$HTTP_SIZE http_request_size=$HTTP_REQUEST_SIZE http_timeout_secs=$HTTP_TIMEOUT_SECS tcp_size=$TCP_SIZE churn_interval=$CHURN_INTERVAL success_sample_rate=$SUCCESS_SAMPLE_RATE interval_seconds=$INTERVAL_SECONDS" echo "start=$START end=$END deadline=$DEADLINE exit_status=$status" } > "$RUN/metadata.txt" rm -rf "$RUNTIME" @@ -159,6 +162,7 @@ name = "api" type = "http" upstream = "https://upstream_http:8443" ca_cert = "/run/locho/ca.crt" +upstream_timeout_secs = $HTTP_TIMEOUT_SECS [[services]] name = "echo" diff --git a/tests/perf/scripts/run-stress.sh b/tests/perf/scripts/run-stress.sh index b102272..368ce8a 100755 --- a/tests/perf/scripts/run-stress.sh +++ b/tests/perf/scripts/run-stress.sh @@ -9,8 +9,8 @@ HTTP_SIZE=1024 HTTP_REQUEST_SIZE=0 TCP_SIZE=256 OUTPUT_ROOT="$REPO/artifacts" -HTTP_LEVELS="1 10 50 100 200" -TCP_LEVELS="1 10 50 100" +HTTP_LEVELS="1 10 50 100" +TCP_LEVELS="1 10 50" usage() { echo "usage: $0 [--duration 15m] [--http-concurrency LIST] [--tcp-concurrency LIST] [--http-size BYTES] [--http-request-size BYTES] [--tcp-size BYTES] [--output DIR]"; } parse_duration() { diff --git a/tests/release_smoke.rs b/tests/release_smoke.rs index 9283cd9..3fdcd8e 100644 --- a/tests/release_smoke.rs +++ b/tests/release_smoke.rs @@ -480,13 +480,13 @@ fn release_binary_reports_http_upstream_timeout() { let binary = release_binary(); let state_dir = TestDir::new(); let https_upstream = - HttpsUpstream::start_with_options(state_dir.path(), 1, Duration::from_secs(31)); + HttpsUpstream::start_with_options(state_dir.path(), 1, Duration::from_secs(2)); let config_path = state_dir.path().join("locho.toml"); let direct_address = format!("127.0.0.1:{}", free_port()); fs::write( &config_path, format!( - "[[services]]\nname = \"web\"\ntype = \"http\"\nupstream = \"https://127.0.0.1:{}\"\nca_cert = \"{}\"\n", + "[[services]]\nname = \"web\"\ntype = \"http\"\nupstream = \"https://127.0.0.1:{}\"\nupstream_timeout_secs = 1\nca_cert = \"{}\"\n", https_upstream.address.port(), toml_string(&https_upstream.ca_cert), ), @@ -516,7 +516,7 @@ fn release_binary_reports_http_upstream_timeout() { ); http.wait_for("Local proxy:"); assert_eq!( - http_get_with_timeout(http_port, "/slow", Duration::from_secs(35)).0, + http_get_with_timeout(http_port, "/slow", Duration::from_secs(5)).0, 504 );