feat(cdp): bound live CDP connections with --max-connections
Each connection runs on its own OS thread with its own V8 isolates, so connection count is what drives the server's thread footprint. MAX_PENDING_WS_HANDOFFS bounds only the handoff queue: once a stream is handed off, nothing limited how many could be live at once, and a client could grow the server without limit. Reserve a slot in the accept loop and release it from a guard that runs when the connection thread unwinds, on every path including panics and the early returns before the runtime is built -- a plain decrement at the end of the closure would leak slots until the cap wedged the server shut. Connections past the limit get an explicit 503 with X-Obscura-Reason: max-connections rather than a bare reset, so a client can tell a refusal from a crash. The refusal happens in the WS dispatch loop, so the HTTP control plane (#62) stays reachable at the cap. Default 128, matching the handoff bound and well above any real client fan-out (Playwright/Puppeteer use one connection per browser). At the cap, with arenas capped by the previous commit: 146 threads, 33.2 GiB of reserved address space and 51 MiB resident -- nearly all of that reservation being V8's process-wide sandbox, which is present at zero connections. Threads are the honest cost here, not memory: at 100 connections each running JS this server reserves 83.5 GiB against `main`'s 83.6 GiB, because `main` pays the same V8 sandbox as soon as it runs any JS. What it does not pay is 118 threads against 17. The test covers all three properties, and specifically that a closed connection frees its slot: a leak there would only surface as a server that silently stops accepting after N lifetime connections. Refs #430.
This commit is contained in:
1 parent
dc79fde1a0
commit
3015f7d8ad
4 files changed
+286
-9
No files matched your search
@@ -78,6 +78,13 @@ enum Command {
|
||||
#[arg(long, default_value_t = 1)]
|
||||
workers: u16,
|
||||
|
||||
/// Maximum live CDP connections. Each connection runs on its own OS
|
||||
/// thread with its own V8 isolates, so this bounds the server's thread
|
||||
/// and memory footprint. Connections beyond the limit are refused with
|
||||
/// a 503 rather than queued.
|
||||
#[arg(long, default_value_t = obscura_cdp::DEFAULT_MAX_CONNECTIONS)]
|
||||
max_connections: usize,
|
||||
|
||||
/// Allow CDP clients to navigate to file:// URLs. Off by
|
||||
/// default so a CDP connection cannot read arbitrary local
|
||||
/// files. Enable only when serving local HTML for testing
|
||||
@@ -328,7 +335,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let stealth = args.stealth;
|
||||
|
||||
match args.command {
|
||||
Some(Command::Serve { port, host, proxy, user_agent, workers, allow_file_access, storage_dir, quiet: _ }) => {
|
||||
Some(Command::Serve { port, host, proxy, user_agent, workers, max_connections, allow_file_access, storage_dir, quiet: _ }) => {
|
||||
// Fall back to OBSCURA_PROXY so a proxy can be supplied without
|
||||
// putting credentials on the command line. The multi-worker load
|
||||
// balancer passes the proxy to each worker this way (issue #366).
|
||||
@@ -357,9 +364,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("{} worker processes", workers);
|
||||
run_multi_worker_serve(port, host, workers, proxy, stealth, user_agent).await?;
|
||||
} else {
|
||||
obscura_cdp::start_with_full_serve_options(
|
||||
obscura_cdp::start_with_serve_options_and_limit(
|
||||
port, &host, proxy, stealth, user_agent, allow_file_access, storage_dir,
|
||||
args.allow_private_network,
|
||||
args.allow_private_network, max_connections,
|
||||
).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user