diff --git a/.clang-tidy b/.clang-tidy index 6bb01cef..9b49bceb 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,7 +1,10 @@ # clang-tidy configuration for shadowsocks-c project sources (src/ only). # Bundled sources under third_party/ and src/blake3/ are third-party code and # are excluded by the file regex used in CI: -# run-clang-tidy -p build '/src/[^/]+\.c$' +# run-clang-tidy -p build '/(src/(core/|event/|relay/|crypto/|api/)?[^/]+\.(c|cpp)|tests/test_core_[^/]+\.cpp)$' +# +# The header-only C++ core (src/core/*.hpp) is analysed through the +# test_core_* translation units that include it. # # The check set is intentionally conservative: the full clang static analyzer # plus the bugprone/cert checks that are actionable for this codebase. @@ -33,4 +36,4 @@ Checks: > -clang-analyzer-security.insecureAPI.strcpy, -clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling WarningsAsErrors: '' -HeaderFilterRegex: '.*/src/[^/]+\.h$' +HeaderFilterRegex: '.*/src/([^/]+\.h|core/[^/]+\.hpp)$' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2a25a4f..86fccd73 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: branches: [master] pull_request: branches: [master] + workflow_dispatch: jobs: build: diff --git a/.github/workflows/portability.yml b/.github/workflows/portability.yml index 37eee2bd..452b239e 100644 --- a/.github/workflows/portability.yml +++ b/.github/workflows/portability.yml @@ -94,7 +94,7 @@ jobs: - uses: actions/checkout@v6 - name: Build Clang static-only installation run: | - cmake -S . -B build-static -DCMAKE_C_COMPILER=clang -DSS_DEPENDENCY_MODE=bundled -DWITH_STATIC=ON -DSS_BUILD_STATIC_LIBRARY=ON -DSS_BUILD_SHARED_LIBRARY=OFF + cmake -S . -B build-static -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DSS_DEPENDENCY_MODE=bundled -DWITH_STATIC=ON -DSS_BUILD_STATIC_LIBRARY=ON -DSS_BUILD_SHARED_LIBRARY=OFF cmake --build build-static --parallel 4 ctest --test-dir build-static -L 'unit|vendor' --output-on-failure --no-tests=error cmake --install build-static --prefix "$PWD/install-static" @@ -135,7 +135,7 @@ jobs: mingw-w64-clang-x86_64-python - name: Build Clang static-only installation run: | - cmake -S . -B build-static -G Ninja -DCMAKE_C_COMPILER=clang -DSS_DEPENDENCY_MODE=bundled -DWITH_STATIC=ON -DSS_BUILD_STATIC_LIBRARY=ON -DSS_BUILD_SHARED_LIBRARY=OFF + cmake -S . -B build-static -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DSS_DEPENDENCY_MODE=bundled -DWITH_STATIC=ON -DSS_BUILD_STATIC_LIBRARY=ON -DSS_BUILD_SHARED_LIBRARY=OFF cmake --build build-static --parallel 4 ctest --test-dir build-static -L 'unit|vendor' --output-on-failure --no-tests=error cmake --install build-static --prefix "$PWD/install-static" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fb6fdfae..91e1ee77 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,7 @@ on: branches: [master] pull_request: branches: [master] + workflow_dispatch: jobs: lint: @@ -124,7 +125,7 @@ jobs: run: | set -o pipefail tidy_status=0 - run-clang-tidy-18 -quiet -p build "$PWD/src/[^/]+\.c$" 2>&1 | tee tidy.log || tidy_status=$? + run-clang-tidy-18 -quiet -p build -extra-arg=-Wno-unknown-warning-option "$PWD/(src/(core/|event/|relay/|crypto/|api/)?[^/]+\.(c|cpp)|tests/test_core_[^/]+\.cpp)$" 2>&1 | tee tidy.log || tidy_status=$? grep 'warning:' tidy.log | sort -u > warnings.txt || true count="$(wc -l < warnings.txt)" echo "clang-tidy warnings: $count (max allowed: $MAX_WARNINGS)" diff --git a/CLAUDE.md b/CLAUDE.md index 01b2e909..05e6d4cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,78 +4,82 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -shadowsocks-libev is a lightweight SOCKS5 proxy written in pure C. Version 3.3.6, licensed under GPLv3. +shadowsocks-c (formerly shadowsocks-libev) is a lightweight SOCKS5 proxy. Version 3.3.6, licensed under GPLv3. +The sources are C11 and are being ported to C++20 module by module (see `docs/cpp-core.md`); the public +`src/shadowsocks.h` stays a C header with a stable ABI. ## Build Commands ### CMake (sole build system) ```bash -git submodule update --init --recursive -mkdir -p build && cd build -cmake .. -make -sudo make install +cmake --preset bundled # or: cmake -S . -B build -G Ninja +cmake --build --preset bundled +ctest --preset bundled ``` -On macOS, CMake should auto-detect library paths. If needed, specify paths: -```bash -cmake .. -DCMAKE_PREFIX_PATH="/usr/local/opt/mbedtls;/usr/local/opt/libsodium" -``` +Bundled mode (the default, `SS_DEPENDENCY_MODE=bundled`) builds every dependency from the pinned archives in +`third_party/dist` with no network access. `SS_DEPENDENCY_MODE=system` uses distro libraries instead. +There are no git submodules. -CMake outputs binaries to `build/bin/` (static) and `build/shared/bin/` (shared). +Binaries land in `/bin/`, libraries in `/lib/`. ### Build Dependencies -- cmake (>= 3.2), a C compiler (gcc or clang), pkg-config -- libmbedtls, libsodium (>= 1.0.4), libpcre3, libev, libc-ares -- asciidoc + xmlto (documentation only) +- CMake >= 3.20, C11 and C++20 compilers (GCC >= 11, Clang >= 14, Apple Clang >= 15), Ninja for the presets +- System mode only: Mbed TLS 2.x/3.x, libsodium >= 1.0.4, PCRE2 (8-bit), libuv, c-ares +- Doxygen (documentation only) ### CMake Options -- `-DWITH_EMBEDDED_SRC=OFF`: use system libcork/libipset/libbloom instead of bundled submodules -- `-DWITH_DOC_MAN=OFF`: skip man page generation -- `-DENABLE_CONNMARKTOS=ON`: Linux netfilter conntrack QoS support -- `-DENABLE_NFTABLES=ON`: nftables firewall integration -- `-DDISABLE_SSP=ON`: disable stack protector -- `-DBUILD_TESTING=OFF`: disable unit tests +- `-DSS_MINIMAL=ON`: no regex, plugins, manager or legacy stream ciphers +- `-DWITH_STATIC=OFF`: link third-party dependencies dynamically +- `-DSS_BUILD_EXECUTABLES / SS_BUILD_STATIC_LIBRARY / SS_BUILD_SHARED_LIBRARY`: choose outputs +- `-DWITH_DOC_MAN=ON` / `-DWITH_DOC_HTML=ON`: build documentation +- `-DENABLE_CONNMARKTOS=ON`, `-DENABLE_NFTABLES=ON`: Linux netfilter features +- `-DENABLE_SANITIZERS=ON`, `-DENABLE_COVERAGE=ON`, `-DDISABLE_SSP=ON`, `-DBUILD_TESTING=OFF` ## Testing ### Unit Tests (CTest) ```bash -cd build -ctest --output-on-failure +ctest --test-dir build -LE memcheck --output-on-failure ``` -10 unit test modules cover: base64, buffer, crypto, json, jconf, cache, ppbloom, rule, netutils, utils. +C unit tests (`tests/test_*.c`) compile the specific `src/*.c` files they need (see `ss_add_test` in +`tests/CMakeLists.txt`). C++ core tests are `tests/test_core_*.cpp` using `tests/test_helpers.hpp`. +Labels: `unit`, `vendor`, `integration`, `memcheck` (Linux + valgrind). ### Integration Tests -Integration tests use Python and require `curl` and `dig` to be available: ```bash -bash tests/test.sh +python3 tests/interop.py --self --bin build/bin # TCP/UDP relay, all AEAD/2022 methods +python3 tests/test.py --bin build/bin/ -c tests/aes-gcm.json # one cipher via curl/dig (needs network) +python3 tests/test_cli.py --bin-dir build/bin # CLI contract: help/version/exit codes +python3 tests/stress_test.py --bin build/bin/ --size 10 ``` -The test harness (`tests/test.py`) starts ss-server, ss-local, and ss-tunnel locally, then runs curl through the SOCKS5 proxy and dig through the tunnel. Each test config in `tests/*.json` exercises a different cipher. +### Allocation audit -Run a single cipher test: ```bash -python tests/test.py --bin build/bin/ -c tests/aes-gcm.json +python3 tests/alloc_audit/measure_alloc.py --bin build/bin --shim build/lib/libss_alloc_audit.dylib ``` +Reports heap allocations per relayed connection (C baseline numbers are in `docs/cpp-core.md`). + ## Code Formatting Uses **uncrustify** with the config at `.uncrustify.cfg`. Key settings: 4-space indent, no tabs, 120-column width, K&R brace style (braces on same line). ## Code Quality Tooling -- **clang-tidy**: config in `.clang-tidy` (clang-analyzer + bugprone + cert checks, scoped to `src/`). The build exports `compile_commands.json` automatically. Run locally: +- **clang-tidy**: config in `.clang-tidy`. The build exports `compile_commands.json` automatically. Run locally: ```bash - run-clang-tidy -quiet -p build '/src/[^/]+\.c$' + run-clang-tidy -quiet -p build '/(src/(core/|event/|relay/|crypto/|api/)?[^/]+\.(c|cpp)|tests/test_core_[^/]+\.cpp)$' ``` On macOS with Homebrew LLVM, add `-extra-arg="-isysroot$(xcrun --show-sdk-path)"`. CI (`clang-tidy` job in `tests.yml`) enforces a warning-count ratchet via `MAX_WARNINGS` — lower it when fixing findings; never raise it without justification. +- **No-heap lint**: `scripts/check_no_heap.py --self-test` (also a ctest) rejects heap allocation in C++ sources under `src/`. - **Sanitizers**: `cmake .. -DENABLE_SANITIZERS=ON` builds with ASan + UBSan. CI runs ctest and the stress test under sanitizers on every PR. - **Coverage**: `cmake .. -DENABLE_COVERAGE=ON`, run tests, then `make coverage` (needs lcov). HTML report lands in `build/coverage-html/`. CI uploads it as the `coverage-html` artifact. @@ -91,44 +95,41 @@ Each binary is compiled with a module define that controls conditional compilati | `ss-server` | `MODULE_REMOTE` | Server-side proxy | | `ss-tunnel` | `MODULE_TUNNEL` | Port forwarding tunnel (implies `MODULE_LOCAL`) | | `ss-redir` | `MODULE_REDIR` | Transparent proxy via iptables (Linux only, implies `MODULE_LOCAL`) | -| `ss-manager` | `MODULE_MANAGER` | Multi-server manager daemon | +| `ss-manager` | `MODULE_MANAGER` | Multi-server manager daemon (Linux only by default) | -A shared library `libshadowsocks-libev` is also built from the ss-local sources with `-DLIB_ONLY`. Its public API is in `src/shadowsocks.h`. +A library `libshadowsocks-c` (legacy names `libshadowsocks-libev.*` are kept) is built from the ss-local sources with `-DLIB_ONLY`. Its public API is in `src/shadowsocks.h`. ### Source Organization (`src/`) **Shared by all binaries:** -- `utils.c` - logging, system utilities +- `utils.c` - logging, system utilities, `ss_malloc`/`ss_free` - `jconf.c` / `json.c` - JSON config file parsing - `netutils.c` - network address utilities -- `cache.c` - hash-based LRU connection cache +- `cache.c` - uthash-based connection cache (UDP relay, AEAD-2022 tables) - `udprelay.c` - UDP relay implementation (shared, but uses `#ifdef MODULE_*` for per-binary behavior) +- `ss_event.c` / `ss_event.h` - event loop API over libuv +- `resolv.c` - asynchronous DNS via c-ares -**Crypto layer** (two parallel implementations behind a common `crypto_t` interface): -- `crypto.c` / `crypto.h` - crypto initialization, key derivation (HKDF), buffer management. Defines `crypto_t` with function pointers for encrypt/decrypt. -- `stream.c` - stream cipher implementation (CFB mode via mbedTLS) -- `aead.c` - AEAD cipher implementation (AES-GCM via mbedTLS, ChaCha20-Poly1305 via libsodium) -- `ppbloom.c` - ping-pong bloom filter for nonce replay detection +**Crypto layer** (behind a common `crypto_t` function-pointer interface): +- `crypto.c` / `crypto.h` - crypto initialization, key derivation (HKDF), `buffer_t` management +- `stream.c` - legacy stream ciphers +- `aead.c` - AEAD ciphers (AES-GCM via Mbed TLS/libsodium, ChaCha20-Poly1305 via libsodium) +- `aead2022.c` / `aead2022_udp.c` - Shadowsocks 2022 (SIP022), BLAKE3 from `src/blake3` +- `ppbloom.c` - ping-pong bloom filter for salt replay detection (`third_party/bloom`) -**ACL (Access Control Lists):** -- `acl.c` / `rule.c` - IP/domain-based routing rules using libipset +**ACL:** `acl.c` / `rule.c` / `ipset.c` - IP prefix sets and PCRE2/literal domain rules. -**Plugin support:** -- `plugin.c` - SIP003 plugin subprocess management +**Plugins:** `plugin.c` / `process.c` - SIP003 plugin subprocess management. -### Bundled Submodules - -Three git submodules in the repo root (can be replaced with system libs via `-DWITH_EMBEDDED_SRC=OFF`): -- `libcork/` - data structures (dllist, hash-table, buffers) -- `libipset/` - IP set operations for ACL -- `libbloom/` - bloom filter implementation +**C++ core:** `src/core/*.hpp` - header-only ownership and allocation primitives (pools, handles, fixed +buffers, arena, fixed hash map/LRU, intrusive list, bounded heap). See `docs/cpp-core.md`. ### Event Loop -All binaries use **libev** for async I/O. The connection lifecycle follows stages defined in `src/common.h`: `STAGE_INIT` -> `STAGE_HANDSHAKE` -> `STAGE_RESOLVE` -> `STAGE_STREAM` -> `STAGE_STOP`. Each binary defines its own `listen_ctx_t`, `server_t`, and `remote_t` structs (note: "server" in `local.h` means the local-side connection, "remote" means the ss-server side). +All binaries use the `ss_event` API (libuv backend: kqueue/epoll/IOCP). The connection lifecycle follows stages defined in `src/common.h`: `STAGE_INIT` -> `STAGE_HANDSHAKE` -> `STAGE_RESOLVE` -> `STAGE_STREAM` -> `STAGE_STOP`. Each binary defines its own `listen_ctx_t`, `server_t`, and `remote_t` structs (note: "server" in `local.h` means the local-side connection, "remote" means the ss-server side). ### Compiler Flags -Default flags from `CMakeLists.txt`: `-g -O2 -Wall -Werror -Wno-deprecated-declarations -fno-strict-aliasing -std=gnu99 -D_GNU_SOURCE` +From `cmake/ProjectOptions.cmake`: `-Wall -Werror -Wno-deprecated-declarations -fno-strict-aliasing -fstack-protector-strong`, plus `_GNU_SOURCE` on Linux. C is C11 with GNU extensions. C++ is C++20 without extensions and adds `-Wextra -fno-exceptions -fno-rtti -fno-threadsafe-statics`. -The `-Werror` flag means all warnings are errors - new code must compile warning-free. +`-Werror` means all warnings are errors — new code must compile warning-free. diff --git a/CMakeLists.txt b/CMakeLists.txt index e3732e69..ab5b6b5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ set(PROJECT_VERSION "3.3.6") set(PROJECT_DESC "a lightweight secured socks5 proxy") set(PROJECT_URL "https://shadowsocks.org") set(PROJECT_ISSUES_URL "https://github.com/shadowsocks/shadowsocks-libev") -project(${PROJECT_NAME} VERSION ${PROJECT_VERSION} LANGUAGES C) +project(${PROJECT_NAME} VERSION ${PROJECT_VERSION} LANGUAGES C CXX) if(MSVC) message(FATAL_ERROR "MSVC is not supported yet. Use MinGW-w64 (UCRT64) or the Zig Windows toolchain; see docs/modernization.md.") endif() @@ -23,6 +23,13 @@ set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_EXTENSIONS ON) +# The C++20 core (src/core) is being introduced module by module; see +# docs/cpp-core.md. It is built without exceptions or RTTI, so it adds +# no unwinding tables or typeinfo to the static binaries. +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + option(ENABLE_SANITIZERS "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF) option(ENABLE_COVERAGE "Build with coverage instrumentation" OFF) option(SS_WARNINGS_AS_ERRORS "Treat project warnings as errors" ON) diff --git a/cmake/ProjectOptions.cmake b/cmake/ProjectOptions.cmake index cb160b7e..f73366a8 100644 --- a/cmake/ProjectOptions.cmake +++ b/cmake/ProjectOptions.cmake @@ -25,6 +25,10 @@ function(ss_project_options target) endif() else() target_compile_options(${target} PRIVATE -Wall -Wno-deprecated-declarations -fno-strict-aliasing) + # C++ sources: no exceptions or RTTI, and no locks around function + # statics (the event loop is single-threaded). + target_compile_options(${target} PRIVATE + $<$:-Wextra -fno-exceptions -fno-rtti -fno-threadsafe-statics>) if(SS_WARNINGS_AS_ERRORS) target_compile_options(${target} PRIVATE -Werror) endif() diff --git a/cmake/toolchains/zig-windows.cmake b/cmake/toolchains/zig-windows.cmake index 31bad818..59b1a32d 100644 --- a/cmake/toolchains/zig-windows.cmake +++ b/cmake/toolchains/zig-windows.cmake @@ -1,9 +1,10 @@ -# Cross-compile the C project from a Unix host without Docker or a MinGW SDK. +# Cross-compile the project from a Unix host without Docker or a MinGW SDK. # cmake -S . -B build-windows -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/zig-windows.cmake set(CMAKE_SYSTEM_NAME Windows) set(CMAKE_SYSTEM_PROCESSOR AMD64) find_program(SS_ZIG_EXECUTABLE NAMES zig REQUIRED) set(CMAKE_C_COMPILER "${SS_ZIG_EXECUTABLE}" cc -target x86_64-windows-gnu) +set(CMAKE_CXX_COMPILER "${SS_ZIG_EXECUTABLE}" c++ -target x86_64-windows-gnu) # CMake's archive tools accept an executable path, not an argv list. file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/zig-tools") foreach(tool ar ranlib) diff --git a/docker/static/Dockerfile b/docker/static/Dockerfile index fff10cd2..8fab5bc0 100644 --- a/docker/static/Dockerfile +++ b/docker/static/Dockerfile @@ -2,8 +2,8 @@ ARG ALPINE_IMAGE=alpine:3.24 FROM ${ALPINE_IMAGE} AS build ARG ALPINE_MIRROR=https://dl-cdn.alpinelinux.org/alpine RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories \ - && apk add --no-cache clang lld make musl-dev compiler-rt cmake linux-headers python3 binutils file -ENV CC=clang + && apk add --no-cache clang lld make musl-dev compiler-rt llvm-libunwind-static g++ libstdc++-dev cmake linux-headers python3 binutils file +ENV CC=clang CXX=clang++ WORKDIR /src COPY . . ARG SS_MINIMAL=OFF @@ -11,7 +11,7 @@ RUN --network=none cmake -S . -B /build \ -DCMAKE_BUILD_TYPE=Release \ -DSS_DEPENDENCY_MODE=bundled -DWITH_STATIC=ON \ -DSS_BUILD_STATIC_LIBRARY=OFF -DSS_BUILD_SHARED_LIBRARY=OFF \ - -DSS_MINIMAL=${SS_MINIMAL} -DCMAKE_EXE_LINKER_FLAGS="-static -fuse-ld=lld --rtlib=compiler-rt" \ + -DSS_MINIMAL=${SS_MINIMAL} -DCMAKE_EXE_LINKER_FLAGS="-static -fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind" \ -DCMAKE_INSTALL_PREFIX=/out \ && cmake --build /build --parallel 4 \ && ctest --test-dir /build -L 'unit|vendor' --output-on-failure --no-tests=error \ diff --git a/docs/cpp-core.md b/docs/cpp-core.md new file mode 100644 index 00000000..24ecf11e --- /dev/null +++ b/docs/cpp-core.md @@ -0,0 +1,97 @@ +# C++20 core and allocation policy + +Status: phase 1 of an incremental port. The ownership and allocation +primitives in `src/core/` exist and are tested; no program code uses them yet. +All binaries are still built from the C sources. + +## 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 public + `shadowsocks.h` C 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 as `ss::Result` (`src/core/result.hpp`), which + implements a subset of `std::expected`. +- Only header-only parts of the standard library are used (``, + ``, ``, ``, ``'s `std::unique_ptr`). This + keeps the binaries and the static library free of libstdc++/libc++ symbols, + so C consumers of `libshadowsocks-c.a` do not need a C++ runtime. +- `scripts/check_no_heap.py` (a ctest) rejects `new`, `malloc`, allocating + containers, `std::string`, `std::function` and `std::shared_ptr` in C++ + sources under `src/`. A startup reservation that really must allocate is + marked `// heap-ok: `. +- blake3 (`src/blake3`) and `third_party/bloom` stay C. +- Minimum compilers: GCC 11, Clang 14, Apple Clang 15. + +## Primitives (`src/core/`) + +| Header | Purpose | +|---|---| +| `result.hpp` | `Result`, `Status`, `Errc`, `SS_TRY`. | +| `region.hpp` | `Region`, the only startup `calloc` in the core. Also ASan poison helpers. | +| `pool.hpp` | `ObjectPool`, `PoolPtr` (`std::unique_ptr` with a pool deleter), `Handle`. Types that embed libuv handles model `AsyncClosable`: their slot is recycled only after the asynchronous close finishes. | +| `ref.hpp` | `RefCounted`/`RefPtr`, 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`: 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.c`. | +| `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. | + +## Phases + +1. Build and core (this change): C++ enabled in CMake, `src/core/`, unit tests + (`test_core_*`), the no-heap lint and the allocation audit. +2. Mechanical C-to-C++ conversion, leaf modules first. +3. Event loop wrapper over libuv. +4. Crypto: cipher contexts held by value, dispatched with `std::variant`. +5. Services: cache, lists, resolver, ACL and configuration on the new primitives. +6. One TCP relay engine shared by ss-tunnel, ss-local, ss-redir and ss-server. +7. UDP relay engine. +8. Seal the heap after startup; new `max_connections`, `max_udp_sessions` + and `max_dns_queries` settings size the pools. +9. Public API: `shadowsocks.h` kept byte-for-byte, plus a new installed C++ + header with an RAII handle. +10. Remove the C shims. + +## 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. It runs as the `alloc_audit_report` ctest +(report-only for now). It becomes a hard check, with a budget of zero, once +the relay is ported. + +```sh +python3 tests/alloc_audit/measure_alloc.py --bin build/bin \ + --shim build/lib/libss_alloc_audit.dylib --method aes-256-gcm +``` + +### C baseline + +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 | + +Unstripped Release sizes at the same commit: ss-local 1,086,888 bytes, +ss-server 1,086,952 bytes, ss-tunnel 542,200 bytes. diff --git a/docs/modernization.md b/docs/modernization.md index ed8f19f9..d3c8a27c 100644 --- a/docs/modernization.md +++ b/docs/modernization.md @@ -6,12 +6,17 @@ review: https://github.com/shadowsocks/shadowsocks-libev/pull/3050 (merged). ## Compatibility contract -Keep C11 and the existing CLI, JSON configuration, ss:// parsing, SIP003 plugins, +Keep the existing CLI, JSON configuration, ss:// parsing, SIP003 plugins, ACL blacklist/whitelist/outbound precedence, public shadowsocks.h API, legacy stream ciphers, AEAD and SIP022 TCP/UDP behavior in the compatibility profile. Removing regex or legacy ciphers is explicit opt-in, with configuration errors for unavailable functionality. Linux redirection/netfilter remains Linux-only. +The sources are moving from C11 to C++20 module by module, with an explicit +ownership model and no heap allocation after startup. The public +`shadowsocks.h` header stays a C11 header with an unchanged ABI. See +[C++20 core and allocation policy](cpp-core.md). + ## Work and evidence - [x] Baseline: fresh rebuild, unit/integration results, binary/dependency sizes, @@ -44,7 +49,7 @@ The fresh baseline passes all 13 original unit tests. ## Build interface -Requires CMake 3.20+, a C11 compiler, and a build executor. Presets use Ninja; +Requires CMake 3.20+, C11 and C++20 compilers, and a build executor. Presets use Ninja; manual configuration may use another generator. ``` diff --git a/rpm/SPECS/shadowsocks-libev.spec.in b/rpm/SPECS/shadowsocks-libev.spec.in index 66949b7c..845a3502 100644 --- a/rpm/SPECS/shadowsocks-libev.spec.in +++ b/rpm/SPECS/shadowsocks-libev.spec.in @@ -27,7 +27,7 @@ License: GPLv3+ URL: https://github.com/shadowsocks/%{name} Source0: @SOURCE@ -BuildRequires: make gcc pcre-devel asciidoc xmlto automake libtool mbedtls-devel libsodium-devel >= 1.0.4 libev-devel c-ares-devel +BuildRequires: make gcc gcc-c++ pcre-devel asciidoc xmlto automake libtool mbedtls-devel libsodium-devel >= 1.0.4 libev-devel c-ares-devel %if 0%{?suse_version} BuildRequires: libopenssl-devel %else diff --git a/scripts/check_no_heap.py b/scripts/check_no_heap.py new file mode 100644 index 00000000..39fa63b6 --- /dev/null +++ b/scripts/check_no_heap.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Reject heap allocation in the C++ sources under src/. + +The C++ core follows a "no heap after startup" policy (docs/modernization.md): +connections, buffers, crypto contexts and sessions come from fixed pools that +are reserved once at startup. This lint keeps allocating constructs out of +C++ code so the policy cannot erode one convenience at a time. + +Only C++ files (.cpp, .hpp) are checked; the legacy C sources are ported +module by module and keep their ss_malloc calls until then. + +A line that must allocate (a startup reservation) carries a justification: + + void *base = std::calloc(1, size); // heap-ok: startup reservation + +Usage: check_no_heap.py [--root DIR] [--self-test] +""" + +import argparse +import pathlib +import re +import sys + +ALLOW_MARKER = "heap-ok:" + +# (pattern, explanation). Patterns run on code with comments and string +# literals blanked out. +FORBIDDEN = [ + (re.compile(r"\b(?:std::)?(?:malloc|calloc|realloc|aligned_alloc|posix_memalign|strdup|strndup)\s*\("), + "C allocator call"), + (re.compile(r"\bss_(?:malloc|realloc|aligned_malloc|strndup)\s*\("), "ss_* allocator call"), + (re.compile(r"\b(?:std::)?free\s*\("), "free(); memory comes from pools, not malloc"), + # `new T`, `new T[n]`, `::new T` -- but not placement new `new (p) T`. + (re.compile(r"(?])(?:::)?new\s+(?!\()[A-Za-z_:]"), "operator new"), + (re.compile(r"\bdelete(?:\s*\[\s*\])?\s+[A-Za-z_*(]"), "operator delete"), + (re.compile(r"\bstd::(?:make_unique|make_shared|allocate_shared|make_unique_for_overwrite)\b"), + "heap-allocating smart pointer factory; acquire from an ObjectPool"), + (re.compile(r"\bstd::(?:shared_ptr|weak_ptr)\b"), "std::shared_ptr; use ss::RefPtr over a pool"), + (re.compile(r"\bstd::(?:vector|deque|list|forward_list|map|multimap|set|multiset|" + r"unordered_map|unordered_multimap|unordered_set|unordered_multiset)\s*<"), + "allocating container; use std::array, std::span or src/core containers"), + (re.compile(r"\bstd::(?:string|wstring|u8string|stringstream|ostringstream|istringstream|function)\b"), + "allocating type; use std::string_view, a FixedBuffer or a function pointer"), +] + + +def strip_comments_and_strings(text): + """Blank out comments and string/char literals, preserving line breaks.""" + out = [] + i, n = 0, len(text) + while i < n: + c = text[i] + if text.startswith("//", i): + j = text.find("\n", i) + j = n if j == -1 else j + out.append(" " * (j - i)) + i = j + elif text.startswith("/*", i): + j = text.find("*/", i + 2) + j = n if j == -1 else j + 2 + out.append("".join("\n" if ch == "\n" else " " for ch in text[i:j])) + i = j + elif c in "\"'": + j = i + 1 + while j < n and text[j] != c and text[j] != "\n": + j += 2 if text[j] == "\\" else 1 + j = min(j + 1, n) + out.append(c + " " * (j - i - 2) + c if j - i >= 2 else c) + i = j + else: + out.append(c) + i += 1 + return "".join(out) + + +def check_text(text): + """Return a list of (line_number, explanation, source_line) violations.""" + problems = [] + raw_lines = text.splitlines() + code_lines = strip_comments_and_strings(text).splitlines() + for number, (raw, code) in enumerate(zip(raw_lines, code_lines), start=1): + if ALLOW_MARKER in raw: + continue + for pattern, why in FORBIDDEN: + if pattern.search(code): + problems.append((number, why, raw.strip())) + break + return problems + + +def check_tree(root): + src = root / "src" + files = sorted(p for p in src.rglob("*") if p.suffix in (".cpp", ".hpp") and "blake3" not in p.parts) + failures = 0 + for path in files: + for number, why, line in check_text(path.read_text(encoding="utf-8")): + print(f"{path.relative_to(root)}:{number}: {why}: {line}") + failures += 1 + print(f"check_no_heap: {len(files)} C++ files, {failures} violation(s)") + return failures == 0 + + +def self_test(): + bad = { + "int *p = (int *)malloc(4);": True, + "auto *c = new Conn();": True, + "auto *c = ::new Conn;": True, + "delete c;": True, + "std::vector v;": True, + "std::string name;": True, + "std::function cb;": True, + "auto p = std::make_unique(1);": True, + "std::shared_ptr s;": True, + "ss_free(ptr);": False, # not a call pattern we flag (macro wraps free) + "free(buf);": True, + } + good = [ + "std::construct_at(p, 1);", + "::new (static_cast(p)) T(1);", + "Foo(const Foo &) = delete;", + "std::string_view name;", + "std::array key;", + "// std::vector mentioned in a comment", + 'log("new connection from %s", addr);', + "void *b = std::calloc(1, n); // heap-ok: startup reservation", + "bool renew_lease = true;", + "conn.free_slots();", + ] + ok = True + for line, expect in bad.items(): + got = bool(check_text(line)) + if got != expect: + print(f"self-test: expected {'violation' if expect else 'clean'} for: {line}") + ok = False + for line in good: + if check_text(line): + print(f"self-test: false positive for: {line}") + ok = False + print("check_no_heap self-test:", "ok" if ok else "FAILED") + return ok + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path(__file__).resolve().parent.parent) + parser.add_argument("--self-test", action="store_true", help="run the pattern self-test first") + args = parser.parse_args() + if args.self_test and not self_test(): + return 1 + return 0 if check_tree(args.root.resolve()) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/core/arena.hpp b/src/core/arena.hpp new file mode 100644 index 00000000..68385772 --- /dev/null +++ b/src/core/arena.hpp @@ -0,0 +1,127 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_CORE_ARENA_HPP +#define SS_CORE_ARENA_HPP + +// MonotonicArena: bump allocation for data that lives as long as the +// process configuration (parsed JSON, CLI strings, ACL entries). +// +// The arena reserves one block at startup. Allocations never free +// individually; reset() discards everything at once (ss-manager uses this +// per control command). Only trivially destructible types may be placed in +// an arena, because nothing runs destructors. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/region.hpp" +#include "core/result.hpp" + +namespace ss { + +class MonotonicArena { +public: + MonotonicArena() noexcept = default; + MonotonicArena(const MonotonicArena &) = delete; + MonotonicArena &operator=(const MonotonicArena &) = delete; + MonotonicArena(MonotonicArena &&) = default; + MonotonicArena &operator=(MonotonicArena &&) = default; + + Status reserve(std::size_t bytes) noexcept + { + if (!block_.empty()) { + return fail(Errc::invalid); + } + auto region = Region::reserve(bytes); + if (!region) { + return fail(region.error()); + } + block_ = std::move(*region); + used_ = 0; + return {}; + } + + Result > allocate(std::size_t size, std::size_t align = alignof(std::max_align_t)) noexcept + { + if (align == 0 || (align & (align - 1)) != 0) { + return fail(Errc::invalid); + } + auto base = reinterpret_cast(block_.data()); + auto current = base + used_; + auto aligned = (current + align - 1) & ~(static_cast(align) - 1); + std::size_t offset = static_cast(aligned - base); + if (block_.empty() || offset > block_.size() || size > block_.size() - offset) { + return fail(Errc::exhausted); + } + used_ = offset + size; + if (used_ > peak_) { + peak_ = used_; + } + return std::span(block_.data() + offset, size); + } + + template + Result make(Args &&...args) noexcept + { + static_assert(std::is_trivially_destructible_v, "arena objects are never destroyed"); + auto bytes = allocate(sizeof(T), alignof(T)); + if (!bytes) { + return fail(bytes.error()); + } + return std::construct_at(reinterpret_cast(bytes->data()), std::forward(args)...); + } + + template + Result > make_array(std::size_t count) noexcept + { + static_assert(std::is_trivially_destructible_v && std::is_default_constructible_v); + if (count > SIZE_MAX / sizeof(T)) { + return fail(Errc::exhausted); + } + auto bytes = allocate(sizeof(T) * count, alignof(T)); + if (!bytes) { + return fail(bytes.error()); + } + T *first = reinterpret_cast(bytes->data()); + for (std::size_t i = 0; i < count; ++i) { + std::construct_at(first + i); + } + return std::span(first, count); + } + + // Copy a string into the arena, NUL-terminated so it can be handed to C + // APIs. The returned view excludes the terminator. + Result intern(std::string_view text) noexcept + { + auto bytes = allocate(text.size() + 1, 1); + if (!bytes) { + return fail(bytes.error()); + } + char *out = reinterpret_cast(bytes->data()); + if (!text.empty()) { + std::memcpy(out, text.data(), text.size()); + } + out[text.size()] = '\0'; + return std::string_view(out, text.size()); + } + + void reset() noexcept { used_ = 0; } + + std::size_t capacity() const noexcept { return block_.size(); } + std::size_t used() const noexcept { return used_; } + std::size_t peak() const noexcept { return peak_; } + +private: + Region block_; + std::size_t used_ = 0; + std::size_t peak_ = 0; +}; + +} // namespace ss + +#endif diff --git a/src/core/buffer.hpp b/src/core/buffer.hpp new file mode 100644 index 00000000..52cbc584 --- /dev/null +++ b/src/core/buffer.hpp @@ -0,0 +1,154 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_CORE_BUFFER_HPP +#define SS_CORE_BUFFER_HPP + +// FixedBuffer: a byte buffer with inline, compile-time capacity. +// +// Replaces buffer_t (crypto.h) and balloc/brealloc/bprepend. The bytes live +// inside the object, so a buffer embedded in a pooled connection costs no +// allocation at all. Instead of growing, writers see how much room is left +// through writable() and stop reading from the socket when it is full. +// +// Layout: [ consumed | readable (idx .. idx+len) | writable ] + +#include +#include +#include +#include +#include +#include + +namespace ss { + +using Bytes = std::span; +using ConstBytes = std::span; + +inline ConstBytes as_bytes(const void *data, std::size_t size) noexcept +{ + return { static_cast(data), size }; +} + +template +class FixedBuffer { + static_assert(N > 0 && N <= UINT32_MAX); + +public: + constexpr std::size_t capacity() const noexcept { return N; } + constexpr std::size_t size() const noexcept { return len_; } + constexpr bool empty() const noexcept { return len_ == 0; } + constexpr bool full() const noexcept { return idx_ + len_ == N; } + + ConstBytes readable() const noexcept { return { data_.data() + idx_, len_ }; } + Bytes readable() noexcept { return { data_.data() + idx_, len_ }; } + Bytes writable() noexcept { return { data_.data() + idx_ + len_, N - idx_ - len_ }; } + + // Mark `n` bytes of writable() as filled. + bool commit(std::size_t n) noexcept + { + if (n > N - idx_ - len_) { + return false; + } + len_ += static_cast(n); + return true; + } + + // Drop `n` bytes from the front of readable(). + bool consume(std::size_t n) noexcept + { + if (n > len_) { + return false; + } + idx_ += static_cast(n); + len_ -= static_cast(n); + if (len_ == 0) { + idx_ = 0; + } + return true; + } + + // Move the readable bytes to the front so writable() is as large as it + // can be. + void compact() noexcept + { + if (idx_ != 0) { + if (len_ != 0) { + std::memmove(data_.data(), data_.data() + idx_, len_); + } + idx_ = 0; + } + } + + bool append(ConstBytes bytes) noexcept + { + if (bytes.size() > N - idx_ - len_) { + compact(); + if (bytes.size() > N - len_) { + return false; + } + } + if (!bytes.empty()) { + std::memcpy(data_.data() + idx_ + len_, bytes.data(), bytes.size()); + } + len_ += static_cast(bytes.size()); + return true; + } + + // Insert bytes in front of readable(), e.g. a salt or an address header. + bool prepend(ConstBytes bytes) noexcept + { + std::size_t n = bytes.size(); + if (n > N - len_) { + return false; + } + if (n > idx_) { + // Not enough headroom: shift the payload right. + std::size_t new_idx = n; + if (len_ != 0) { + std::memmove(data_.data() + new_idx, data_.data() + idx_, len_); + } + idx_ = static_cast(new_idx); + } + idx_ -= static_cast(n); + if (n != 0) { + std::memcpy(data_.data() + idx_, bytes.data(), n); + } + len_ += static_cast(n); + return true; + } + + void clear() noexcept + { + idx_ = 0; + len_ = 0; + } + + // Overwrite the contents. Fails, leaving the buffer unchanged, if the + // bytes do not fit. + bool assign(ConstBytes bytes) noexcept + { + if (bytes.size() > N) { + return false; + } + clear(); + return append(bytes); + } + + // Wipe the storage, for buffers that held key material or plaintext. + void wipe() noexcept + { + volatile std::byte *p = data_.data(); + for (std::size_t i = 0; i < N; ++i) { + p[i] = std::byte{ 0 }; + } + clear(); + } + +private: + std::uint32_t idx_ = 0; + std::uint32_t len_ = 0; + std::array data_; +}; + +} // namespace ss + +#endif diff --git a/src/core/fixed_heap.hpp b/src/core/fixed_heap.hpp new file mode 100644 index 00000000..f2fb97b1 --- /dev/null +++ b/src/core/fixed_heap.hpp @@ -0,0 +1,229 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_CORE_FIXED_HEAP_HPP +#define SS_CORE_FIXED_HEAP_HPP + +// FixedHeap: a bounded malloc replacement for third-party libraries. +// +// libuv (uv_replace_allocator), c-ares (ares_library_init_mem) and Mbed TLS +// (MBEDTLS_PLATFORM_MEMORY) can be pointed at our own allocation functions. +// Each library gets its own FixedHeap, carved out of one region reserved at +// startup, so their steady-state allocations stay inside a budget and can +// never starve each other or the relay pools. +// +// Blocks come in power-of-two size classes from 32 bytes to 128 KiB, each +// with a 16-byte header. Freed blocks go to a per-class free list and are +// reused; untouched memory is handed out by bumping a pointer. Calls are +// serialised with a spin lock, because libuv's thread pool may allocate +// from worker threads. + +#include +#include +#include +#include +#include +#include + +#include "core/region.hpp" +#include "core/result.hpp" + +namespace ss { + +class FixedHeap { +public: + struct Stats { + std::size_t in_use = 0; // bytes in live blocks, headers included + std::size_t peak = 0; + std::size_t reserved = 0; + std::size_t allocations = 0; + std::size_t failures = 0; + }; + + FixedHeap() noexcept = default; + FixedHeap(const FixedHeap &) = delete; + FixedHeap &operator=(const FixedHeap &) = delete; + + Status reserve(std::size_t budget) noexcept + { + if (!region_.empty()) { + return fail(Errc::invalid); + } + auto region = Region::reserve(budget, kHeader); + if (!region) { + return fail(region.error()); + } + region_ = std::move(*region); + bump_ = 0; + stats_.reserved = region_.size(); + return {}; + } + + void *allocate(std::size_t size) noexcept + { + Guard guard(lock_); + return allocate_locked(size); + } + + void *allocate_zeroed(std::size_t count, std::size_t size) noexcept + { + if (size != 0 && count > SIZE_MAX / size) { + Guard guard(lock_); + ++stats_.failures; + return nullptr; + } + std::size_t total = count * size; + Guard guard(lock_); + void *p = allocate_locked(total); + if (p != nullptr) { + std::memset(p, 0, total); + } + return p; + } + + void *reallocate(void *old, std::size_t size) noexcept + { + if (old == nullptr) { + return allocate(size); + } + if (size == 0) { + deallocate(old); + return nullptr; + } + Guard guard(lock_); + Header *h = header_of(old); + std::size_t usable = class_size(h->cls) - kHeader; + if (size <= usable) { + return old; + } + void *fresh = allocate_locked(size); + if (fresh != nullptr) { + std::memcpy(fresh, old, usable); + deallocate_locked(old); + } + return fresh; + } + + void deallocate(void *p) noexcept + { + if (p == nullptr) { + return; + } + Guard guard(lock_); + deallocate_locked(p); + } + + bool owns(const void *p) const noexcept + { + auto *b = static_cast(p); + return b >= region_.data() && b < region_.data() + region_.size(); + } + + Stats stats() const noexcept + { + Guard guard(lock_); + return stats_; + } + + static constexpr std::size_t max_request() noexcept { return class_size(kClasses - 1) - kHeader; } + +private: + static constexpr std::size_t kHeader = 16; + static constexpr unsigned kMinShift = 5; // 32-byte blocks + static constexpr unsigned kClasses = 13; // ... up to 128 KiB + static constexpr std::uint32_t kMagic = 0x55aa1357U; + static constexpr std::uint32_t kFreed = 0xdeadf7eeU; + + struct Header { + std::uint32_t magic; + std::uint32_t cls; + void *next_free; // valid only while the block is on a free list + }; + static_assert(sizeof(Header) <= kHeader); + + class Guard { + public: + explicit Guard(std::atomic_flag &flag) noexcept : flag_(flag) + { + while (flag_.test_and_set(std::memory_order_acquire)) { + } + } + ~Guard() { flag_.clear(std::memory_order_release); } + Guard(const Guard &) = delete; + Guard &operator=(const Guard &) = delete; + + private: + std::atomic_flag &flag_; + }; + + static constexpr std::size_t class_size(unsigned cls) noexcept { return std::size_t{ 1 } << (cls + kMinShift); } + + static Header *header_of(void *p) noexcept + { + auto *h = reinterpret_cast
(static_cast(p) - kHeader); + if (h->magic != kMagic || h->cls >= kClasses) { + std::abort(); // not ours, or corrupted + } + return h; + } + + void *allocate_locked(std::size_t size) noexcept + { + if (size == 0) { + size = 1; + } + unsigned cls = 0; + while (cls < kClasses && class_size(cls) - kHeader < size) { + ++cls; + } + if (cls == kClasses) { + ++stats_.failures; + return nullptr; + } + std::byte *block = nullptr; + if (free_[cls] != nullptr) { + block = static_cast(free_[cls]); + SS_UNPOISON(block, class_size(cls)); + free_[cls] = reinterpret_cast
(block)->next_free; + } else if (class_size(cls) <= region_.size() - bump_) { + block = region_.data() + bump_; + bump_ += class_size(cls); + } else { + ++stats_.failures; + return nullptr; + } + auto *h = reinterpret_cast
(block); + h->magic = kMagic; + h->cls = cls; + h->next_free = nullptr; + stats_.in_use += class_size(cls); + ++stats_.allocations; + if (stats_.in_use > stats_.peak) { + stats_.peak = stats_.in_use; + } + return block + kHeader; + } + + void deallocate_locked(void *p) noexcept + { + if (!owns(p)) { + std::abort(); + } + Header *h = header_of(p); + unsigned cls = h->cls; + stats_.in_use -= class_size(cls); + h->magic = kFreed; // a second free of this block aborts in header_of + h->next_free = free_[cls]; + free_[cls] = h; + // Keep the header readable for the free list; poison the payload. + SS_POISON(static_cast(p), class_size(cls) - kHeader); + } + + Region region_; + std::size_t bump_ = 0; + void *free_[kClasses] = {}; + Stats stats_; + mutable std::atomic_flag lock_ = ATOMIC_FLAG_INIT; +}; + +} // namespace ss + +#endif diff --git a/src/core/fixed_map.hpp b/src/core/fixed_map.hpp new file mode 100644 index 00000000..0385f021 --- /dev/null +++ b/src/core/fixed_map.hpp @@ -0,0 +1,495 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_CORE_FIXED_MAP_HPP +#define SS_CORE_FIXED_MAP_HPP + +// Fixed-capacity associative containers. Replace uthash (cache.c, +// ss_event.c, manager.c) without any per-entry allocation. +// +// FixedHashMap +// Open addressing with linear probing and backward-shift deletion (no +// tombstones, so lookups stay short however long the process runs). +// The table is sized at reserve() time to at least twice the capacity. +// Entries move when others are erased, so never keep a pointer to a +// value across an erase. +// +// FixedLru +// Map plus recency order. Values live in a slab and never move, so the +// pointer returned by insert() or find() stays valid until that entry +// is erased or evicted. Replaces the pseudo-LRU in cache.c. +// +// Keys from the network (UDP peer addresses, session IDs) are hashed with a +// per-table seed. Seed it from a CSPRNG in production code so a peer cannot +// precompute colliding keys. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/buffer.hpp" +#include "core/region.hpp" +#include "core/result.hpp" + +namespace ss { + +constexpr std::uint64_t mix64(std::uint64_t x) noexcept +{ + x ^= x >> 30; + x *= 0xbf58476d1ce4e5b9ULL; + x ^= x >> 27; + x *= 0x94d049bb133111ebULL; + x ^= x >> 31; + return x; +} + +inline std::uint64_t hash_bytes(const void *data, std::size_t size, std::uint64_t seed) noexcept +{ + const auto *p = static_cast(data); + std::uint64_t h = mix64(seed ^ (size * 0x9e3779b97f4a7c15ULL)); + while (size >= 8) { + std::uint64_t chunk; + std::memcpy(&chunk, p, 8); + h = mix64(h ^ chunk) * 0x9e3779b97f4a7c15ULL; + p += 8; + size -= 8; + } + std::uint64_t tail = 0; + if (size != 0) { + std::memcpy(&tail, p, size); + } + return mix64(h ^ tail); +} + +// Inline byte-string key of at most N bytes (e.g. a sockaddr or an +// AEAD-2022 session ID). +template +class FixedKey { + static_assert(N > 0 && N <= 255); + +public: + constexpr FixedKey() noexcept = default; + + static std::optional from(ConstBytes bytes) noexcept + { + if (bytes.size() > N) { + return std::nullopt; + } + FixedKey key; + key.len_ = static_cast(bytes.size()); + if (!bytes.empty()) { + std::memcpy(key.data_.data(), bytes.data(), bytes.size()); + } + return key; + } + + ConstBytes bytes() const noexcept { return { data_.data(), len_ }; } + std::size_t size() const noexcept { return len_; } + + friend bool operator==(const FixedKey &a, const FixedKey &b) noexcept + { + return a.len_ == b.len_ && std::memcmp(a.data_.data(), b.data_.data(), a.len_) == 0; + } + +private: + std::array data_{}; + std::uint8_t len_ = 0; +}; + +template +struct Hasher; + +template +requires std::is_integral_v || std::is_enum_v +struct Hasher { + std::uint64_t operator()(K key, std::uint64_t seed) const noexcept + { + return mix64(static_cast(key) ^ mix64(seed)); + } +}; + +template +struct Hasher > { + std::uint64_t operator()(const FixedKey &key, std::uint64_t seed) const noexcept + { + return hash_bytes(key.bytes().data(), key.size(), seed); + } +}; + +template > +class FixedHashMap { + static_assert(std::is_nothrow_move_constructible_v && std::is_nothrow_move_constructible_v); + +public: + FixedHashMap() noexcept = default; + FixedHashMap(const FixedHashMap &) = delete; + FixedHashMap &operator=(const FixedHashMap &) = delete; + ~FixedHashMap() { clear(); } + + Status reserve(std::uint32_t capacity, std::uint64_t seed = 0) noexcept + { + if (capacity_ != 0 || capacity == 0 || capacity > (UINT32_MAX >> 2)) { + return fail(Errc::invalid); + } + std::uint32_t slots = 2; + while (slots < capacity * 2U) { + slots <<= 1; + } + auto entries = Region::reserve(static_cast(slots) * sizeof(Entry), alignof(Entry)); + if (!entries) { + return fail(entries.error()); + } + auto used = Region::reserve(slots, 1); + if (!used) { + return fail(used.error()); + } + entries_ = std::move(*entries); + used_ = std::move(*used); + mask_ = slots - 1; + capacity_ = capacity; + seed_ = seed; + return {}; + } + + std::uint32_t size() const noexcept { return size_; } + std::uint32_t capacity() const noexcept { return capacity_; } + bool empty() const noexcept { return size_ == 0; } + bool full() const noexcept { return size_ == capacity_; } + + V *find(const K &key) noexcept + { + std::uint32_t slot; + return locate(key, slot) ? &entry(slot)->value : nullptr; + } + const V *find(const K &key) const noexcept { return const_cast(this)->find(key); } + bool contains(const K &key) const noexcept { return find(key) != nullptr; } + + // Insert a new key. Fails with Errc::exhausted when full and with + // Errc::invalid when the key is already present. + template + Result emplace(const K &key, Args &&...args) noexcept + { + if (capacity_ == 0) { + return fail(Errc::exhausted); + } + std::uint32_t slot; + if (locate(key, slot)) { + return fail(Errc::invalid); + } + if (full()) { + return fail(Errc::exhausted); + } + // locate() leaves `slot` at the first empty slot of the probe run. + Entry *e = std::construct_at(entry(slot), key, std::forward(args)...); + used_.data()[slot] = std::byte{ 1 }; + ++size_; + return &e->value; + } + + std::optional take(const K &key) noexcept + { + std::uint32_t slot; + if (!locate(key, slot)) { + return std::nullopt; + } + std::optional out(std::move(entry(slot)->value)); + remove_at(slot); + return out; + } + + bool erase(const K &key) noexcept + { + std::uint32_t slot; + if (!locate(key, slot)) { + return false; + } + remove_at(slot); + return true; + } + + void clear() noexcept + { + for (std::uint32_t i = 0; capacity_ != 0 && i <= mask_; ++i) { + if (occupied(i)) { + std::destroy_at(entry(i)); + used_.data()[i] = std::byte{ 0 }; + } + } + size_ = 0; + } + + // Visit every entry in unspecified order. The callback must not insert + // or erase. + template + void for_each(F &&f) + { + for (std::uint32_t i = 0; capacity_ != 0 && i <= mask_; ++i) { + if (occupied(i)) { + f(static_cast(entry(i)->key), entry(i)->value); + } + } + } + +private: + struct Entry { + template + explicit Entry(const K &k, Args &&...args) : key(k), value(std::forward(args)...) + { + } + K key; + V value; + }; + + Entry *entry(std::uint32_t slot) const noexcept { return reinterpret_cast(entries_.data()) + slot; } + bool occupied(std::uint32_t slot) const noexcept { return used_.data()[slot] != std::byte{ 0 }; } + std::uint32_t home(const K &key) const noexcept + { + return static_cast(Hash{}(key, seed_)) & mask_; + } + + // True if found (slot = its position); otherwise slot = first empty slot. + bool locate(const K &key, std::uint32_t &slot) const noexcept + { + if (capacity_ == 0) { + return false; + } + std::uint32_t i = home(key); + while (occupied(i)) { + if (entry(i)->key == key) { + slot = i; + return true; + } + i = (i + 1) & mask_; + } + slot = i; + return false; + } + + void remove_at(std::uint32_t hole) noexcept + { + std::destroy_at(entry(hole)); + used_.data()[hole] = std::byte{ 0 }; + --size_; + // Backward-shift: pull later members of the probe run into the hole + // when the hole lies between their home slot and where they sit. + std::uint32_t j = hole; + for (;;) { + j = (j + 1) & mask_; + if (!occupied(j)) { + return; + } + std::uint32_t k = home(entry(j)->key); + bool movable = (hole <= j) ? (k <= hole || k > j) : (k <= hole && k > j); + if (!movable) { + continue; + } + std::construct_at(entry(hole), std::move(*entry(j))); + used_.data()[hole] = std::byte{ 1 }; + std::destroy_at(entry(j)); + used_.data()[j] = std::byte{ 0 }; + hole = j; + } + } + + Region entries_; + Region used_; + std::uint32_t mask_ = 0; + std::uint32_t capacity_ = 0; + std::uint32_t size_ = 0; + std::uint64_t seed_ = 0; +}; + +template > +class FixedLru { +public: + FixedLru() noexcept = default; + FixedLru(const FixedLru &) = delete; + FixedLru &operator=(const FixedLru &) = delete; + ~FixedLru() { clear(); } + + Status reserve(std::uint32_t capacity, std::uint64_t seed = 0) noexcept + { + SS_TRY(index_.reserve(capacity, seed)); + auto nodes = Region::reserve(static_cast(capacity) * sizeof(Node), alignof(Node)); + if (!nodes) { + return fail(nodes.error()); + } + nodes_ = std::move(*nodes); + capacity_ = capacity; + for (std::uint32_t i = 0; i < capacity; ++i) { + std::construct_at(node(i))->next = (i + 1 < capacity) ? i + 1 : kNone; + } + free_ = 0; + return {}; + } + + std::uint32_t size() const noexcept { return index_.size(); } + std::uint32_t capacity() const noexcept { return capacity_; } + bool empty() const noexcept { return size() == 0; } + bool full() const noexcept { return free_ == kNone; } + + // Look up and mark as most recently used. + V *find(const K &key) noexcept + { + std::uint32_t *slot = index_.find(key); + if (slot == nullptr) { + return nullptr; + } + touch(*slot); + return value(*slot); + } + + // Look up without changing the recency order. + V *peek(const K &key) noexcept + { + std::uint32_t *slot = index_.find(key); + return slot == nullptr ? nullptr : value(*slot); + } + + // Insert as most recently used. Fails with Errc::exhausted when full + // (evict with pop_oldest() first) and Errc::invalid for a duplicate key. + template + Result emplace(const K &key, Args &&...args) noexcept + { + if (free_ == kNone) { + return fail(Errc::exhausted); + } + std::uint32_t slot = free_; + auto indexed = index_.emplace(key, slot); + if (!indexed) { + return fail(indexed.error()); + } + Node *n = node(slot); + free_ = n->next; + std::construct_at(&n->key, key); + std::construct_at(value(slot), std::forward(args)...); + link_newest(slot); + return value(slot); + } + + std::optional take(const K &key) noexcept + { + auto slot = index_.take(key); + if (!slot) { + return std::nullopt; + } + return std::optional(release(*slot)); + } + + bool erase(const K &key) noexcept { return take(key).has_value(); } + + V *oldest() noexcept { return oldest_ == kNone ? nullptr : value(oldest_); } + const K *oldest_key() const noexcept { return oldest_ == kNone ? nullptr : &node(oldest_)->key; } + + // Remove the least recently used entry and hand its key and value to + // the caller, who decides how to dispose of it. + std::optional > pop_oldest() noexcept + { + if (oldest_ == kNone) { + return std::nullopt; + } + std::uint32_t slot = oldest_; + K key(node(slot)->key); + index_.erase(key); + V v = release(slot); + return std::pair(std::move(key), std::move(v)); + } + + void clear() noexcept + { + while (oldest_ != kNone) { + (void)pop_oldest(); + } + } + + // Visit entries from oldest to newest. The callback may not modify the + // container. + template + void for_each(F &&f) + { + for (std::uint32_t s = oldest_; s != kNone; s = node(s)->next) { + f(static_cast(node(s)->key), *value(s)); + } + } + +private: + static constexpr std::uint32_t kNone = UINT32_MAX; + + struct Node { + union { + K key; + }; + alignas(V) std::byte storage[sizeof(V)]; + std::uint32_t prev = kNone; + std::uint32_t next = kNone; + + Node() noexcept {} + ~Node() {} + }; + + Node *node(std::uint32_t slot) const noexcept { return reinterpret_cast(nodes_.data()) + slot; } + V *value(std::uint32_t slot) const noexcept { return reinterpret_cast(node(slot)->storage); } + + void link_newest(std::uint32_t slot) noexcept + { + Node *n = node(slot); + n->prev = newest_; + n->next = kNone; + if (newest_ != kNone) { + node(newest_)->next = slot; + } else { + oldest_ = slot; + } + newest_ = slot; + } + + void unlink(std::uint32_t slot) noexcept + { + Node *n = node(slot); + if (n->prev != kNone) { + node(n->prev)->next = n->next; + } else { + oldest_ = n->next; + } + if (n->next != kNone) { + node(n->next)->prev = n->prev; + } else { + newest_ = n->prev; + } + n->prev = n->next = kNone; + } + + void touch(std::uint32_t slot) noexcept + { + if (slot != newest_) { + unlink(slot); + link_newest(slot); + } + } + + V release(std::uint32_t slot) noexcept + { + unlink(slot); + Node *n = node(slot); + V out(std::move(*value(slot))); + std::destroy_at(value(slot)); + std::destroy_at(&n->key); + n->next = free_; + free_ = slot; + return out; + } + + FixedHashMap index_; + Region nodes_; + std::uint32_t capacity_ = 0; + std::uint32_t free_ = kNone; + std::uint32_t oldest_ = kNone; + std::uint32_t newest_ = kNone; +}; + +} // namespace ss + +#endif diff --git a/src/core/intrusive_list.hpp b/src/core/intrusive_list.hpp new file mode 100644 index 00000000..89e4de74 --- /dev/null +++ b/src/core/intrusive_list.hpp @@ -0,0 +1,137 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_CORE_INTRUSIVE_LIST_HPP +#define SS_CORE_INTRUSIVE_LIST_HPP + +// IntrusiveList: a doubly linked list whose links live inside the +// elements. Replaces ss_list (core.h). +// +// An element type derives from ListNode once per list it can be on. +// The list never owns its elements; it only links objects whose lifetime +// is managed elsewhere (usually a pool). Destroying a linked node unlinks +// it, so an element can never leave a dangling link behind. +// +// Owner recovery uses static_cast from the base class, which is well +// defined, instead of ss_container_of pointer arithmetic. + +#include +#include + +namespace ss { + +struct DefaultListTag; + +template +class ListNode { +public: + ListNode() noexcept = default; + ListNode(const ListNode &) = delete; + ListNode &operator=(const ListNode &) = delete; + ~ListNode() { unlink(); } + + bool linked() const noexcept { return next_ != nullptr; } + + void unlink() noexcept + { + if (next_ != nullptr) { + prev_->next_ = next_; + next_->prev_ = prev_; + next_ = prev_ = nullptr; + } + } + +private: + template + friend class IntrusiveList; + + ListNode *prev_ = nullptr; + ListNode *next_ = nullptr; +}; + +template +class IntrusiveList { + using Node = ListNode; + +public: + IntrusiveList() noexcept { head_.prev_ = head_.next_ = &head_; } + IntrusiveList(const IntrusiveList &) = delete; + IntrusiveList &operator=(const IntrusiveList &) = delete; + ~IntrusiveList() { clear(); } + + bool empty() const noexcept { return head_.next_ == &head_; } + + std::size_t size() const noexcept + { + std::size_t n = 0; + for (const Node *p = head_.next_; p != &head_; p = p->next_) { + ++n; + } + return n; + } + + void push_back(T &item) noexcept { insert_before(&head_, node(item)); } + void push_front(T &item) noexcept { insert_before(head_.next_, node(item)); } + + // Move an element that is already on this list to the back. + void move_to_back(T &item) noexcept + { + Node *n = node(item); + n->unlink(); + insert_before(&head_, n); + } + + static void remove(T &item) noexcept { node(item)->unlink(); } + + T *front() noexcept { return empty() ? nullptr : owner(head_.next_); } + T *back() noexcept { return empty() ? nullptr : owner(head_.prev_); } + + T *pop_front() noexcept + { + T *item = front(); + if (item != nullptr) { + remove(*item); + } + return item; + } + + // Unlink everything without touching the elements' lifetimes. + void clear() noexcept + { + while (!empty()) { + head_.next_->unlink(); + } + } + + // Visit every element. The callback may unlink or destroy the element + // it was given (the next pointer is read first), but not others. + template + void for_each(F &&f) + { + Node *p = head_.next_; + while (p != &head_) { + Node *next = p->next_; + f(*owner(p)); + p = next; + } + } + +private: + static Node *node(T &item) noexcept { return static_cast(&item); } + static T *owner(Node *n) noexcept { return static_cast(n); } + + static void insert_before(Node *pos, Node *n) noexcept + { + if (n->linked()) { + std::abort(); // already on a list using this tag + } + n->next_ = pos; + n->prev_ = pos->prev_; + pos->prev_->next_ = n; + pos->prev_ = n; + } + + Node head_; +}; + +} // namespace ss + +#endif diff --git a/src/core/pool.hpp b/src/core/pool.hpp new file mode 100644 index 00000000..7618f832 --- /dev/null +++ b/src/core/pool.hpp @@ -0,0 +1,282 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_CORE_POOL_HPP +#define SS_CORE_POOL_HPP + +// ObjectPool: fixed-capacity storage for objects of one type. +// +// Ownership model +// * acquire() constructs a T in a free slot and returns PoolPtr, a +// std::unique_ptr whose deleter hands the slot back to the pool. The +// PoolPtr is the one and only owner. +// * Anything that needs to refer to the object without owning it (a DNS +// callback, a timer, the peer of a relay) keeps a Handle. A handle +// carries the slot's generation, so resolve() returns nullptr once the +// object is gone, even if the slot has been reused since. +// * A type that embeds libuv handles cannot be freed while libuv still +// points at them. Such types model AsyncClosable: when the owner drops +// the PoolPtr, the slot enters the closing state (handles stop +// resolving), T::begin_close() starts closing the libuv handles, and the +// slot is destroyed and recycled only when T invokes the completion. +// +// The pool reserves all of its memory up front, so acquire() and release +// never call the system allocator. Free slots are poisoned under ASan, which +// keeps use-after-free detectable even though memory is recycled. + +#include +#include +#include +#include +#include +#include + +#include "core/region.hpp" +#include "core/result.hpp" + +namespace ss { + +template +class ObjectPool; + +template +struct Handle { + std::uint32_t index = UINT32_MAX; + std::uint32_t gen = 0; + + constexpr explicit operator bool() const noexcept { return gen != 0; } + friend constexpr bool operator==(const Handle &, const Handle &) = default; +}; + +// Passed to T::begin_close(). T must invoke it exactly once, after its last +// asynchronous close has finished; it may also be invoked synchronously from +// inside begin_close(). The object is destroyed during the call, so T must +// not touch itself afterwards. +class CloseCompletion { +public: + CloseCompletion() noexcept = default; + CloseCompletion(void (*fn)(void *, std::uint32_t), void *pool, std::uint32_t index) noexcept + : fn_(fn), pool_(pool), index_(index) + { + } + + void operator()() noexcept + { + auto fn = std::exchange(fn_, nullptr); + if (fn == nullptr) { + std::abort(); // completed twice, or never armed + } + fn(pool_, index_); + } + + explicit operator bool() const noexcept { return fn_ != nullptr; } + +private: + void (*fn_)(void *, std::uint32_t) = nullptr; + void *pool_ = nullptr; + std::uint32_t index_ = 0; +}; + +template +concept AsyncClosable = requires(T &t, CloseCompletion done) { + t.begin_close(done); +}; + +template +struct PoolDeleter { + ObjectPool *pool = nullptr; + + void operator()(T *object) const noexcept + { + if (pool == nullptr) { + std::abort(); // a PoolPtr must come from ObjectPool::acquire + } + pool->release(object); + } +}; + +template +using PoolPtr = std::unique_ptr >; + +template +class ObjectPool { + static_assert(std::is_nothrow_destructible_v); + +public: + ObjectPool() noexcept = default; + ObjectPool(const ObjectPool &) = delete; + ObjectPool &operator=(const ObjectPool &) = delete; + ObjectPool(ObjectPool &&) = delete; + ObjectPool &operator=(ObjectPool &&) = delete; + + ~ObjectPool() + { + // Every PoolPtr must be gone and every close finished before the + // pool goes away; otherwise a deleter or close callback would write + // into freed memory. + if (live_ != 0 || closing_ != 0) { + std::abort(); + } + } + + // Reserve storage for `capacity` objects. Called once at startup. + Status reserve(std::uint32_t capacity) noexcept + { + if (capacity_ != 0 || capacity == 0 || capacity == UINT32_MAX) { + return fail(Errc::invalid); + } + auto objects = Region::reserve(static_cast(capacity) * sizeof(T), alignof(T)); + if (!objects) { + return fail(objects.error()); + } + auto meta = Region::reserve(static_cast(capacity) * sizeof(Meta), alignof(Meta)); + if (!meta) { + return fail(meta.error()); + } + objects_ = std::move(*objects); + meta_ = std::move(*meta); + capacity_ = capacity; + for (std::uint32_t i = 0; i < capacity; ++i) { + Meta *m = std::construct_at(meta_at(i)); + m->next = (i + 1 < capacity) ? i + 1 : kNone; + } + free_head_ = 0; + SS_POISON(objects_.data(), objects_.size()); + return {}; + } + + template + Result > acquire(Args &&...args) noexcept + { + if (free_head_ == kNone) { + return fail(Errc::exhausted); + } + std::uint32_t index = free_head_; + Meta *m = meta_at(index); + free_head_ = m->next; + m->next = kNone; + m->state = State::live; + ++live_; + void *raw = slot_at(index); + SS_UNPOISON(raw, sizeof(T)); + T *object = std::construct_at(static_cast(raw), std::forward(args)...); + return PoolPtr(object, PoolDeleter{ this }); + } + + // Handle for a live object owned by this pool. + Handle handle_of(const T *object) const noexcept + { + std::uint32_t index = index_of(object); + return Handle{ index, meta_at(index)->gen }; + } + + // The object a handle refers to, or nullptr once it is closing or gone. + T *resolve(Handle handle) const noexcept + { + if (handle.index >= capacity_) { + return nullptr; + } + const Meta *m = meta_at(handle.index); + if (m->state != State::live || m->gen != handle.gen) { + return nullptr; + } + return static_cast(slot_at(handle.index)); + } + + bool owns(const T *object) const noexcept + { + auto *p = reinterpret_cast(object); + auto *first = objects_.data(); + return first != nullptr && p >= first && p < first + objects_.size(); + } + + std::uint32_t capacity() const noexcept { return capacity_; } + std::uint32_t live() const noexcept { return live_; } + std::uint32_t closing() const noexcept { return closing_; } + std::uint32_t available() const noexcept { return capacity_ - live_ - closing_; } + bool full() const noexcept { return free_head_ == kNone; } + +private: + friend struct PoolDeleter; + + enum class State : std::uint8_t { free, live, closing }; + + struct Meta { + std::uint32_t gen = 1; // 0 is reserved for the empty handle + std::uint32_t next = kNone; + State state = State::free; + }; + + static constexpr std::uint32_t kNone = UINT32_MAX; + + void *slot_at(std::uint32_t index) const noexcept + { + return objects_.data() + static_cast(index) * sizeof(T); + } + Meta *meta_at(std::uint32_t index) const noexcept + { + return reinterpret_cast(meta_.data()) + index; + } + + std::uint32_t index_of(const T *object) const noexcept + { + if (!owns(object)) { + std::abort(); + } + auto offset = static_cast(reinterpret_cast(object) - objects_.data()); + if (offset % sizeof(T) != 0) { + std::abort(); + } + return static_cast(offset / sizeof(T)); + } + + void release(T *object) noexcept + { + std::uint32_t index = index_of(object); + Meta *m = meta_at(index); + if (m->state != State::live) { + std::abort(); // double release + } + --live_; + if constexpr (AsyncClosable) { + m->state = State::closing; + ++closing_; + object->begin_close(CloseCompletion(&ObjectPool::closed_trampoline, this, index)); + } else { + recycle(index); + } + } + + static void closed_trampoline(void *pool, std::uint32_t index) noexcept + { + auto *self = static_cast(pool); + Meta *m = self->meta_at(index); + if (m->state != State::closing) { + std::abort(); + } + --self->closing_; + self->recycle(index); + } + + void recycle(std::uint32_t index) noexcept + { + Meta *m = meta_at(index); + std::destroy_at(static_cast(slot_at(index))); + SS_POISON(slot_at(index), sizeof(T)); + m->state = State::free; + if (++m->gen == 0) { + m->gen = 1; + } + m->next = free_head_; + free_head_ = index; + } + + Region objects_; + Region meta_; + std::uint32_t capacity_ = 0; + std::uint32_t free_head_ = kNone; + std::uint32_t live_ = 0; + std::uint32_t closing_ = 0; +}; + +} // namespace ss + +#endif diff --git a/src/core/ref.hpp b/src/core/ref.hpp new file mode 100644 index 00000000..b3d905ab --- /dev/null +++ b/src/core/ref.hpp @@ -0,0 +1,135 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_CORE_REF_HPP +#define SS_CORE_REF_HPP + +// RefCounted / RefPtr: intrusive shared ownership. +// +// Use this only where ownership is genuinely shared. Today that means +// AEAD-2022 UDP sessions, which are held by the session table and by every +// relay slot that is still talking to the peer. Everything else has a single +// owner and uses std::unique_ptr (usually PoolPtr). +// +// Why not std::shared_ptr: std::allocate_shared has an implementation- +// defined control block that cannot be sized into a pool portably, weak_ptr +// would keep the slot alive after the object dies, and the atomic count is +// wasted on a single-threaded event loop. The count here is a plain integer; +// RefPtr must not cross threads. +// +// T derives from RefCounted and provides +// static void destroy(T *object) noexcept; +// which runs when the last reference goes away (typically by dropping the +// PoolPtr that owns the storage). + +#include +#include +#include +#include + +namespace ss { + +template +class RefPtr; + +template +class RefCounted { +public: + RefCounted(const RefCounted &) = delete; + RefCounted &operator=(const RefCounted &) = delete; + + std::size_t ref_count() const noexcept { return count_; } + +protected: + ~RefCounted() + { + if (count_ != 0) { + std::abort(); // destroyed while references remain + } + } + +private: + friend T; + friend class RefPtr; + + RefCounted() noexcept = default; + + void retain() noexcept + { + if (count_ == SIZE_MAX) { + std::abort(); + } + ++count_; + } + + void release() noexcept + { + if (count_ == 0) { + std::abort(); + } + if (--count_ == 0) { + T::destroy(static_cast(this)); + } + } + + std::size_t count_ = 0; +}; + +template +class RefPtr { +public: + constexpr RefPtr() noexcept = default; + constexpr RefPtr(std::nullptr_t) noexcept {} // NOLINT(google-explicit-constructor) + + // Takes the first reference, or adds one to an object that is already + // shared. + explicit RefPtr(T *object) noexcept : ptr_(object) + { + if (ptr_ != nullptr) { + ptr_->retain(); + } + } + + RefPtr(const RefPtr &other) noexcept : RefPtr(other.ptr_) {} + RefPtr(RefPtr &&other) noexcept : ptr_(std::exchange(other.ptr_, nullptr)) {} + + RefPtr &operator=(const RefPtr &other) noexcept + { + RefPtr(other).swap(*this); + return *this; + } + RefPtr &operator=(RefPtr &&other) noexcept + { + RefPtr(std::move(other)).swap(*this); + return *this; + } + RefPtr &operator=(std::nullptr_t) noexcept + { + reset(); + return *this; + } + + ~RefPtr() { reset(); } + + void reset() noexcept + { + if (T *p = std::exchange(ptr_, nullptr)) { + p->release(); + } + } + + void swap(RefPtr &other) noexcept { std::swap(ptr_, other.ptr_); } + + T *get() const noexcept { return ptr_; } + T &operator*() const noexcept { return *ptr_; } + T *operator->() const noexcept { return ptr_; } + explicit operator bool() const noexcept { return ptr_ != nullptr; } + + friend bool operator==(const RefPtr &a, const RefPtr &b) noexcept { return a.ptr_ == b.ptr_; } + friend bool operator==(const RefPtr &a, std::nullptr_t) noexcept { return a.ptr_ == nullptr; } + +private: + T *ptr_ = nullptr; +}; + +} // namespace ss + +#endif diff --git a/src/core/region.hpp b/src/core/region.hpp new file mode 100644 index 00000000..248951f3 --- /dev/null +++ b/src/core/region.hpp @@ -0,0 +1,117 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_CORE_REGION_HPP +#define SS_CORE_REGION_HPP + +// Region: one aligned block of raw memory obtained from the system allocator. +// +// This is the only place in src/core that calls malloc. Pools, arenas and +// tables reserve their storage through a Region while the process starts up; +// after that the steady-state data path never touches the system allocator. + +#include +#include +#include +#include +#include + +#include "core/result.hpp" + +#if defined(__has_feature) +#if __has_feature(address_sanitizer) +#define SS_HAVE_ASAN 1 +#endif +#endif +#if defined(__SANITIZE_ADDRESS__) +#define SS_HAVE_ASAN 1 +#endif + +#ifdef SS_HAVE_ASAN +#include +#define SS_POISON(addr, size) ASAN_POISON_MEMORY_REGION((addr), (size)) +#define SS_UNPOISON(addr, size) ASAN_UNPOISON_MEMORY_REGION((addr), (size)) +#else +#define SS_POISON(addr, size) ((void)(addr), (void)(size)) +#define SS_UNPOISON(addr, size) ((void)(addr), (void)(size)) +#endif + +namespace ss { + +class Region { +public: + Region() noexcept = default; + Region(const Region &) = delete; + Region &operator=(const Region &) = delete; + Region(Region &&other) noexcept + : base_(std::exchange(other.base_, nullptr)) + , data_(std::exchange(other.data_, nullptr)) + , size_(std::exchange(other.size_, 0)) + { + } + Region &operator=(Region &&other) noexcept + { + if (this != &other) { + release(); + base_ = std::exchange(other.base_, nullptr); + data_ = std::exchange(other.data_, nullptr); + size_ = std::exchange(other.size_, 0); + } + return *this; + } + ~Region() { release(); } + + // Reserve `size` zeroed bytes aligned to `align` (a power of two). + static Result reserve(std::size_t size, std::size_t align = alignof(std::max_align_t)) noexcept + { + if (align == 0 || (align & (align - 1)) != 0) { + return fail(Errc::invalid); + } + if (align < alignof(std::max_align_t)) { + align = alignof(std::max_align_t); + } + if (size == 0) { + return Region{}; + } + if (size > SIZE_MAX - align) { + return fail(Errc::exhausted); + } + // Over-allocate and align by hand: aligned_alloc is missing on + // MinGW, and _aligned_malloc would need its own free function. + void *base = std::calloc(1, size + align); // heap-ok: startup reservation + if (base == nullptr) { + return fail(Errc::exhausted); + } + auto addr = reinterpret_cast(base); + auto aligned = (addr + align - 1) & ~(static_cast(align) - 1); + Region r; + r.base_ = base; + r.data_ = reinterpret_cast(aligned); + r.size_ = size; + return r; + } + + std::byte *data() const noexcept { return data_; } + std::size_t size() const noexcept { return size_; } + bool empty() const noexcept { return size_ == 0; } + +private: + void release() noexcept + { + if (base_ != nullptr) { + // Pools may leave freed slots poisoned; the allocator needs to + // be able to reuse the whole block. + SS_UNPOISON(data_, size_); + std::free(base_); // heap-ok: startup reservation + } + base_ = nullptr; + data_ = nullptr; + size_ = 0; + } + + void *base_ = nullptr; + std::byte *data_ = nullptr; + std::size_t size_ = 0; +}; + +} // namespace ss + +#endif diff --git a/src/core/result.hpp b/src/core/result.hpp new file mode 100644 index 00000000..143f14eb --- /dev/null +++ b/src/core/result.hpp @@ -0,0 +1,236 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_CORE_RESULT_HPP +#define SS_CORE_RESULT_HPP + +// Error-or-value return type for code built without exceptions. +// +// The interface is a subset of C++23 std::expected (has_value, value, error, +// and_then, transform, value_or) so Result can become an alias of +// std::expected once the toolchain floor allows it. + +#include +#include +#include +#include +#include +#include + +namespace ss { + +enum class Errc : std::uint8_t { + exhausted, // a fixed-capacity pool or table is full + io, // socket or file error; Error::sys holds errno + again, // operation would block + crypto, // authentication or cipher failure + protocol, // malformed peer input + config, // invalid configuration + closed, // object is closing or already closed + invalid, // invalid argument from the caller +}; + +struct Error { + Errc code; + int sys = 0; + + friend constexpr bool operator==(const Error &, const Error &) = default; +}; + +template +struct Unexpected { + E value; +}; + +template +constexpr Unexpected > fail(E &&e) +{ + return { std::forward(e) }; +} + +constexpr Unexpected fail(Errc code, int sys = 0) +{ + return { Error{ code, sys } }; +} + +namespace detail { +[[noreturn]] inline void result_misuse() noexcept +{ + // Accessing the wrong alternative is a programming error. There is no + // exception to throw, so stop here instead of reading garbage. + std::abort(); +} +} // namespace detail + +template +class [[nodiscard]] Result { + static_assert(!std::is_reference_v, "Result is not supported; use a pointer"); + +public: + using value_type = T; + using error_type = E; + + template + requires std::is_constructible_v + constexpr Result(U &&v) noexcept(std::is_nothrow_constructible_v) // NOLINT(google-explicit-constructor) + : ok_(true) + { + std::construct_at(&value_, std::forward(v)); + } + + template + constexpr Result(Unexpected &&e) noexcept // NOLINT(google-explicit-constructor) + : ok_(false) + { + std::construct_at(&error_, std::move(e.value)); + } + + Result(const Result &other) requires std::is_copy_constructible_v + : ok_(other.ok_) + { + if (ok_) { + std::construct_at(&value_, other.value_); + } else { + std::construct_at(&error_, other.error_); + } + } + + Result(Result &&other) noexcept(std::is_nothrow_move_constructible_v) + : ok_(other.ok_) + { + if (ok_) { + std::construct_at(&value_, std::move(other.value_)); + } else { + std::construct_at(&error_, std::move(other.error_)); + } + } + + Result &operator=(const Result &) = delete; + Result &operator=(Result &&) = delete; + + ~Result() + { + if (ok_) { + std::destroy_at(&value_); + } else { + std::destroy_at(&error_); + } + } + + constexpr bool has_value() const noexcept { return ok_; } + constexpr explicit operator bool() const noexcept { return ok_; } + + constexpr T &value() & noexcept + { + if (!ok_) { + detail::result_misuse(); + } + return value_; + } + constexpr const T &value() const & noexcept + { + if (!ok_) { + detail::result_misuse(); + } + return value_; + } + constexpr T &&value() && noexcept + { + if (!ok_) { + detail::result_misuse(); + } + return std::move(value_); + } + + constexpr T &operator*() & noexcept { return value(); } + constexpr const T &operator*() const & noexcept { return value(); } + constexpr T &&operator*() && noexcept { return std::move(*this).value(); } + constexpr T *operator->() noexcept { return &value(); } + constexpr const T *operator->() const noexcept { return &value(); } + + constexpr const E &error() const noexcept + { + if (ok_) { + detail::result_misuse(); + } + return error_; + } + + template + constexpr T value_or(U &&fallback) const & { return ok_ ? value_ : static_cast(std::forward(fallback)); } + + template + constexpr auto and_then(F &&f) && + { + using R = std::invoke_result_t; + if (ok_) { + return std::forward(f)(std::move(value_)); + } + return R(Unexpected{ error_ }); + } + + template + constexpr auto transform(F &&f) && + { + using U = std::invoke_result_t; + if (ok_) { + return Result(std::forward(f)(std::move(value_))); + } + return Result(Unexpected{ error_ }); + } + +private: + union { + T value_; + E error_; + }; + bool ok_; +}; + +template +class [[nodiscard]] Result { +public: + using value_type = void; + using error_type = E; + + constexpr Result() noexcept : ok_(true), error_() {} + + template + constexpr Result(Unexpected &&e) noexcept // NOLINT(google-explicit-constructor) + : ok_(false), error_(std::move(e.value)) + { + } + + constexpr bool has_value() const noexcept { return ok_; } + constexpr explicit operator bool() const noexcept { return ok_; } + constexpr void value() const noexcept + { + if (!ok_) { + detail::result_misuse(); + } + } + constexpr const E &error() const noexcept + { + if (ok_) { + detail::result_misuse(); + } + return error_; + } + +private: + bool ok_; + E error_; +}; + +using Status = Result; + +// Propagate an error out of a function returning any Result. +#define SS_TRY(expr) \ + do { \ + auto ss_try_result_ = (expr); \ + if (!ss_try_result_) { \ + return ::ss::fail(ss_try_result_.error()); \ + } \ + } while (0) + +} // namespace ss + +#endif diff --git a/src/server.c b/src/server.c index e75e1bdc..a5ee7bb5 100644 --- a/src/server.c +++ b/src/server.c @@ -2588,6 +2588,12 @@ Same behavior as `-d`; see that option for details. Short alias: `-d`. } } +#ifdef __MINGW32__ + if (manager_addr != NULL) { + LOGE("--manager-address is not supported on Windows; ignored"); + } +#endif + #ifndef __MINGW32__ if (manager_addr != NULL) { ss_timer_init(&stat_update_watcher, stat_update_cb, UPDATE_INTERVAL, UPDATE_INTERVAL); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d11fd636..c950432c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -57,6 +57,19 @@ function(ss_add_test name sources libs) endif() endfunction() +# C++ core (src/core): header-only ownership and allocation primitives. +foreach(core_test result pool buffer arena fixed_map ownership) + ss_add_test(test_core_${core_test} "test_core_${core_test}.cpp" "") +endforeach() + +# C++ sources under src/ must not allocate on the heap (see the script). +find_package(Python3 COMPONENTS Interpreter QUIET) +if(Python3_Interpreter_FOUND) + add_test(NAME check_no_heap + COMMAND ${Python3_EXECUTABLE} ${PROJECT_SOURCE_DIR}/scripts/check_no_heap.py --self-test) + set_tests_properties(check_no_heap PROPERTIES LABELS "unit") +endif() + # test_base64 - no external deps ss_add_test(test_base64 "test_base64.c;${PROJECT_SOURCE_DIR}/src/base64.c" @@ -215,6 +228,15 @@ ss_add_test(test_resolv_async "ss_ev;ss_cares;ss_sodium") set_tests_properties(test_resolv_async PROPERTIES TIMEOUT 30) +# Preloadable malloc counter for the allocation audit. It interposes the +# system allocator, so it is only built where that is supported (glibc, +# macOS) and never together with the sanitizers, which own malloc. +include(CheckSymbolExists) +check_symbol_exists(__GLIBC__ "stdlib.h" SS_HAVE_GLIBC) +if(SS_BUILD_EXECUTABLES AND NOT ENABLE_SANITIZERS AND (APPLE OR SS_HAVE_GLIBC)) + add_library(ss_alloc_audit SHARED alloc_audit/alloc_counter.c) +endif() + if(SS_BUILD_EXECUTABLES AND NOT CMAKE_CROSSCOMPILING) find_package(Python3 COMPONENTS Interpreter QUIET) if(Python3_Interpreter_FOUND) @@ -233,6 +255,15 @@ if(SS_BUILD_EXECUTABLES AND NOT CMAKE_CROSSCOMPILING) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/experiments/test_transport.py) set_tests_properties(test_experimental_transports PROPERTIES LABELS "integration" TIMEOUT 30) + # Steady-state allocation audit (report-only until the C++ relay + # lands; see tests/alloc_audit/measure_alloc.py). + if(TARGET ss_alloc_audit) + add_test(NAME alloc_audit_report + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/alloc_audit/measure_alloc.py + --bin ${CMAKE_RUNTIME_OUTPUT_DIRECTORY} + --shim $) + set_tests_properties(alloc_audit_report PROPERTIES LABELS "integration" TIMEOUT 120) + endif() add_test(NAME test_printable_salt_interop COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/interop.py --self --printable-salt --bin ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) diff --git a/tests/alloc_audit/alloc_counter.c b/tests/alloc_audit/alloc_counter.c new file mode 100644 index 00000000..c3ed5a70 --- /dev/null +++ b/tests/alloc_audit/alloc_counter.c @@ -0,0 +1,217 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* + * alloc_counter: a preloadable shim that counts heap allocations. + * + * Load it into a program with LD_PRELOAD (glibc) or DYLD_INSERT_LIBRARIES + * (macOS). Every SIGUSR2 appends one line with the running totals to the + * file named by SS_ALLOC_AUDIT_OUT: + * + * + * + * A driver takes a snapshot after warming a proxy up and another after more + * traffic; the difference is what the steady-state data path allocates. + * tests/alloc_audit/measure_alloc.py is that driver. + * + * The signal handler only uses async-signal-safe calls (open, write, close). + */ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include +#include +#include +#include +#include +#include +#include +#include + +static uint64_t allocations; +static uint64_t frees; +static uint64_t bytes; +static const char *out_path; + +static void +count_alloc(size_t size) +{ + __atomic_add_fetch(&allocations, 1, __ATOMIC_RELAXED); + __atomic_add_fetch(&bytes, (uint64_t)size, __ATOMIC_RELAXED); +} + +static void +count_free(void *ptr) +{ + if (ptr != NULL) { + __atomic_add_fetch(&frees, 1, __ATOMIC_RELAXED); + } +} + +static char * +format_u64(char *end, uint64_t value) +{ + do { + *--end = (char)('0' + value % 10); + value /= 10; + } while (value != 0); + return end; +} + +static void +snapshot(int signo) +{ + (void)signo; + int saved_errno = errno; + if (out_path != NULL) { + uint64_t fields[4] = { + (uint64_t)getpid(), + __atomic_load_n(&allocations, __ATOMIC_RELAXED), + __atomic_load_n(&frees, __ATOMIC_RELAXED), + __atomic_load_n(&bytes, __ATOMIC_RELAXED), + }; + char line[128]; + char *end = line + sizeof(line); + char *p = end; + *--p = '\n'; + for (int i = 3; i >= 0; i--) { + p = format_u64(p, fields[i]); + if (i != 0) { + *--p = ' '; + } + } + int fd = open(out_path, O_WRONLY | O_CREAT | O_APPEND, 0644); + if (fd >= 0) { + ssize_t ignored = write(fd, p, (size_t)(end - p)); + (void)ignored; + close(fd); + } + } + errno = saved_errno; +} + +__attribute__((constructor)) static void +install(void) +{ + out_path = getenv("SS_ALLOC_AUDIT_OUT"); + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = snapshot; + action.sa_flags = SA_RESTART; + sigemptyset(&action.sa_mask); + sigaction(SIGUSR2, &action, NULL); +} + +#if defined(__APPLE__) + +#include + +static void * +audit_malloc(size_t size) +{ + count_alloc(size); + return malloc(size); +} + +static void * +audit_calloc(size_t count, size_t size) +{ + count_alloc(count * size); + return calloc(count, size); +} + +static void * +audit_realloc(void *ptr, size_t size) +{ + count_alloc(size); + return realloc(ptr, size); +} + +static int +audit_posix_memalign(void **out, size_t align, size_t size) +{ + count_alloc(size); + return posix_memalign(out, align, size); +} + +static void +audit_free(void *ptr) +{ + count_free(ptr); + free(ptr); +} + +#define SS_INTERPOSE(replacement, original) \ + __attribute__((used)) static const struct { \ + const void *replacement_fn; \ + const void *original_fn; \ + } interpose_##original __attribute__((section("__DATA,__interpose"))) = { \ + (const void *)(uintptr_t)&replacement, (const void *)(uintptr_t)&original } + +SS_INTERPOSE(audit_malloc, malloc); +SS_INTERPOSE(audit_calloc, calloc); +SS_INTERPOSE(audit_realloc, realloc); +SS_INTERPOSE(audit_posix_memalign, posix_memalign); +SS_INTERPOSE(audit_free, free); + +#elif defined(__GLIBC__) + +extern void *__libc_malloc(size_t); +extern void *__libc_calloc(size_t, size_t); +extern void *__libc_realloc(void *, size_t); +extern void *__libc_memalign(size_t, size_t); +extern void __libc_free(void *); + +void * +malloc(size_t size) +{ + count_alloc(size); + return __libc_malloc(size); +} + +void * +calloc(size_t count, size_t size) +{ + count_alloc(count * size); + return __libc_calloc(count, size); +} + +void * +realloc(void *ptr, size_t size) +{ + count_alloc(size); + return __libc_realloc(ptr, size); +} + +void * +memalign(size_t align, size_t size) +{ + count_alloc(size); + return __libc_memalign(align, size); +} + +void * +aligned_alloc(size_t align, size_t size) +{ + return memalign(align, size); +} + +int +posix_memalign(void **out, size_t align, size_t size) +{ + void *p = memalign(align, size); + if (p == NULL) { + return ENOMEM; + } + *out = p; + return 0; +} + +void +free(void *ptr) +{ + count_free(ptr); + __libc_free(ptr); +} + +#else +#error "alloc_counter supports glibc and macOS only" +#endif diff --git a/tests/alloc_audit/measure_alloc.py b/tests/alloc_audit/measure_alloc.py new file mode 100644 index 00000000..28c634d7 --- /dev/null +++ b/tests/alloc_audit/measure_alloc.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Count heap allocations per proxied TCP connection in ss-local and ss-server. + +Both peers run with the alloc_counter shim preloaded. After a warm-up the +script snapshots each process's allocation counter, relays more connections, +and snapshots again. The difference, divided by the number of connections, +is what one connection costs in the steady state. The no-heap goal for the +C++ core is zero. + +Report-only by default; --max-per-connection N turns it into a check. + +Example: + python3 tests/alloc_audit/measure_alloc.py --bin build/bin \\ + --shim build/lib/libss_alloc_audit.dylib --method aes-256-gcm +""" +import argparse +import base64 +import concurrent.futures +import json +import os +from pathlib import Path +import signal +import sys +import tempfile +import threading +import time + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from interop import TCPHandler, TCPOrigin, free_port, peers, tcp_case # noqa: E402 + + +def read_snapshot(path, previous_lines, timeout=10): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + lines = path.read_text().splitlines() if path.exists() else [] + if len(lines) > previous_lines: + pid, allocations, frees, size = (int(v) for v in lines[-1].split()) + return {"pid": pid, "allocations": allocations, "frees": frees, "bytes": size}, len(lines) + time.sleep(.02) + raise AssertionError(f"no allocation snapshot written to {path}") + + +def snapshot_all(processes, outputs, counts): + results = [] + for index, (process, path) in enumerate(zip(processes, outputs)): + os.kill(process.pid, signal.SIGUSR2) + result, counts[index] = read_snapshot(path, counts[index]) + results.append(result) + return results + + +def relay(local_port, origin_port, connections, concurrency, size): + with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool: + futures = [pool.submit(tcp_case, local_port, origin_port, size) for _ in range(connections)] + for future in futures: + future.result(timeout=60) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--bin", default="build/bin", help="directory with ss-local and ss-server") + parser.add_argument("--shim", required=True, help="path to the alloc_counter shared library") + parser.add_argument("--method", default="aes-256-gcm") + parser.add_argument("--warmup", type=int, default=50, help="connections before the first snapshot") + parser.add_argument("--connections", type=int, default=200, help="connections between snapshots") + parser.add_argument("--concurrency", type=int, default=10) + parser.add_argument("--size", type=int, default=16384, help="bytes echoed per connection") + parser.add_argument("--max-per-connection", type=float, help="fail if any peer exceeds this") + parser.add_argument("--json", action="store_true", help="print the result as JSON") + args = parser.parse_args() + + binary_dir = Path(args.bin).resolve() + shim = str(Path(args.shim).resolve()) + preload = "DYLD_INSERT_LIBRARIES" if sys.platform == "darwin" else "LD_PRELOAD" + key_size = 16 if args.method == "2022-blake3-aes-128-gcm" else 32 + password = base64.b64encode(os.urandom(key_size)).decode("ascii") + + with TCPOrigin(("127.0.0.1", 0), TCPHandler) as origin, \ + tempfile.TemporaryDirectory(prefix="ss-alloc-") as temp: + threading.Thread(target=origin.serve_forever, daemon=True).start() + server_port, local_port = free_port(), free_port() + names = ["ss-server", "ss-local"] + outputs = [Path(temp) / f"{name}.counts" for name in names] + commands = [ + [str(binary_dir / "ss-server"), "-s", "127.0.0.1", "-p", str(server_port), + "-k", password, "-m", args.method], + [str(binary_dir / "ss-local"), "-s", "127.0.0.1", "-p", str(server_port), + "-l", str(local_port), "-k", password, "-m", args.method], + ] + environments = [] + for output in outputs: + env = os.environ.copy() + env[preload] = shim + env["SS_ALLOC_AUDIT_OUT"] = str(output) + environments.append(env) + + # peers() takes one environment for all of its commands, so each + # peer gets its own context and its own output file. + with peers([commands[0]], [server_port], environments[0]) as server, \ + peers([commands[1]], [local_port], environments[1]) as local: + processes = server + local + counts = [0, 0] + relay(local_port, origin.server_address[1], args.warmup, args.concurrency, args.size) + time.sleep(.5) + before = snapshot_all(processes, outputs, counts) + relay(local_port, origin.server_address[1], args.connections, args.concurrency, args.size) + time.sleep(.5) + after = snapshot_all(processes, outputs, counts) + + report = {"method": args.method, "connections": args.connections, "size": args.size, "peers": {}} + failed = False + for name, b, a in zip(names, before, after): + delta = a["allocations"] - b["allocations"] + per = delta / args.connections + report["peers"][name] = { + "allocations": delta, + "frees": a["frees"] - b["frees"], + "bytes": a["bytes"] - b["bytes"], + "allocations_per_connection": round(per, 2), + } + if args.max_per_connection is not None and per > args.max_per_connection: + failed = True + + if args.json: + print(json.dumps(report, indent=2)) + else: + print(f"method {args.method}, {args.connections} connections of {args.size} bytes") + for name, peer in report["peers"].items(): + print(f" {name:9} {peer['allocations_per_connection']:8.2f} allocations/connection " + f"({peer['allocations']} total, {peer['bytes']} bytes)") + if failed: + print(f"FAIL: more than {args.max_per_connection} allocations per connection") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/interop.py b/tests/interop.py index c9a04083..7b7efb48 100644 --- a/tests/interop.py +++ b/tests/interop.py @@ -159,7 +159,7 @@ def peers(commands, ports, environment=None): process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, env=environment) processes.append(process) wait_ready(process, port) - yield + yield processes except BaseException: for path in logs: print(path.name + ":\n" + path.read_text(errors="replace")[-6000:], flush=True) diff --git a/tests/test_core_arena.cpp b/tests/test_core_arena.cpp new file mode 100644 index 00000000..bce6c2f5 --- /dev/null +++ b/tests/test_core_arena.cpp @@ -0,0 +1,63 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#include +#include + +#include "core/arena.hpp" +#include "test_helpers.hpp" + +namespace { + +struct Pair { + int a; + double b; +}; + +void test_allocate_alignment() +{ + ss::MonotonicArena arena; + SS_CHECK(arena.reserve(256)); + auto c = arena.allocate(1, 1); + SS_CHECK(c); + auto d = arena.allocate(8, 64); + SS_CHECK(d); + SS_CHECK(reinterpret_cast(d->data()) % 64 == 0); + auto p = arena.make(Pair{ 3, 1.5 }); + SS_CHECK(p && (*p)->a == 3 && (*p)->b == 1.5); + SS_CHECK(!arena.allocate(8, 3)); +} + +void test_exhaustion_and_reset() +{ + ss::MonotonicArena arena; + SS_CHECK(!arena.allocate(1)); + SS_CHECK(arena.reserve(64)); + SS_CHECK(arena.allocate(64, 1)); + auto over = arena.allocate(1, 1); + SS_CHECK(!over && over.error().code == ss::Errc::exhausted); + arena.reset(); + SS_CHECK(arena.used() == 0 && arena.peak() == 64); + SS_CHECK(arena.allocate(32, 1)); +} + +void test_intern_and_arrays() +{ + ss::MonotonicArena arena; + SS_CHECK(arena.reserve(128)); + auto s = arena.intern("aes-256-gcm"); + SS_CHECK(s && *s == "aes-256-gcm"); + SS_CHECK(s->data()[s->size()] == '\0'); + auto empty = arena.intern(""); + SS_CHECK(empty && empty->empty()); + auto ports = arena.make_array(4); + SS_CHECK(ports && ports->size() == 4 && (*ports)[3] == 0); +} + +} // namespace + +int main() +{ + SS_RUN(test_allocate_alignment); + SS_RUN(test_exhaustion_and_reset); + SS_RUN(test_intern_and_arrays); + return 0; +} diff --git a/tests/test_core_buffer.cpp b/tests/test_core_buffer.cpp new file mode 100644 index 00000000..c18f7c2b --- /dev/null +++ b/tests/test_core_buffer.cpp @@ -0,0 +1,82 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#include +#include + +#include "core/buffer.hpp" +#include "test_helpers.hpp" + +namespace { + +ss::ConstBytes bytes(std::string_view s) +{ + return ss::as_bytes(s.data(), s.size()); +} + +bool equals(ss::ConstBytes b, std::string_view s) +{ + return b.size() == s.size() && std::memcmp(b.data(), s.data(), s.size()) == 0; +} + +void test_append_consume() +{ + ss::FixedBuffer<8> buf; + SS_CHECK(buf.empty() && buf.capacity() == 8); + SS_CHECK(buf.append(bytes("hello"))); + SS_CHECK(equals(buf.readable(), "hello")); + SS_CHECK(buf.consume(2)); + SS_CHECK(equals(buf.readable(), "llo")); + SS_CHECK(!buf.consume(4)); + // Appending past the tail compacts first. + SS_CHECK(buf.append(bytes("world"))); + SS_CHECK(equals(buf.readable(), "lloworld")); + SS_CHECK(!buf.append(bytes("x"))); + SS_CHECK(buf.consume(8) && buf.empty()); +} + +void test_commit_writable() +{ + ss::FixedBuffer<4> buf; + auto w = buf.writable(); + SS_CHECK(w.size() == 4); + std::memcpy(w.data(), "abcd", 4); + SS_CHECK(buf.commit(4)); + SS_CHECK(buf.full() && buf.writable().empty()); + SS_CHECK(!buf.commit(1)); + SS_CHECK(equals(buf.readable(), "abcd")); +} + +void test_prepend() +{ + ss::FixedBuffer<10> buf; + SS_CHECK(buf.append(bytes("data"))); + // No headroom: payload shifts right. + SS_CHECK(buf.prepend(bytes("hdr"))); + SS_CHECK(equals(buf.readable(), "hdrdata")); + SS_CHECK(buf.consume(3)); + // Headroom from the consumed bytes is reused in place. + SS_CHECK(buf.prepend(bytes("ab"))); + SS_CHECK(equals(buf.readable(), "abdata")); + SS_CHECK(!buf.prepend(bytes("12345"))); + SS_CHECK(equals(buf.readable(), "abdata")); +} + +void test_assign_wipe() +{ + ss::FixedBuffer<4> buf; + SS_CHECK(!buf.assign(bytes("toolong"))); + SS_CHECK(buf.assign(bytes("key"))); + SS_CHECK(equals(buf.readable(), "key")); + buf.wipe(); + SS_CHECK(buf.empty()); +} + +} // namespace + +int main() +{ + SS_RUN(test_append_consume); + SS_RUN(test_commit_writable); + SS_RUN(test_prepend); + SS_RUN(test_assign_wipe); + return 0; +} diff --git a/tests/test_core_fixed_map.cpp b/tests/test_core_fixed_map.cpp new file mode 100644 index 00000000..1f658366 --- /dev/null +++ b/tests/test_core_fixed_map.cpp @@ -0,0 +1,148 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#include +#include +#include +#include + +#include "core/fixed_map.hpp" +#include "test_helpers.hpp" + +namespace { + +using Key = ss::FixedKey<16>; + +Key key(std::string_view s) +{ + auto k = Key::from(ss::as_bytes(s.data(), s.size())); + SS_CHECK(k.has_value()); + return *k; +} + +// Every key lands in the same home slot, to exercise probe runs and the +// backward-shift deletion. +struct CollidingHash { + std::uint64_t operator()(std::uint32_t, std::uint64_t) const noexcept { return 7; } +}; + +void test_map_basic() +{ + ss::FixedHashMap map; + SS_CHECK(map.reserve(3, 0x1234)); + SS_CHECK(map.emplace(1, 10)); + SS_CHECK(map.emplace(2, 20)); + SS_CHECK(map.emplace(3, 30)); + auto dup = map.emplace(2, 99); + SS_CHECK(!dup && dup.error().code == ss::Errc::invalid); + auto over = map.emplace(4, 40); + SS_CHECK(!over && over.error().code == ss::Errc::exhausted); + SS_CHECK(*map.find(2) == 20); + auto taken = map.take(2); + SS_CHECK(taken && *taken == 20); + SS_CHECK(!map.find(2) && map.size() == 2); + SS_CHECK(map.emplace(4, 40)); + SS_CHECK(!map.erase(5)); +} + +void test_map_backward_shift() +{ + ss::FixedHashMap map; + SS_CHECK(map.reserve(8)); + for (std::uint32_t i = 0; i < 8; ++i) { + SS_CHECK(map.emplace(i, static_cast(i) * 100)); + } + // Remove from the middle of the probe run; later entries must still be + // reachable without tombstones. + SS_CHECK(map.erase(3)); + SS_CHECK(map.erase(0)); + for (std::uint32_t i = 0; i < 8; ++i) { + const int *v = map.find(i); + if (i == 0 || i == 3) { + SS_CHECK(v == nullptr); + } else { + SS_CHECK(v != nullptr && *v == static_cast(i) * 100); + } + } + int count = 0; + map.for_each([&](std::uint32_t, int &) { ++count; }); + SS_CHECK(count == 6); +} + +void test_map_move_only_values() +{ + ss::FixedHashMap > map; + SS_CHECK(map.reserve(2)); + SS_CHECK(map.emplace(1, std::make_unique(5))); + auto v = map.take(1); + SS_CHECK(v && **v == 5); +} + +void test_lru_order() +{ + ss::FixedLru lru; + SS_CHECK(lru.reserve(3, 42)); + SS_CHECK(lru.emplace(key("a"), 1)); + SS_CHECK(lru.emplace(key("b"), 2)); + SS_CHECK(lru.emplace(key("c"), 3)); + SS_CHECK(lru.full()); + auto over = lru.emplace(key("d"), 4); + SS_CHECK(!over && over.error().code == ss::Errc::exhausted); + + // Touch "a" so "b" becomes the oldest. + SS_CHECK(*lru.find(key("a")) == 1); + SS_CHECK(*lru.oldest_key() == key("b")); + // peek() does not change the order. + SS_CHECK(*lru.peek(key("b")) == 2); + SS_CHECK(*lru.oldest_key() == key("b")); + + auto evicted = lru.pop_oldest(); + SS_CHECK(evicted && evicted->first == key("b") && evicted->second == 2); + SS_CHECK(lru.emplace(key("d"), 4)); + + int order[3] = {}; + int n = 0; + lru.for_each([&](const Key &, int &v) { order[n++] = v; }); + SS_CHECK(n == 3 && order[0] == 3 && order[1] == 1 && order[2] == 4); +} + +void test_lru_stable_values() +{ + ss::FixedLru lru; + SS_CHECK(lru.reserve(64)); + auto first = lru.emplace(0, 1000); + SS_CHECK(first); + int *stable = *first; + for (std::uint32_t i = 1; i < 64; ++i) { + SS_CHECK(lru.emplace(i, static_cast(i))); + } + for (std::uint32_t i = 1; i < 64; i += 2) { + SS_CHECK(lru.erase(i)); + } + // Erasing others reshuffles the index but never moves values. + SS_CHECK(lru.peek(0) == stable && *stable == 1000); + auto taken = lru.take(0); + SS_CHECK(taken && *taken == 1000); + SS_CHECK(lru.size() == 31); + lru.clear(); + SS_CHECK(lru.empty() && lru.oldest() == nullptr); +} + +void test_key_limits() +{ + char big[17] = {}; + SS_CHECK(!Key::from(ss::as_bytes(big, sizeof(big)))); + SS_CHECK(key("ab") != key("abc")); + SS_CHECK(key("") == key("")); +} + +} // namespace + +int main() +{ + SS_RUN(test_map_basic); + SS_RUN(test_map_backward_shift); + SS_RUN(test_map_move_only_values); + SS_RUN(test_lru_order); + SS_RUN(test_lru_stable_values); + SS_RUN(test_key_limits); + return 0; +} diff --git a/tests/test_core_ownership.cpp b/tests/test_core_ownership.cpp new file mode 100644 index 00000000..e0012769 --- /dev/null +++ b/tests/test_core_ownership.cpp @@ -0,0 +1,145 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +// IntrusiveList, RefPtr and FixedHeap: the remaining ownership primitives. +#include +#include +#include + +#include "core/fixed_heap.hpp" +#include "core/intrusive_list.hpp" +#include "core/pool.hpp" +#include "core/ref.hpp" +#include "test_helpers.hpp" + +namespace { + +struct IdleTag; + +struct Conn : ss::ListNode<>, ss::ListNode { + explicit Conn(int v) noexcept : id(v) {} + int id; +}; + +void test_list_basic() +{ + ss::IntrusiveList all; + ss::IntrusiveList idle; + Conn a(1), b(2), c(3); + all.push_back(a); + all.push_back(b); + all.push_front(c); + idle.push_back(b); + SS_CHECK(all.size() == 3 && idle.size() == 1); + SS_CHECK(all.front()->id == 3 && all.back()->id == 2); + + all.move_to_back(c); + SS_CHECK(all.back()->id == 3); + + // Being on one list does not affect the other. + ss::IntrusiveList::remove(b); + SS_CHECK(all.size() == 2 && idle.front() == &b); + + int sum = 0; + all.for_each([&](Conn &x) { sum += x.id; }); + SS_CHECK(sum == 4); + SS_CHECK(all.pop_front()->id == 1); +} + +void test_list_unlinks_on_destruction() +{ + ss::IntrusiveList all; + { + Conn temp(9); + all.push_back(temp); + SS_CHECK(!all.empty()); + } + SS_CHECK(all.empty()); +} + +// A pooled, shared object: the pool owns the storage, references own the +// object's lifetime. +struct Session : ss::RefCounted { + static inline ss::ObjectPool *pool = nullptr; + static inline int destroyed = 0; + + // The PoolPtr is parked inside the object while references exist. + ss::PoolPtr self; + + static void destroy(Session *s) noexcept + { + ++destroyed; + ss::PoolPtr owner = std::move(s->self); + owner.reset(); // returns the slot to the pool + } +}; + +ss::RefPtr make_session() +{ + auto slot = Session::pool->acquire(); + SS_CHECK(slot); + Session *raw = slot->get(); + raw->self = std::move(slot).value(); + return ss::RefPtr(raw); +} + +void test_refptr_lifecycle() +{ + ss::ObjectPool pool; + SS_CHECK(pool.reserve(2)); + Session::pool = &pool; + Session::destroyed = 0; + { + ss::RefPtr table = make_session(); + SS_CHECK(table->ref_count() == 1); + ss::RefPtr relay = table; + SS_CHECK(table->ref_count() == 2); + ss::RefPtr moved = std::move(relay); + SS_CHECK(!relay && moved->ref_count() == 2); // NOLINT(bugprone-use-after-move): checks moved-from state + table.reset(); + SS_CHECK(Session::destroyed == 0 && pool.live() == 1); + moved = nullptr; + SS_CHECK(Session::destroyed == 1 && pool.live() == 0); + } + SS_CHECK(pool.available() == 2); +} + +void test_fixed_heap() +{ + ss::FixedHeap heap; + SS_CHECK(heap.reserve(4096)); + void *a = heap.allocate(10); + void *b = heap.allocate_zeroed(4, 25); + SS_CHECK(a && b && heap.owns(a) && heap.owns(b)); + SS_CHECK(reinterpret_cast(a) % 16 == 0); + auto *zeros = static_cast(b); + for (int i = 0; i < 100; ++i) { + SS_CHECK(zeros[i] == 0); + } + std::memcpy(a, "0123456789", 10); + void *grown = heap.reallocate(a, 200); + SS_CHECK(grown && std::memcmp(grown, "0123456789", 10) == 0); + + auto before = heap.stats(); + heap.deallocate(grown); + heap.deallocate(b); + SS_CHECK(heap.stats().in_use < before.in_use); + + // Freed blocks are reused instead of consuming fresh space. + void *again = heap.allocate(200); + SS_CHECK(again == grown); + heap.deallocate(again); + + SS_CHECK(heap.allocate(ss::FixedHeap::max_request() + 1) == nullptr); + SS_CHECK(heap.allocate(8192) == nullptr); // over budget + SS_CHECK(heap.stats().failures == 2); +} + +} // namespace + +int main() +{ + SS_RUN(test_list_basic); + SS_RUN(test_list_unlinks_on_destruction); + SS_RUN(test_refptr_lifecycle); + SS_RUN(test_fixed_heap); + return 0; +} diff --git a/tests/test_core_pool.cpp b/tests/test_core_pool.cpp new file mode 100644 index 00000000..a856af72 --- /dev/null +++ b/tests/test_core_pool.cpp @@ -0,0 +1,140 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#include +#include + +#include "core/pool.hpp" +#include "test_helpers.hpp" + +namespace { + +int g_destroyed = 0; + +struct Plain { + explicit Plain(int v) noexcept : value(v) {} + ~Plain() { ++g_destroyed; } + int value; +}; + +// Models an object that owns libuv handles: it can only be recycled once +// its asynchronous closes have finished. +struct Closable { + static inline ss::CloseCompletion pending; + static inline int begun = 0; + + void begin_close(ss::CloseCompletion done) noexcept + { + ++begun; + pending = done; + } + ~Closable() { ++g_destroyed; } +}; + +struct ClosesSynchronously { + void begin_close(ss::CloseCompletion done) noexcept { done(); } +}; + +void test_acquire_release() +{ + ss::ObjectPool pool; + SS_CHECK(pool.reserve(2)); + g_destroyed = 0; + { + auto a = pool.acquire(1); + auto b = pool.acquire(2); + SS_CHECK(a && b); + SS_CHECK((*a)->value == 1 && (*b)->value == 2); + SS_CHECK(pool.live() == 2 && pool.full()); + + auto c = pool.acquire(3); + SS_CHECK(!c && c.error().code == ss::Errc::exhausted); + } + SS_CHECK(g_destroyed == 2); + SS_CHECK(pool.live() == 0 && pool.available() == 2); +} + +void test_reuse_and_handles() +{ + ss::ObjectPool pool; + SS_CHECK(pool.reserve(1)); + auto first = pool.acquire(10); + SS_CHECK(first); + ss::PoolPtr owner = std::move(first).value(); + ss::Handle h = pool.handle_of(owner.get()); + SS_CHECK(h); + SS_CHECK(pool.resolve(h) == owner.get()); + + owner.reset(); + SS_CHECK(pool.resolve(h) == nullptr); + + // The slot is reused, but the stale handle must not resolve to the new + // occupant. + auto second = pool.acquire(20); + SS_CHECK(second); + SS_CHECK(pool.resolve(h) == nullptr); + SS_CHECK(pool.resolve(pool.handle_of(second->get())) == second->get()); + SS_CHECK(!pool.resolve(ss::Handle{})); +} + +void test_async_close() +{ + ss::ObjectPool pool; + SS_CHECK(pool.reserve(1)); + g_destroyed = 0; + Closable::begun = 0; + auto obj = pool.acquire(); + SS_CHECK(obj); + ss::Handle h = pool.handle_of(obj->get()); + + obj->reset(); + // Ownership is gone, but the memory is not reusable yet. + SS_CHECK(Closable::begun == 1); + SS_CHECK(g_destroyed == 0); + SS_CHECK(pool.closing() == 1 && pool.available() == 0); + SS_CHECK(pool.resolve(h) == nullptr); + SS_CHECK(!pool.acquire()); + + Closable::pending(); + SS_CHECK(g_destroyed == 1); + SS_CHECK(pool.closing() == 0 && pool.available() == 1); + { + auto again = pool.acquire(); + SS_CHECK(again); + } + // The pool refuses to die with a close still in flight, so finish it. + SS_CHECK(pool.closing() == 1); + Closable::pending(); + SS_CHECK(pool.closing() == 0); +} + +void test_sync_close() +{ + ss::ObjectPool pool; + SS_CHECK(pool.reserve(1)); + { + auto obj = pool.acquire(); + SS_CHECK(obj); + } + SS_CHECK(pool.available() == 1); +} + +void test_reserve_rules() +{ + ss::ObjectPool pool; + SS_CHECK(!pool.acquire(1)); + SS_CHECK(!pool.reserve(0)); + SS_CHECK(pool.reserve(4)); + SS_CHECK(!pool.reserve(4)); + SS_CHECK(pool.capacity() == 4); +} + +} // namespace + +int main() +{ + SS_RUN(test_acquire_release); + SS_RUN(test_reuse_and_handles); + SS_RUN(test_async_close); + SS_RUN(test_sync_close); + SS_RUN(test_reserve_rules); + return 0; +} diff --git a/tests/test_core_result.cpp b/tests/test_core_result.cpp new file mode 100644 index 00000000..8e396815 --- /dev/null +++ b/tests/test_core_result.cpp @@ -0,0 +1,79 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#include +#include + +#include "core/result.hpp" +#include "test_helpers.hpp" + +namespace { + +ss::Result parse_positive(int v) +{ + if (v <= 0) { + return ss::fail(ss::Errc::invalid); + } + return v; +} + +ss::Status check_twice(int v) +{ + SS_TRY(parse_positive(v)); + SS_TRY(parse_positive(v - 1)); + return {}; +} + +void test_value_and_error() +{ + auto ok = parse_positive(5); + SS_CHECK(ok.has_value()); + SS_CHECK(*ok == 5); + SS_CHECK(ok.value_or(0) == 5); + + auto bad = parse_positive(-1); + SS_CHECK(!bad); + SS_CHECK(bad.error().code == ss::Errc::invalid); + SS_CHECK(bad.value_or(42) == 42); +} + +void test_status_and_try() +{ + SS_CHECK(check_twice(3).has_value()); + auto r = check_twice(1); + SS_CHECK(!r); + SS_CHECK(r.error() == ss::Error{ ss::Errc::invalid, 0 }); +} + +void test_move_only_value() +{ + auto make = []() -> ss::Result > { return std::make_unique(7); }; + auto r = make(); + SS_CHECK(r); + std::unique_ptr p = std::move(r).value(); + SS_CHECK(*p == 7); +} + +void test_monadic() +{ + auto doubled = parse_positive(4).transform([](int v) { return v * 2; }); + SS_CHECK(doubled && *doubled == 8); + auto chained = parse_positive(4).and_then([](int v) { return parse_positive(v - 10); }); + SS_CHECK(!chained && chained.error().code == ss::Errc::invalid); +} + +void test_sys_errno() +{ + ss::Result r = ss::fail(ss::Errc::io, 104); + SS_CHECK(r.error().sys == 104); +} + +} // namespace + +int main() +{ + SS_RUN(test_value_and_error); + SS_RUN(test_status_and_try); + SS_RUN(test_move_only_value); + SS_RUN(test_monadic); + SS_RUN(test_sys_errno); + return 0; +} diff --git a/tests/test_helpers.hpp b/tests/test_helpers.hpp new file mode 100644 index 00000000..5dc4a125 --- /dev/null +++ b/tests/test_helpers.hpp @@ -0,0 +1,26 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef SS_TEST_HELPERS_HPP +#define SS_TEST_HELPERS_HPP + +// Minimal harness for the C++ unit tests. Tests are built without +// exceptions, so a failed check reports its location and aborts. + +#include +#include + +#define SS_CHECK(...) \ + do { \ + if (!(__VA_ARGS__)) { \ + std::fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, \ + __LINE__, #__VA_ARGS__); \ + std::abort(); \ + } \ + } while (0) + +#define SS_RUN(test) \ + do { \ + test(); \ + std::printf("ok %s\n", #test); \ + } while (0) + +#endif