scripts/ci/c100k_https.sh checks that one CWIST HTTPS server holds and
serves 100,000 concurrent TLS connections. CI runs it in
.github/workflows/c100k.yml.
- Server:
tests/rss_gate_serveron the normalcwist_app_listen()path with 4 prefork workers and a throwaway ECDSA P-256 certificate.CWIST_HTTPS_IDLE_TIMEOUT_MSis set to 600000 so the idle budget outlasts the ramp and the hold. - Client:
tests/c100k_https_client, plain OpenSSL + epoll, 4 threads, at most 512 handshakes in flight per thread.
The test has three phases:
- Ramp. Open 100,000 connections (TCP and TLS 1.3 handshake). All of them stay open.
- Hold. Leave every connection idle for 5 s. The server must not close any of them. Idle HTTPS connections live in the park set.
- Rounds. Two keep-alive rounds. Each round writes one
GET /on every connection before it reads any response. Every response must be a 200 with the expected body.
PASS requires all of the following:
- 100,000 connections established.
- No connection dropped.
- Every request answered in every round.
- The ramp finishes within 180 s.
- Server memory with all connections open stays at or below 8 KiB per connection, measured above the idle baseline. (The limit was 16 KiB until parked connections released their read buffer; see below.)
Each limit can be overridden with an environment variable. The variables are listed in the script header.
The client spreads connections over 127.0.0.1 to 127.0.0.4. The server binds
0.0.0.0. Each destination address has its own ephemeral-port space, so
the default Linux range (32768 to 60999, about 28k ports) gives about 112k
ports across the four addresses.
With only two addresses and the ports nearly used up, the ramp took 55 s
instead of 18.5 s. As the free ports run out, connect() takes longer to
find one. The slowdown came from the client side, not from the server.
Local machine, 12 cores, C1M reactor mode (default), HTTPS gate server
tests/rss_gate_server (4 prefork workers) driven by
tests/c100k_https_client through scripts/ci/c100k_https.sh:
| Scale | Established | Dropped idle | Round 1 | Round 2 | Ramp | Memory / open conn |
|---|---|---|---|---|---|---|
C10K (C100K_CONNS=10000) |
10000 | 0 | 10000 ok, 64.0k rps | 10000 ok, 115.9k rps | 1.97 s | 5906 B |
| C100K (default) | 100000 | 0 | 100000 ok, 73.0k rps | 100000 ok, 106.4k rps | 18.14 s | 4975 B |
Both runs PASS every gate criterion (ramp under 180 s, no dropped connection, every request answered, memory at most 8192 B per open connection). The per-connection figure is consistent with the ~5 KB parked-connection cost measured 2026-10-11 after parked connections started releasing their 16 KiB read buffer.
Same server, driven by 8 c100k_https_client processes x 125,000
connections through scripts/ci/c1m_local.sh inside an unprivileged
user+network namespace (unshare -Urn), clients linked against the
repo's BoringSSL:
| Scale | Established | Dropped idle | Ramp | Rounds | Server peak |
|---|---|---|---|---|---|
| C1M (8x125K, TLS 1.3) | 1,000,000 | 0 | 328-341 s per client | all 2,000,000 answered, 0 failed | 4.56 GiB (4866 B/conn) |
The ramp is TLS-handshake-bound, not server-bound: ~2,900 full P-256
handshakes/s across the 8 clients while 12 cores are shared with the
server, so opening 1M connections takes ~340 s (C100K: 18 s). Every
client reported 125000 ok in both keep-alive rounds. The server holds
~4.9 KB per open TLS connection — the same parked-connection cost as
C100K. The whole client fleet resident-uses ~7 GiB thanks to BoringSSL
honoring SSL_MODE_RELEASE_BUFFERS (the OpenSSL build idles at ~4.2 GiB
per 125K-connection client; see below).
| Mode | Machine | Established | Dropped | Round 1 | Round 2 | Ramp | Memory / open conn |
|---|---|---|---|---|---|---|---|
| C1M (default) | local, 12 cores | 100000 | 0 | 100000 ok, 71.1k rps | 100000 ok, 110.2k rps | 19.4 s | 13189 B |
Classic (CWIST_C1M_MODE=0) |
local, 12 cores | 100000 | 0 | 100000 ok, 74.9k rps | 100000 ok, 113.0k rps | 19.3 s | 13218 B |
Full GC (CWIST_TEST_FULL_GC=1) |
local, 12 cores | 100000 | 0 | 100000 ok, 60.9k rps | 100000 ok, 107.4k rps | 19.7 s | 13208 B |
| C1M / classic / full GC | GitHub runner, 4 cores | 100000 | 0 | 100000 ok, ~33k rps | 100000 ok, ~52-56k rps | ~33.8 s | ~13.2 KB |
Total server memory is 32 MB idle and about 1.3 GB with 100k connections
open, across the master and 3 forked workers. With CWIST_WORKERS=4 the
master serves connections too.
The table above was measured before parked connections released their read buffer. Since then, the gate measures ~5.0 KB per open connection locally (C1M 4982 B, full GC 5064 B, 2026-10-11). Total server memory with 100k connections open dropped from ~1.3 GB to ~0.52 GB.
- Before the change, the connection's 16 KiB
read_bufstayed allocated while the connection was idle in the park set.cwist_https_park()now frees it when nothing is buffered, andcwist_https_receive_request()allocates a new one when the connection is served again. - The remaining ~5 KB is the TLS session state (BoringSSL
SSLobject) plus the conn, park entry and allocator overhead.
An earlier local run reported ~3.4 KB per connection. That run was wrong. A
server left over from a previous run was still listening on the same port
with SO_REUSEPORT, so it received part of the connections. The script now
fails at start if the port already answers. It also counts swapped-out pages
together with RSS.
Five limits gate a 1M-connection HTTPS run, all outside CWIST. The first
two are why c1m_local.sh self-configures its namespace; the last three
were found by failing at each of them in turn:
- conntrack table. Every loopback flow occupies an entry wherever
conntrack is active. The host's
nf_conntrack_maxof 262144 froze one 2026-10-17 attempt at exactly 262090 connections (the rest of the table was unrelated traffic), recycling at ~180/s as entries timed out. A fresh network namespace has no conntrack hooks at all (nothing references it), sounshare -Urnsidesteps the table entirely without touching the host. - Ephemeral port range. A fresh network namespace starts from kernel
defaults:
ip_local_port_rangeis 32768-60999. With three destination addresses per client that caps a client at exactly 3 x 28232 = 84696 connections — every furtherconnect()fails with EADDRNOTAVAIL, and the client burns the rest of its ramp deadline retrying. The script widens the range to 1024-65535 itself (written via/proc/sys, becausesysctl(8)lives in/usr/sbin, off a bareunsharePATH). - Per-client address isolation. With all clients spreading over the
same 127.0.0.1-3 addresses, their ephemeral-port ranges collide in the
kernel 4-tuple space and the ramp deadlocks around ~180k total
connections: client
connect()burns kernel time retrying, there are no SYN retransmits, and the server sits idle. Giving each client its own loopback /16 (127.0.<client+1>.1-3) removes the overlap, because the destination address is part of the 4-tuple. - SIGPIPE in the load client. When the server closes any connection
under extreme load, the client's next request write raises SIGPIPE and
the default action kills the whole client — observed as silent deaths
(exit 141, empty log) at 500k+ connections.
c100k_https_clientnow ignores SIGPIPE so the failed write is counted as a round failure. - Client fleet memory. An idle TLS connection costs the server ~5 KB
but the client keeps two 16 KiB buffers per connection — unless the
TLS stack honors
SSL_MODE_RELEASE_BUFFERS. BoringSSL (what the repo vendors and the server uses) does; system OpenSSL does not, inflating an idle 125K-connection client from ~0.9 GiB to ~4.2 GiB. For 8x125K the fleet needs ~7 GiB linked against BoringSSL versus ~34 GiB against OpenSSL. Build the BoringSSL client with:gcc -O2 -o c100k_bssl_client tests/c100k_https_client.c \ -Ilib/boringssl/include lib/boringssl/build/libssl.a \ lib/boringssl/build/libcrypto.a -lpthread -ldl -lstdc++
scripts/ci/c1m_local.sh automates the 8x125K run: per-client loopback
/16s, namespace self-configuration, server RSS+swap sampling, and
per-client gate accounting. Example:
make rss_gate_server c100k_https_client
unshare -Urn bash -c '
ip link set lo up
C1M_CLIENT_BIN=./c100k_bssl_client scripts/ci/c1m_local.sh
'On a host with less free RAM, stagger the client starts so at most one
ramp is in flight (C1M_STAGGER_S=60) and keep earlier clients' sockets
open with C1M_LINGER_S until the last client finishes; both trade wall
time for peak memory.
make rss_gate_server c100k_https_client
scripts/ci/c100k_https.sh # C1M
scripts/ci/c1m_local.sh # C1M scale: 8x125K clients
CWIST_C1M_MODE=0 scripts/ci/c100k_https.sh # classic
CWIST_TEST_FULL_GC=1 scripts/ci/c100k_https.sh # full GCThe script needs a hard RLIMIT_NOFILE of at least 104096. The client and
the server each hold one fd per connection, in separate processes. CI raises
the limit with prlimit. It also widens ip_local_port_range and raises
somaxconn.