Add C++20 ownership and allocation core

Header-only primitives for the no-heap-after-startup design:
Result/Status, Region, ObjectPool with PoolPtr (std::unique_ptr with a
pool deleter), generation-checked Handle and asynchronous close support,
intrusive RefPtr, FixedBuffer, MonotonicArena, FixedHashMap and FixedLru,
IntrusiveList, and a bounded FixedHeap for third-party allocator hooks.

Unit tests cover each primitive (test_core_*). scripts/check_no_heap.py
rejects heap allocation in C++ sources under src/ and runs as a ctest.
clang-tidy now also analyses C++ sources and the core headers.
This commit is contained in:
Max Lv
2026-10-14 12:51:47 +08:00
parent 0799d3a9aa
commit f90cfe9034
20 changed files with 2789 additions and 3 deletions
+5 -2
View File
@@ -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)$'
+1 -1
View File
@@ -124,7 +124,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 "$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)"
+155
View File
@@ -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"(?<![\w.>])(?:::)?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<int> v;": True,
"std::string name;": True,
"std::function<void()> cb;": True,
"auto p = std::make_unique<int>(1);": True,
"std::shared_ptr<T> 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<void *>(p)) T(1);",
"Foo(const Foo &) = delete;",
"std::string_view name;",
"std::array<std::byte, 16> key;",
"// std::vector<int> 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())
+127
View File
@@ -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 <cstddef>
#include <cstdint>
#include <cstring>
#include <memory>
#include <span>
#include <string_view>
#include <type_traits>
#include <utility>
#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<std::span<std::byte> > 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<std::uintptr_t>(block_.data());
auto current = base + used_;
auto aligned = (current + align - 1) & ~(static_cast<std::uintptr_t>(align) - 1);
std::size_t offset = static_cast<std::size_t>(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<std::byte>(block_.data() + offset, size);
}
template <class T, class... Args>
Result<T *> make(Args &&...args) noexcept
{
static_assert(std::is_trivially_destructible_v<T>, "arena objects are never destroyed");
auto bytes = allocate(sizeof(T), alignof(T));
if (!bytes) {
return fail(bytes.error());
}
return std::construct_at(reinterpret_cast<T *>(bytes->data()), std::forward<Args>(args)...);
}
template <class T>
Result<std::span<T> > make_array(std::size_t count) noexcept
{
static_assert(std::is_trivially_destructible_v<T> && std::is_default_constructible_v<T>);
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<T *>(bytes->data());
for (std::size_t i = 0; i < count; ++i) {
std::construct_at(first + i);
}
return std::span<T>(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<std::string_view> intern(std::string_view text) noexcept
{
auto bytes = allocate(text.size() + 1, 1);
if (!bytes) {
return fail(bytes.error());
}
char *out = reinterpret_cast<char *>(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
+156
View File
@@ -0,0 +1,156 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#ifndef SS_CORE_BUFFER_HPP
#define SS_CORE_BUFFER_HPP
// FixedBuffer<N>: 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 <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
namespace ss {
using Bytes = std::span<std::byte>;
using ConstBytes = std::span<const std::byte>;
inline ConstBytes as_bytes(const void *data, std::size_t size) noexcept
{
return { static_cast<const std::byte *>(data), size };
}
template <std::size_t N>
class FixedBuffer {
static_assert(N > 0 && N <= UINT32_MAX);
public:
static constexpr std::size_t kCapacity = N;
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<std::uint32_t>(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<std::uint32_t>(n);
len_ -= static_cast<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(new_idx);
}
idx_ -= static_cast<std::uint32_t>(n);
if (n != 0) {
std::memcpy(data_.data() + idx_, bytes.data(), n);
}
len_ += static_cast<std::uint32_t>(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<std::byte, N> data_;
};
} // namespace ss
#endif
+229
View File
@@ -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 <atomic>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <utility>
#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<const std::byte *>(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<Header *>(static_cast<std::byte *>(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<std::byte *>(free_[cls]);
SS_UNPOISON(block, class_size(cls));
free_[cls] = reinterpret_cast<Header *>(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<Header *>(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<std::byte *>(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
+495
View File
@@ -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<K, V>
// 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<K, V>
// 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 <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <memory>
#include <optional>
#include <type_traits>
#include <utility>
#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<const unsigned char *>(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 <std::size_t N>
class FixedKey {
static_assert(N > 0 && N <= 255);
public:
constexpr FixedKey() noexcept = default;
static std::optional<FixedKey> from(ConstBytes bytes) noexcept
{
if (bytes.size() > N) {
return std::nullopt;
}
FixedKey key;
key.len_ = static_cast<std::uint8_t>(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<std::byte, N> data_{};
std::uint8_t len_ = 0;
};
template <class K>
struct Hasher;
template <class K>
requires std::is_integral_v<K> || std::is_enum_v<K>
struct Hasher<K> {
std::uint64_t operator()(K key, std::uint64_t seed) const noexcept
{
return mix64(static_cast<std::uint64_t>(key) ^ mix64(seed));
}
};
template <std::size_t N>
struct Hasher<FixedKey<N> > {
std::uint64_t operator()(const FixedKey<N> &key, std::uint64_t seed) const noexcept
{
return hash_bytes(key.bytes().data(), key.size(), seed);
}
};
template <class K, class V, class Hash = Hasher<K> >
class FixedHashMap {
static_assert(std::is_nothrow_move_constructible_v<K> && std::is_nothrow_move_constructible_v<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<std::size_t>(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<FixedHashMap *>(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 <class... Args>
Result<V *> 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>(args)...);
used_.data()[slot] = std::byte{ 1 };
++size_;
return &e->value;
}
std::optional<V> take(const K &key) noexcept
{
std::uint32_t slot;
if (!locate(key, slot)) {
return std::nullopt;
}
std::optional<V> 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 <class F>
void for_each(F &&f)
{
for (std::uint32_t i = 0; capacity_ != 0 && i <= mask_; ++i) {
if (occupied(i)) {
f(static_cast<const K &>(entry(i)->key), entry(i)->value);
}
}
}
private:
struct Entry {
template <class... Args>
explicit Entry(const K &k, Args &&...args) : key(k), value(std::forward<Args>(args)...)
{
}
K key;
V value;
};
Entry *entry(std::uint32_t slot) const noexcept { return reinterpret_cast<Entry *>(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<std::uint32_t>(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 K, class V, class Hash = Hasher<K> >
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<std::size_t>(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 <class... Args>
Result<V *> 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>(args)...);
link_newest(slot);
return value(slot);
}
std::optional<V> take(const K &key) noexcept
{
auto slot = index_.take(key);
if (!slot) {
return std::nullopt;
}
return std::optional<V>(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<std::pair<K, V> > 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<K, V>(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 <class F>
void for_each(F &&f)
{
for (std::uint32_t s = oldest_; s != kNone; s = node(s)->next) {
f(static_cast<const K &>(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<Node *>(nodes_.data()) + slot; }
V *value(std::uint32_t slot) const noexcept { return reinterpret_cast<V *>(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<K, std::uint32_t, Hash> 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
+137
View File
@@ -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<T, Tag>: a doubly linked list whose links live inside the
// elements. Replaces ss_list (core.h).
//
// An element type derives from ListNode<Tag> 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 <cstddef>
#include <cstdlib>
namespace ss {
struct DefaultListTag;
template <class Tag = DefaultListTag>
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 <class, class>
friend class IntrusiveList;
ListNode *prev_ = nullptr;
ListNode *next_ = nullptr;
};
template <class T, class Tag = DefaultListTag>
class IntrusiveList {
using Node = ListNode<Tag>;
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 <class F>
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<Node *>(&item); }
static T *owner(Node *n) noexcept { return static_cast<T *>(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
+282
View File
@@ -0,0 +1,282 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#ifndef SS_CORE_POOL_HPP
#define SS_CORE_POOL_HPP
// ObjectPool<T>: fixed-capacity storage for objects of one type.
//
// Ownership model
// * acquire() constructs a T in a free slot and returns PoolPtr<T>, 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<T>. 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 <cstddef>
#include <cstdint>
#include <cstdlib>
#include <memory>
#include <new>
#include <utility>
#include "core/region.hpp"
#include "core/result.hpp"
namespace ss {
template <class T>
class ObjectPool;
template <class T>
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 <class T>
concept AsyncClosable = requires(T &t, CloseCompletion done) {
t.begin_close(done);
};
template <class T>
struct PoolDeleter {
ObjectPool<T> *pool = nullptr;
void operator()(T *object) const noexcept
{
if (pool == nullptr) {
std::abort(); // a PoolPtr must come from ObjectPool::acquire
}
pool->release(object);
}
};
template <class T>
using PoolPtr = std::unique_ptr<T, PoolDeleter<T> >;
template <class T>
class ObjectPool {
static_assert(std::is_nothrow_destructible_v<T>);
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<std::size_t>(capacity) * sizeof(T), alignof(T));
if (!objects) {
return fail(objects.error());
}
auto meta = Region::reserve(static_cast<std::size_t>(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 <class... Args>
Result<PoolPtr<T> > 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<T *>(raw), std::forward<Args>(args)...);
return PoolPtr<T>(object, PoolDeleter<T>{ this });
}
// Handle for a live object owned by this pool.
Handle<T> handle_of(const T *object) const noexcept
{
std::uint32_t index = index_of(object);
return Handle<T>{ index, meta_at(index)->gen };
}
// The object a handle refers to, or nullptr once it is closing or gone.
T *resolve(Handle<T> 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<T *>(slot_at(handle.index));
}
bool owns(const T *object) const noexcept
{
auto *p = reinterpret_cast<const std::byte *>(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<T>;
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<std::size_t>(index) * sizeof(T);
}
Meta *meta_at(std::uint32_t index) const noexcept
{
return reinterpret_cast<Meta *>(meta_.data()) + index;
}
std::uint32_t index_of(const T *object) const noexcept
{
if (!owns(object)) {
std::abort();
}
auto offset = static_cast<std::size_t>(reinterpret_cast<const std::byte *>(object) - objects_.data());
if (offset % sizeof(T) != 0) {
std::abort();
}
return static_cast<std::uint32_t>(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<T>) {
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<ObjectPool *>(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<T *>(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
+135
View File
@@ -0,0 +1,135 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#ifndef SS_CORE_REF_HPP
#define SS_CORE_REF_HPP
// RefCounted<T> / RefPtr<T>: 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<T> 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 <cstddef>
#include <cstdint>
#include <cstdlib>
#include <utility>
namespace ss {
template <class T>
class RefPtr;
template <class T>
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<T>;
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<T *>(this));
}
}
std::size_t count_ = 0;
};
template <class T>
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
+117
View File
@@ -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 <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <utility>
#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 <sanitizer/asan_interface.h>
#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<Region> 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<std::uintptr_t>(base);
auto aligned = (addr + align - 1) & ~(static_cast<std::uintptr_t>(align) - 1);
Region r;
r.base_ = base;
r.data_ = reinterpret_cast<std::byte *>(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
+236
View File
@@ -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 <cstdint>
#include <cstdlib>
#include <memory>
#include <new>
#include <type_traits>
#include <utility>
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 <class E>
struct Unexpected {
E value;
};
template <class E>
constexpr Unexpected<std::decay_t<E> > fail(E &&e)
{
return { std::forward<E>(e) };
}
constexpr Unexpected<Error> 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 T, class E = Error>
class [[nodiscard]] Result {
static_assert(!std::is_reference_v<T>, "Result<T&> is not supported; use a pointer");
public:
using value_type = T;
using error_type = E;
template <class U = T>
requires std::is_constructible_v<T, U &&>
constexpr Result(U &&v) noexcept(std::is_nothrow_constructible_v<T, U &&>) // NOLINT(google-explicit-constructor)
: ok_(true)
{
std::construct_at(&value_, std::forward<U>(v));
}
template <class G>
constexpr Result(Unexpected<G> &&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<T>
: 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<T>)
: 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 <class U>
constexpr T value_or(U &&fallback) const & { return ok_ ? value_ : static_cast<T>(std::forward<U>(fallback)); }
template <class F>
constexpr auto and_then(F &&f) &&
{
using R = std::invoke_result_t<F, T &&>;
if (ok_) {
return std::forward<F>(f)(std::move(value_));
}
return R(Unexpected<E>{ error_ });
}
template <class F>
constexpr auto transform(F &&f) &&
{
using U = std::invoke_result_t<F, T &&>;
if (ok_) {
return Result<U, E>(std::forward<F>(f)(std::move(value_)));
}
return Result<U, E>(Unexpected<E>{ error_ });
}
private:
union {
T value_;
E error_;
};
bool ok_;
};
template <class E>
class [[nodiscard]] Result<void, E> {
public:
using value_type = void;
using error_type = E;
constexpr Result() noexcept : ok_(true), error_() {}
template <class G>
constexpr Result(Unexpected<G> &&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<void>;
// 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
+31
View File
@@ -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 $<TARGET_FILE:ss_alloc_audit>)
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})
+63
View File
@@ -0,0 +1,63 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#include <cstdint>
#include <cstring>
#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<std::uintptr_t>(d->data()) % 64 == 0);
auto p = arena.make<Pair>(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<std::uint16_t>(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;
}
+82
View File
@@ -0,0 +1,82 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#include <cstring>
#include <string_view>
#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;
}
+148
View File
@@ -0,0 +1,148 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#include <cstdint>
#include <cstring>
#include <memory>
#include <string_view>
#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<std::uint32_t, int> 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<std::uint32_t, int, CollidingHash> map;
SS_CHECK(map.reserve(8));
for (std::uint32_t i = 0; i < 8; ++i) {
SS_CHECK(map.emplace(i, static_cast<int>(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<int>(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<std::uint32_t, std::unique_ptr<int> > map;
SS_CHECK(map.reserve(2));
SS_CHECK(map.emplace(1, std::make_unique<int>(5)));
auto v = map.take(1);
SS_CHECK(v && **v == 5);
}
void test_lru_order()
{
ss::FixedLru<Key, int> 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<std::uint32_t, int> 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<int>(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;
}
+145
View File
@@ -0,0 +1,145 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
// IntrusiveList, RefPtr and FixedHeap: the remaining ownership primitives.
#include <cstdint>
#include <cstring>
#include <utility>
#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<IdleTag> {
explicit Conn(int v) noexcept : id(v) {}
int id;
};
void test_list_basic()
{
ss::IntrusiveList<Conn> all;
ss::IntrusiveList<Conn, IdleTag> 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<Conn>::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<Conn> 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<Session> {
static inline ss::ObjectPool<Session> *pool = nullptr;
static inline int destroyed = 0;
// The PoolPtr is parked inside the object while references exist.
ss::PoolPtr<Session> self;
static void destroy(Session *s) noexcept
{
++destroyed;
ss::PoolPtr<Session> owner = std::move(s->self);
owner.reset(); // returns the slot to the pool
}
};
ss::RefPtr<Session> make_session()
{
auto slot = Session::pool->acquire();
SS_CHECK(slot);
Session *raw = slot->get();
raw->self = std::move(slot).value();
return ss::RefPtr<Session>(raw);
}
void test_refptr_lifecycle()
{
ss::ObjectPool<Session> pool;
SS_CHECK(pool.reserve(2));
Session::pool = &pool;
Session::destroyed = 0;
{
ss::RefPtr<Session> table = make_session();
SS_CHECK(table->ref_count() == 1);
ss::RefPtr<Session> relay = table;
SS_CHECK(table->ref_count() == 2);
ss::RefPtr<Session> 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<std::uintptr_t>(a) % 16 == 0);
auto *zeros = static_cast<unsigned char *>(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;
}
+140
View File
@@ -0,0 +1,140 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#include <cstdint>
#include <utility>
#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<Plain> 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<Plain> pool;
SS_CHECK(pool.reserve(1));
auto first = pool.acquire(10);
SS_CHECK(first);
ss::PoolPtr<Plain> owner = std::move(first).value();
ss::Handle<Plain> 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<Plain>{}));
}
void test_async_close()
{
ss::ObjectPool<Closable> pool;
SS_CHECK(pool.reserve(1));
g_destroyed = 0;
Closable::begun = 0;
auto obj = pool.acquire();
SS_CHECK(obj);
ss::Handle<Closable> 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<ClosesSynchronously> pool;
SS_CHECK(pool.reserve(1));
{
auto obj = pool.acquire();
SS_CHECK(obj);
}
SS_CHECK(pool.available() == 1);
}
void test_reserve_rules()
{
ss::ObjectPool<Plain> 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;
}
+79
View File
@@ -0,0 +1,79 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
#include <memory>
#include <utility>
#include "core/result.hpp"
#include "test_helpers.hpp"
namespace {
ss::Result<int> 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<std::unique_ptr<int> > { return std::make_unique<int>(7); };
auto r = make();
SS_CHECK(r);
std::unique_ptr<int> 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<int> 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;
}
+26
View File
@@ -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 <cstdio>
#include <cstdlib>
#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