* Remove ss_event and the C resolver shim
Each program's loop, signals, plugin watcher and stats timer now go
through ss::event::Program (src/event/program.hpp), held on main()'s
stack; ss-manager uses it too. ss-server resolves the manager address
with the relay resolver. With no C caller left, ss_event.cpp/.h,
resolv.cpp/.h and their C tests go; test_resolver_cpp.cpp replaces the
resolver tests against the same delayed local DNS server, and ss_ev is
now an interface target over libuv.
* Reinitialize the event loop after daemonize()
The loop is created before the programs fork into the background, and
kqueue/epoll descriptors do not survive fork(), so ss-server -f (which
ss-manager always uses) died as soon as it ran. Call uv_loop_fork() in
the child.
* Move the C codecs to tests/reference and drop uthash from ss-manager
The programs now use only the C++ codecs. The C stream/AEAD/AEAD-2022
implementations stay as the reference the differential tests and the C
unit tests compare against. Key derivation and the cipher tables used by
the doc snippets move to src/crypto/keys.{hpp,cpp}.
ss-manager keeps its servers in an ss_list (at most MAX_PORT_NUM,
looked up only on control messages) instead of a uthash table, and
tests/test_manager.py covers add/ping/list/remove end to end.
* Replace uthash and ss_list with the C++ containers
The C cache (now used only by the reference codecs) moves to
tests/reference and src/uthash.h is deleted. ACL rules and ss-manager's
servers are ListNodes on IntrusiveLists; destroying an element unlinks
it, so the free paths no longer have to remember to. core.h keeps only
ss_ip. test_rule is ported to C++ and also covers literal rules.
* Describe the C++20/libuv build in the Debian and snap metadata
* Keep clang-tidy clean in the new C++ tests
* Add --max-dns-queries / max_dns_queries
Sizes the resolver's query pool, the last of the capacity settings the
no-heap-after-startup design calls for. Defaults are unchanged: the
connection limit (plus 64 on ss-server, which also resolves for its
statistics).
* Report the C++20 rewrite: allocation, size, throughput and memory
docs/performance.md compares master (C) with the C++ engine on macOS
arm64: zero heap allocations per connection or datagram (from 27-89),
TCP throughput within noise, and about 20 MiB more RSS at 1,000 open
connections from the inline per-connection buffers. docs/cpp-core.md
and CLAUDE.md now describe the finished layout.
* Fix the MinGW build of ss-server: stat_resolver is POSIX-only
* Fix the fully static image and the build image for the C++ sources
docker/static: the C++ unit tests link the static libstdc++, whose error
paths need an unwinder that --rtlib=compiler-rt does not provide; link
llvm-libunwind. The programs themselves still need no C++ runtime.
docker/build: build in the default bundled mode with CMake instead of
against libev/udns (Alpine's Mbed TLS 4 is not supported by system
mode).
* Fall back to the system heap when a library realloc cannot be served
heap_realloc returned NULL when a block outgrew the largest size class or
the budget, and libuv aborts when growing its watcher array fails: a poll
on fd 8190 or above (about 4,000 connections) killed the process.
Move the block to the system heap instead, as malloc and calloc already
do. test_heap_guard_cpp polls fd 16380 with the heaps installed.
* Leave pool room for UDP sessions that are still closing
An evicted session keeps its pool slot until its socket's close callback
runs, so with pool capacity equal to the table's the acquire after an
eviction failed: a full table dropped new-session packets after evicting
a live session for nothing. The AEAD-2022 shared-session pool had the
same problem with entries still referenced by relay sessions.
* Time client-side TCP connections when idle, not only while connecting
ss-local, ss-tunnel and ss-redir only timed the upstream connect (as the
C relays did), so a client that never finished its SOCKS handshake, or a
stream whose peer vanished without a FIN, held a pool slot forever. With
the pool now a hard cap that pauses accepting when full, enough of them
stopped the listener for good. Every connection now starts with the idle
deadline, connected() re-arms it, and traffic refreshes it for all kinds
(the idle timeout keeps its 24 h floor, MIN_TCP_IDLE_TIMEOUT).
* Include <algorithm> for std::min in crypto/keys.cpp (CLANG64)
* test.py: accept host:port for --dns
The tunnel test hard-coded port 53, so it could only use a public
resolver or root. With a port it runs against a local DNS stand-in.
* Use the Mbed TLS compat wrappers for MD5 key derivation
* Fix the documentation build after moving the codecs
utils.cpp's \snippet aead.cpp and stream.cpp now live in src/crypto/,
which was not on Doxygen's EXAMPLE_PATH, so the man1 and HTML builds
failed. List the documentation sources explicitly instead of guessing
their extension.
8.8 KiB
C++20 core and allocation policy
Status: all ten phases are done. Every module of src/ is C++20. The four
relaying programs run on one TCP engine and one UDP engine (src/relay/)
built from the ownership and allocation primitives in src/core/, the libuv
wrappers in src/event/ and value-type codecs in src/crypto/. On the data
path they make no heap allocations (see performance).
The original C codecs and cache are kept only as test oracles in
tests/reference/.
Goals
- Move
src/from C11 to C++20, one module at a time, with every step keeping the CLI, the JSON configuration, protocol behaviour and the publicshadowsocks.hC ABI unchanged. - Make ownership explicit. Each object has exactly one owner, a
std::unique_ptr. Non-owning references are generation-checked handles that go stale instead of dangling. - No heap allocation after startup. Connections, buffers, cipher contexts,
DNS queries and UDP sessions come from fixed-capacity pools reserved once
at startup. The steady-state data path does not call
malloc. When a pool is full, new work is refused rather than allocated.
Language profile
- C++20,
-fno-exceptions -fno-rtti -fno-threadsafe-statics, warnings as errors. Errors are returned asss::Result<T>(src/core/result.hpp), which implements a subset ofstd::expected. - Only header-only parts of the standard library are used (
<array>,<span>,<optional>,<variant>,<memory>'sstd::unique_ptr). This keeps the binaries and the static library free of libstdc++/libc++ symbols, so C consumers oflibshadowsocks-c.ado not need a C++ runtime. scripts/check_no_heap.py(a ctest) rejectsnew,malloc, allocating containers,std::string,std::functionandstd::shared_ptrin C++ sources undersrc/. A startup reservation that really must allocate is marked// heap-ok: <reason>.- blake3 (
src/blake3) andthird_party/bloomstay C. - Minimum compilers: GCC 11, Clang 14, Apple Clang 15.
Primitives (src/core/)
| Header | Purpose |
|---|---|
result.hpp |
Result<T>, Status, Errc, SS_TRY. |
region.hpp |
Region, the only startup calloc in the core. Also ASan poison helpers. |
pool.hpp |
ObjectPool<T>, PoolPtr<T> (std::unique_ptr with a pool deleter), Handle<T>. Types that embed libuv handles model AsyncClosable: their slot is recycled only after the asynchronous close finishes. |
ref.hpp |
RefCounted<T>/RefPtr<T>, intrusive shared ownership for the few genuinely shared objects (AEAD-2022 UDP sessions). Used instead of std::shared_ptr because its control block cannot be pooled portably. |
buffer.hpp |
FixedBuffer<N>: inline bytes with std::span views; replaces buffer_t. |
arena.hpp |
MonotonicArena for configuration data that lives as long as the process. |
fixed_map.hpp |
FixedHashMap (open addressing, backward-shift deletion) and FixedLru; replace uthash and cache.cpp. |
intrusive_list.hpp |
IntrusiveList/ListNode; replaces ss_list. Nodes unlink themselves when destroyed. |
fixed_heap.hpp |
Bounded size-class allocator to hand to libuv, c-ares and Mbed TLS through their allocator hooks. |
heap_guard.hpp |
install_library_heaps() routes libuv, c-ares and Mbed TLS into their own FixedHeaps (executables only, never the library); heap_seal() marks the end of startup; log_heap_report() prints heap use with -v. |
Event layer (src/event/loop.hpp)
Loop, Poll, Timer, Signal and Async wrap libuv handles that are
embedded by value in their owner and call a member function of the owner
chosen at compile time (declare that member before the handle). A Poll
owns its socket and carries both read and write interest on one libuv
handle; a handle closed from another callback gets no further events.
Handles close into a CloseGroup, which signals completion after the last
asynchronous close, so pooled owners implement AsyncClosable::begin_close
by closing their handles into one. DeadlineList drives the idle timeouts
of many owners from one timer. program.hpp holds what every main()
shares: the loop, SIGINT/SIGTERM (and SIGCHLD when a plugin runs), periodic
callbacks, a cross-thread request_stop(), and after_fork() for
daemonize().
Relay engines (src/relay/)
| File | Purpose |
|---|---|
tcp.hpp |
TcpRelay: listeners, a pool of max_connections connections, one idle-deadline timer. Kind selects the handshake (tunnel, SOCKS5 local, redir, server). A full pool pauses accepting until a slot frees. |
udp.hpp |
UdpRelay: sessions in a FixedLru over a pool of max_udp_sessions, one 64 KiB scratch buffer per relay. AEAD-2022 server sessions are RefPtr-shared between the session table and their client sockets. |
codec.hpp |
Method (immutable, from the configuration) and per-connection Encoder/Decoder variants: plain, stream, AEAD, AEAD-2022. |
resolver.hpp |
c-ares with a pool of max_dns_queries queries; answers reach their connection through a generation-checked handle, so a closed connection is never called back. |
socket.hpp |
Upstream socket setup, TCP Fast Open, SOCKS address encoding. |
Per-binary behaviour (TOS per redir port, connmark-to-TOS, nftables, manager statistics, Android protect) plugs in through hooks on the configs, so the engines do not link it.
Crypto (src/crypto/)
aead.hpp (SIP004/SIP007 AEAD), aead2022.hpp (SIP022) and stream.hpp
(legacy stream ciphers) provide value-type codecs: the cipher state is held
inside the codec, TCP decoders reassemble into a fixed buffer sized for one
chunk and can hand plaintext out in windows of any size, and HKDF-SHA1 runs
on stack SHA-1 state. keys.hpp derives keys and lists the supported
methods. All codecs are tested byte for byte against the C reference
implementation in tests/reference/. The bundled Mbed TLS enables
MBEDTLS_BLOCK_CIPHER_C so GCM key setup does not allocate; with a system
Mbed TLS lacking it, mbedtls_gcm_setkey may still allocate.
Capacities
| Setting | Default | Pool |
|---|---|---|
--max-connections / max_connections |
1024 | TCP connections, about 104 KiB each |
--max-udp-sessions / max_udp_sessions |
256 (ss-server 512) | UDP associations; the least recently used is dropped when full |
--max-dns-queries / max_dns_queries |
the connection limit (ss-server +64) | DNS lookups in flight |
Pools are reserved at startup and only touched slots become resident.
Phases
- Build and core: C++ in CMake,
src/core/, the no-heap lint, the C++ runtime check and the allocation audit. - Mechanical C-to-C++ conversion, leaf modules first.
scripts/no_heap_baseline.txtratchets down the remaining startup allocations in converted modules. - Event layer over libuv (
src/event/). - Crypto codecs held by value: AEAD, stream and AEAD-2022.
- Services: the cache on a fixed slab,
IntrusiveListfor ACL rules and the manager, the pooled resolver. - One TCP engine for ss-tunnel, ss-local, ss-redir and ss-server.
- One UDP engine.
- Library heaps for libuv, c-ares and Mbed TLS,
heap_seal(), and the capacity settings above. - Public API:
shadowsocks.hunchanged, plusshadowsocks/control.h(ss_local_start/stop/wait/destroy) and the RAIIshadowsocks/shadowsocks.hpp. - Removal of
ss_event, the C resolver, the C codecs (now test oracles), uthash andss_list.
Allocation audit
tests/alloc_audit/alloc_counter.c is a preloadable malloc counter
(glibc and macOS). tests/alloc_audit/measure_alloc.py warms ss-local and
ss-server up, snapshots their counters, relays more connections and reports
heap allocations per connection; --udp measures datagrams instead and
--target picks the destination. The alloc_steady_programs_* ctests
require zero for each method family over TCP and UDP.
python3 tests/alloc_audit/measure_alloc.py --bin build/bin \
--shim build/lib/libss_alloc_audit.dylib --method aes-256-gcm
C baseline (before the rewrite)
Commit 553cdc7, macOS arm64, bundled Release build, 200 connections, each
echoing 16 KiB through ss-local and ss-server on loopback. The figures
include allocations made inside libuv, c-ares and Mbed TLS. ss-server
resolves the destination (localhost) for every connection, so its count
includes a DNS lookup.
| Method | ss-server allocations/connection | ss-local allocations/connection |
|---|---|---|
| aes-256-gcm | 89.1 | 39.2 |
| chacha20-ietf-poly1305 | 83.1 | 33.0 |
| 2022-blake3-aes-256-gcm | 84.0 | 33.0 |
| aes-256-cfb (stream) | 77.1 | 26.5 |
| aes-256-gcm, 1-byte payload | 85.0 | 36.0 |
The C++ programs are at 0 for every row, including the name-resolving ss-server case; see performance for the full comparison.
Unstripped Release sizes at the same commit: ss-local 1,086,888 bytes, ss-server 1,086,952 bytes, ss-tunnel 542,200 bytes.