mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-11 07:36:53 +00:00
feat(server): add opt-in global connection cap to the S3 accept loop (#4957)
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.
This commit is contained in:
@@ -73,3 +73,20 @@ pub const ENV_API_RATE_LIMIT_BUCKET_BURST: &str = "RUSTFS_API_RATE_LIMIT_BUCKET_
|
||||
|
||||
/// Default for `RUSTFS_API_RATE_LIMIT_BUCKET_BURST` (`0` = same as bucket RPM).
|
||||
pub const DEFAULT_API_RATE_LIMIT_BUCKET_BURST: u32 = 0;
|
||||
|
||||
/// Maximum concurrently served connections on the main API listener.
|
||||
///
|
||||
/// `0` (the default) means unlimited. When set, the accept loop stops
|
||||
/// accepting once the cap is reached and lets the kernel backlog absorb
|
||||
/// bursts, releasing capacity as connections close. This bounds file
|
||||
/// descriptor and memory usage under a connection flood.
|
||||
///
|
||||
/// The cap covers everything on the main listener — S3, admin, console,
|
||||
/// and internode gRPC — so size it well above peer-node count plus the
|
||||
/// expected client concurrency.
|
||||
/// Environment variable: RUSTFS_API_MAX_CONNECTIONS
|
||||
/// Example: RUSTFS_API_MAX_CONNECTIONS=10000
|
||||
pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
|
||||
|
||||
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
|
||||
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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(())
|
||||
}
|
||||
@@ -83,6 +83,10 @@ mod security_boundary_test;
|
||||
#[cfg(test)]
|
||||
mod api_rate_limit_test;
|
||||
|
||||
// Opt-in global connection cap on the main listener (backlog#1191 follow-up)
|
||||
#[cfg(test)]
|
||||
mod connection_cap_test;
|
||||
|
||||
// Admin authorization gate: non-admin denial + root-credential lifecycle (backlog#1151 sec-4)
|
||||
#[cfg(test)]
|
||||
mod admin_auth_test;
|
||||
|
||||
Reference in New Issue
Block a user