Repository navigation
Description
A turnloop-backed Perry server stops accepting at 2,048 concurrent connections. The 2,049th is refused. On the same machine, the same fixture on the tokio path reaches 10,000 without difficulty.
Measured
Bench mini (M1, macOS 26.5.1), RLIMIT_NOFILE 200,000 on both client and server, scripts/turnloop/apps/node_http_hello.ts compiled by each arm, opening 10,000 idle keep-alive connections:
| connect batch size | turnloop: opened | failed |
|---|---|---|
| 1 (fully sequential) | 2,048 | 7,952 |
| 64 | 2,061 | 7,939 |
| 512 | 2,076 | 7,924 |
Batch size is irrelevant, which rules out listener backlog and SYN-queue effects — 2,048 is a ceiling, not a rate limit. The tokio arm opened 10,000 of 10,000 in the same harness.
Cause
crates/perry-runtime/src/event_pump/agent_loop.rs, net_config():
fn net_config() -> Config {
Config {
max_handles: 4096,
max_operations: 8192,
...A connection costs two handles, so 4,096 handles is 2,048 connections plus the listener. The constant is hardcoded, chosen when the profile was written for P1's socket work, and nothing re-reads it.
Two things make it worse than a tuning mistake:
- It cannot be raised at runtime.
Configis fixed at loop construction and a loop cannot grow in place (A loop's Config cannot grow in place; recreating the loop drops in-flight pool completions turnloop#43); recreating the loop closes itsWorkPortand drops in-flight completions. So there is no "grow when full" path available today. - The failure is a refusal, not backpressure. The server simply stops accepting. A client sees a connection error, not a slow accept.
Why it matters
This is a hard ceiling on every turnloop-backed node:http/node:https server, and 2,048 concurrent connections is well inside what a real service sees. It is the one place where the turnloop path is strictly worse than the tokio path it replaces, and it would be discovered in production rather than in review.
Options, none of them free
- Raise the constant. One line. But turnloop's baseline RSS is already 41 MB against tokio's 20 MB on this fixture, and the preallocated tables are part of that, so a large raise costs resident memory on every process including ones that never open a socket.
- Size it from a limit that already exists, e.g. from
RLIMIT_NOFILEat loop construction, so a process configured for many descriptors gets a table to match. - Grow the table on demand, which needs A loop's Config cannot grow in place; recreating the loop drops in-flight pool completions turnloop#43 first.
The measured tradeoff should decide it: turnloop costs 11,993 bytes per idle connection against tokio's 30,864 (−61 %), so it wins per connection but starts ~20 MB higher. A bigger table shifts that crossover.
Provenance
Found by the P0–P7 A/B on the quiet bench host, turnloop/integration at 4e4ca6a186 versus main at 6bab431189. The same run measured +24 % to +26 % throughput and −12 % to −22 % latency for turnloop at every concurrency level tested, so this is the exception in an otherwise favourable comparison — which is exactly why it should not be allowed to go unnoticed.
Activity
deps: take turnloop 0.1.0-alpha.5 and raise the handle ceiling to 65,536
proggeramlug commentedon Oct 12, 2026
Fixed in the turnloop migration (#10354). net_config() in event_pump/agent_loop.rs now sets max_handles to 65_536 and the comment there cites this issue.