Lower memory use and faster connection handling
This release addresses the memory growth reported in
#97 and
#113. The
leak-shaped growth was not an unbounded Rust collection: most of it came from
memory reserved and retained independently by every active connection. A busy
proxy multiplied large async state machines, WebSocket buffers, duplicate
MTProto packet storage, short-lived strings, and relay tasks by the number of
connected clients.
The optimization keeps the connection pool and media-specific warm sockets.
Those are important for media latency and have not been disabled, merged, or
made smaller. Instead, this release reduces the cost of each live client and
makes pool maintenance more efficient.
Smaller session state
- Route selection now runs in a short-lived boxed future. Once an upstream
route wins, synchronous dispatch creates only the selected WebSocket,
upstream MTProto, or raw-TCP bridge. The complete fallback ladder is freed
before the session-long relay await instead of remaining embedded in every
client future. - A regression test caps the outer per-client future at 4 KiB. On x86-64 its
size fell from 20,784 to 1,808 bytes, a 91.3% reduction in the fixed async
state allocated for every accepted client. - Client addresses remain allocation-free
SocketAddrvalues through routing
and bridging. Direct-pool lookup borrows the configured DC IP, and only a
refill that actually reserves a bucket creates an owned target string. A
1,000-request burst no longer creates 999 throwaway copies for refill tasks
that have no work to do.
The complete release, compared with main, reduces peak private memory from
257.9 MiB to 118.1 MiB for 1,000 held direct-WebSocket clients (-54.2%). Proxy
CPU time in the same workload falls from 1.53 s to 1.19 s (-22.2%).
Bounded WebSocket buffering and single-pass MTProto framing
- Incoming WebSocket frames and messages are limited to 4 MiB instead of
tungstenite's 16 MiB frame and 64 MiB message defaults. Telegram media parts
are at most 1 MiB, leaving protocol headroom without allowing one unusual
upstream frame to reserve tens of megabytes for a connection. - The WebSocket write-buffer target is 16 KiB instead of 128 KiB. Every bridge
send is awaited and flushed, so the larger target did not provide useful
batching in this proxy. - MTProto WebSocket framing now reads plaintext packet-length headers before
relay encryption. The splitter no longer decrypts the complete upload a
second time and no longer retains parallel plaintext and ciphertext buffers.
Once a packet is complete, its encrypted allocation moves directly into the
WebSocket message. - Cloudflare Worker remains deliberately different: it tunnels a raw TCP byte
stream and does not packet-align WebSocket messages. This preserves uploads
whose MTProto packets exceed Cloudflare's 1 MiB WebSocket-message limit.
In the final Linux comparison, 64 simultaneous direct-WebSocket media clients,
each sending eight 1 MiB packets plus two smaller packets in both directions,
reduced peak private memory from 308.2 MiB to 221.2 MiB (-28.2%) and proxy CPU
from 3.57 s to 3.06 s (-14.3%). Every returned byte was checked.
Cheaper full-duplex relays
- Traffic counters accumulate in task-local
u64values and publish once when
a direction finishes or is cancelled. This removes a mutex acquisition from
every relay chunk while preserving accurate partial totals on 32-bit targets
where 64-bit atomics are unavailable. - TCP streams use Tokio's specialized owned read/write halves. This removes the
shared mutex used by generictokio::io::splitfrom inbound clients,
upstream MTProto proxies, TCP fallback, and the connectivity checker. - A bridge now downloads in the existing client task and spawns only the
upload direction. This removes one Tokio task per active session while
preserving simultaneous upload/download, WebSocket backpressure, partial
traffic totals, and immediate cancellation when either peer closes.
Incremental testing of the owned TCP halves reduced direct-WebSocket proxy CPU
by 2.7% and raw-TCP wall time by 2.1%. Removing the second bridge task reduced
held raw-TCP memory peaks by 3.5%. In the cumulative final comparison, 1,000
held TCP-fallback clients use 10.7% less private memory. Under a heavy TCP
media transfer, private memory is 7.0% lower; the 0.7% CPU and 2.2% wall-time
differences are within run-to-run noise.
Parsed once, borrowed per connection
- Immutable inbound and upstream MTProto secrets are decoded once and stored
in ready-to-use binary form. Client handshakes borrow cached keys and FakeTLS
hostnames instead of repeating hex decoding and allocations. - Standard and configured
NO_PROXYrules are parsed once. Matching uses
borrowed host/port values instead of constructing and parsing a URL for every
outbound connection. - Direct TCP, SOCKS5, SOCKS5h, and ordinary HTTP CONNECT paths keep target
hostnames borrowed unless a protocol frame must own the bytes. - FakeTLS handshakes build their output in one allocation, read the inbound
initialization into an exact-size record, and consume coalesced pending data
by offset rather than repeatedly moving the remaining bytes.
With 1,000 held upstream-FakeTLS clients, the final build uses 48.9 MiB of
private memory instead of 59.8 MiB (-18.2%). With inbound FakeTLS and 64 extra
configured secrets, private memory falls from 260.6 MiB to 117.6 MiB (-54.9%)
and CPU from 1.33 s to 1.08 s (-18.8%).
Pool latency without a larger pool
- Direct and Cloudflare refills reserve capacity in their bucket before
spawning. A 1,000-client burst against one bucket now creates one refill
task instead of 1,000 tasks of which 999 wake up only to discover that the
bucket is already full. - Direct-pool fill uses tightly bounded concurrency: two handshakes per bucket
and two startup buckets, for at most four in-flight handshakes regardless of
configured pool size. This avoids an unbounded TLS/DNS/socket burst. - Normal and media pools remain separate,
--pool-sizekeeps the same meaning,
and all expected spare connections are still created and replenished.
With a deterministic 250 ms handshake delay, the median time to prepare four
spares fell from 1,138 ms to 256 ms (-77.5%) over seven interleaved Linux runs.
Peak private memory during warmup fell from 5.57 MiB to 5.31 MiB (-4.6%); the
steady-state number of sockets is unchanged.
Lazy Cloudflare fallback
- Cloudflare fallback creates only the hostname for the attempt it is about to
make. It no longer builds a2 * Nowned-string queue and string-keyed
deduplication set for every client. - Balanced domains rotate as a borrowed cyclic view rather than cloning the
complete domain list. The fallback still preserves media-first ordering and
the intentionalkwsN-1tokwsNretry.
The incremental 65-domain/1,000-client benchmark reduced CPU by 8.2%, peak RSS
by 3.8%, and private memory by 3.9%. Across all optimizations in this release,
the same Cloudflare workload reduces private memory from 264.3 MiB to 118.3 MiB
(-55.2%) and CPU from 1.38 s to 1.09 s (-21.0%).
Cumulative Linux performance
The following table compares the v2.2.4 main baseline at 641c6b6 with the
optimized v2.2.5 code. Both binaries were built with Rust 1.97.1 using the same
release profile and Debian Bookworm container. Each workload used five
interleaved runs; the table reports medians. Memory is sampled from Linux
/proc/<pid>/smaps_rollup, and CPU time from /proc/<pid>/stat.
| Workload | Peak private memory | Change | Proxy CPU | Change | Wall time change |
|---|---|---|---|---|---|
| 1,000 held direct WS sessions | 257.9 -> 118.1 MiB | -54.2% | 1.53 -> 1.19 s | -22.2% | -4.6% |
| 64 direct WS media clients, 8 MiB each way | 308.2 -> 221.2 MiB | -28.2% | 3.57 -> 3.06 s | -14.3% | -1.4% |
| 1,000 held TCP-fallback sessions | 62.7 -> 56.0 MiB | -10.7% | 0.96 -> 0.93 s | -3.1% | +3.7% |
| 64 TCP media clients, 8 MiB each way | 9.2 -> 8.5 MiB | -7.0% | 1.54 -> 1.55 s | +0.7% | +2.2% |
| 1,000 held CF sessions, 65 balanced domains | 264.3 -> 118.3 MiB | -55.2% | 1.38 -> 1.09 s | -21.0% | +4.6% |
| 64 CF Worker media clients, 8 MiB each way | 13.7 -> 12.2 MiB | -11.1% | 2.49 -> 2.57 s | +3.2% | -3.1% |
| 1,000 held upstream-FakeTLS sessions | 59.8 -> 48.9 MiB | -18.2% | 1.01 -> 0.97 s | -4.0% | -1.6% |
| 1,000 held inbound-FakeTLS sessions, 64 extra secrets | 260.6 -> 117.6 MiB | -54.9% | 1.33 -> 1.08 s | -18.8% | -2.6% |
Held-session wall time includes a fixed backend hold and test-process startup,
so differences around 2-5% there are scheduler noise rather than connection
throughput. Likewise, the small TCP/Worker CPU changes are not claimed as
regressions: the Worker workload completed 3.1% faster while using 3.2% more
accounted CPU. The stable result across every workload is lower peak memory;
the direct WS and Cloudflare hot paths also show a clear CPU reduction.
The stripped x86-64 release binary is also 8,728 bytes smaller than main
(4,367,848 -> 4,359,120 bytes, -0.2%).
Comparison with v1.7.1
The loopback harness also compared the final build with v1.7.1 using fake
Telegram TLS/WebSocket and HTTP CONNECT endpoints. It waits for every upstream
handshake and verifies that every expected transfer drains before sampling the
result. In identical Linux containers with pooling disabled, two runs in
reversed order produced:
| Workload | v1.7.1 RSS | v2.2.5 RSS | Change |
|---|---|---|---|
| 200 idle WebSocket sessions | 19.87 MiB | 19.00 MiB | -4.4% |
| 200 sessions, four 256 KiB frames each, peak | 83.98 MiB | 79.20 MiB | -5.7% |
| Same transfer after every client drained | 40.42 MiB | 38.02 MiB | -5.9% |
The default --pool-size 4 was measured separately. Both versions retained all
16 expected direct-WebSocket spares: four normal and four media connections for
each of DC 2 and DC 4.
| Default-pool workload | v1.7.1 RSS | v2.2.5 RSS | Change |
|---|---|---|---|
| 16 ready connections, no clients | 6.48 MiB | 6.37 MiB | -1.8% |
| 200 idle sessions plus 16 ready connections | 19.75 MiB | 18.63 MiB | -5.7% |
The post-drain result includes allocator retention and is not a best-case live
heap snapshot. These workloads therefore report process RSS rather than an
allocation profiler's narrower live-byte count.
Functional verification
Both the baseline and optimized Linux release binaries passed the same
byte-exact synthetic E2E matrix:
- direct WebSocket and direct media;
- Cloudflare proxy and Cloudflare Worker media;
- TCP fallback;
- upstream MTProto and upstream FakeTLS;
- inbound FakeTLS;
- abridged, intermediate, and padded-intermediate MTProto transports;
- concurrent media transfers;
- rejected clients, upstream disconnects, and fail-closed behavior;
- normal/media pool separation and refill.
The functional matrix transfers a 1,065,484-byte mixed payload through every
route. The heavier performance workload moves approximately 516 MiB in each
direction across 64 concurrent clients. Payloads are generated independently
for upload and download and compared byte-for-byte after the proxy.
Release and OpenWrt packaging
Cargo.tomlis now the single source for the stable LuCI package version.
The OpenWrt builder reads it automatically and supplies it to every SDK make
invocation; the package recipe no longer carries a version fallback that can
drift during a release bump.- A beta tag such as
v2.2.5-beta.1reuses this base-version release-notes
file. Release CI creates a temporary changelog with the beta heading before
passing it toparse-changelog, so a duplicate
docs/release-notes/2.2.5-beta.1.mdis not required. - Explicit package versions remain supported for the APK
2.2.5_beta1and IPK
2.2.5~beta.1formats generated by beta release CI.
Deliberately unchanged behavior
- Pool caching and the default pool size are unchanged. The release reduces
refill overhead and warmup latency rather than trading away ready media
connections. - The global pool mutex remains. Per-bucket mutexes, fixed async shards, and a
synchronous mutex were prototyped, but Linux A/B results were neutral,
unstable, or regressive. The existing critical sections are short, so adding
idle allocations and complexity had no demonstrated benefit. - Relay buffers remain deliberately small. Router deployments benefit more
from a low per-connection footprint than from avoiding a few extra syscalls.
Compatibility cost
An upstream WebSocket frame or fragmented message above 4 MiB now closes that
upstream connection instead of allocating up to the old limit. This is well
above Telegram's observed 1 MiB media-part ceiling, but a non-Telegram custom
upstream sending larger WebSocket messages is no longer accepted. Cloudflare
Worker uploads are unaffected because that route uses raw-stream framing.