mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-29 09:38:59 +00:00
40c089f31b
Follow-up to backlog#1191 (optional sub-item). The main accept loop spawned one task per socket with no global bound, so a connection flood could exhaust file descriptors and memory and take existing traffic down with it. - New RUSTFS_API_MAX_CONNECTIONS (default 0 = unlimited, no semaphore constructed, accept loop unchanged). - When set, the loop acquires an owned semaphore permit BEFORE accept(): at saturation it simply stops accepting and lets the kernel backlog absorb bursts (TCP-native backpressure) instead of accept-then-close churn. The permit moves into the connection task and is released by RAII on any exit path, including TLS handshake failures. - Saturation is observable via the rustfs_http_server_connection_cap_saturated_total counter, a connection_cap_state startup event, and a per-wait debug event; shutdown stays responsive while parked on the semaphore. - The cap covers everything on the main listener (S3, admin, console, internode gRPC); the constant docs carry sizing guidance. - E2E coverage: ten sequential Connection-close requests against cap 2 prove permits never leak; two stalled connections saturating cap 2 leave a third unserved until their permits are released, after which the queued request is accepted and answered.
128 lines
5.3 KiB
Rust
128 lines
5.3 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! E2E coverage for the opt-in global connection cap on the main API listener
|
|
//! (backlog#1191 follow-up, `RUSTFS_API_MAX_CONNECTIONS`): permits must be
|
|
//! released when connections close (no leak), and the cap must actually bound
|
|
//! concurrency — a queued connection is served only after a held one closes.
|
|
|
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
|
use serial_test::serial;
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpStream;
|
|
use tokio::time::timeout;
|
|
|
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
|
|
|
/// Open a TCP connection and write one unauthenticated `GET /` (any response,
|
|
/// e.g. 403, proves the connection was accepted and served).
|
|
async fn open_and_request(addr: &str, connection: &str) -> std::io::Result<TcpStream> {
|
|
let mut stream = TcpStream::connect(addr).await?;
|
|
let request = format!("GET / HTTP/1.1\r\nHost: {addr}\r\nConnection: {connection}\r\n\r\n");
|
|
stream.write_all(request.as_bytes()).await?;
|
|
Ok(stream)
|
|
}
|
|
|
|
/// Read until the response head is complete, or `None` on timeout/close —
|
|
/// a `None` on an open socket means the connection sits unaccepted in the
|
|
/// kernel backlog behind the cap.
|
|
async fn read_response_head(stream: &mut TcpStream, dur: Duration) -> Option<String> {
|
|
let deadline = tokio::time::Instant::now() + dur;
|
|
let mut buf = vec![0u8; 4096];
|
|
let mut collected = String::new();
|
|
loop {
|
|
let remaining = deadline.checked_duration_since(tokio::time::Instant::now())?;
|
|
match timeout(remaining, stream.read(&mut buf)).await {
|
|
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
|
|
Ok(Ok(n)) => {
|
|
collected.push_str(&String::from_utf8_lossy(&buf[..n]));
|
|
if collected.contains("\r\n\r\n") {
|
|
return Some(collected);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn connection_cap_releases_permits_on_close() -> TestResult {
|
|
init_logging();
|
|
let mut env = RustFSTestEnvironment::new().await?;
|
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_API_MAX_CONNECTIONS", "2")])
|
|
.await?;
|
|
|
|
// Ten sequential connections against cap 2: if permits leaked, the third
|
|
// request would already hang in the backlog and time out.
|
|
for i in 0..10 {
|
|
let mut stream = open_and_request(&env.address, "close").await?;
|
|
let head = read_response_head(&mut stream, Duration::from_secs(10))
|
|
.await
|
|
.unwrap_or_else(|| panic!("request {i} got no response — a connection permit leaked"));
|
|
assert!(head.starts_with("HTTP/1.1"), "request {i} unexpected response: {head}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Open a TCP connection and send an INCOMPLETE request head. Once accepted
|
|
/// it pins a connection permit: hyper waits for the rest of the head (75s
|
|
/// default header timeout) until we close the socket.
|
|
async fn open_and_stall(addr: &str) -> std::io::Result<TcpStream> {
|
|
let mut stream = TcpStream::connect(addr).await?;
|
|
stream
|
|
.write_all(format!("GET / HTTP/1.1\r\nHost: {addr}\r\n").as_bytes())
|
|
.await?;
|
|
Ok(stream)
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn connection_cap_blocks_excess_connections_until_permits_free() -> TestResult {
|
|
init_logging();
|
|
let mut env = RustFSTestEnvironment::new().await?;
|
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_API_MAX_CONNECTIONS", "2")])
|
|
.await?;
|
|
// Let the readiness poller's pooled connection close and free its permit.
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
|
|
// Two stalled connections saturate cap 2 (a served-and-closed connection
|
|
// would release its permit immediately, so stalling is what makes the
|
|
// occupancy deterministic).
|
|
let stalled_a = open_and_stall(&env.address).await?;
|
|
let stalled_b = open_and_stall(&env.address).await?;
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
// A complete request now sits in the kernel backlog: connect() succeeds
|
|
// but no permit is available, so no response arrives.
|
|
let mut blocked = open_and_request(&env.address, "close").await?;
|
|
assert!(
|
|
read_response_head(&mut blocked, Duration::from_secs(3)).await.is_none(),
|
|
"cap 2 with two stalled connections must leave the third unserved"
|
|
);
|
|
|
|
// Dropping the stalled connections releases their permits (hyper sees
|
|
// EOF while reading the head); the queued request's bytes already sit in
|
|
// the socket buffer, so it must now be accepted and served.
|
|
drop(stalled_a);
|
|
drop(stalled_b);
|
|
let head = read_response_head(&mut blocked, Duration::from_secs(10))
|
|
.await
|
|
.expect("queued connection must be served after permits are released");
|
|
assert!(head.starts_with("HTTP/1.1"), "unexpected response: {head}");
|
|
|
|
Ok(())
|
|
}
|