Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ target_sources(dgram
FILES
src/dgram/error.cppm
src/dgram/cmsg.cppm
src/dgram/hash.cppm
src/dgram/address.cppm
src/dgram/socket.cppm
src/dgram/feature.cppm
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ compiled and run as a test, so none of them can rot.
| `pktinfo` / `ecn` | Which local address a datagram arrived on, and its ECN marking. Both families. |
| `gro` / `segment` | Hardware segmentation offload: many datagrams per slot in, one large buffer out. |
| `txtime` / `pacer` | Per-datagram departure times via `SO_TXTIME`, and drift-free rate arithmetic. |
| `flow_table` / `route` | Open-addressing demultiplexing to protocol state, keyed by a caller-supplied projection. |
| `flow_table` / `route` | Open-addressing demultiplexing to protocol state, keyed by a caller-supplied projection and hashed under a per-table SipHash seed, so peers cannot flood it. |
| `timer_wheel` | Hierarchical timing wheel for protocol timeouts: constant-time schedule, cancel and expiry. |
| `result` | `std::expected` over `errno`, with left-to-right pipe combinators. |

Expand Down
40 changes: 38 additions & 2 deletions docs/demux.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,49 @@ and comparing it would make the same peer look new on every datagram, filling a
table with duplicates. Scope *is* identity, since `fe80::1%eth0` and
`fe80::1%eth1` are different destinations.

### Your own key type

A `demux_key` is equality-comparable, trivially destructible, and has a
`hash_append` that feeds its identity to a hasher:

```cpp
struct session_key {
dgram::endpoint peer{};
std::uint32_t epoch{};

friend bool operator==(const session_key&, const session_key&) = default;

template <dgram::byte_hasher H> friend void hash_append(H& h, const session_key& k) noexcept {
hash_append(h, k.peer);
hash_append(h, k.epoch);
}
};
```

Feed exactly the fields `operator==` compares, in an encoding where no key's
bytes are a prefix of another's. The provided keys already are: an endpoint's
family fixes its width, and a `byte_key` feeds its length first. Two unequal keys
that feed the same bytes collide under every seed, which is the one collision
seeding cannot fix.

## The table

```cpp
using table = dgram::flow_table<dgram::peer_key, connection*, 1024>;

libmem::arena arena{table::footprint()};
auto flows{table::carve(arena)};
auto flows{table::carve(arena)}; // seed from getrandom
auto pinned{table::carve(arena, dgram::hash_seed{k0, k1})};
```

Open addressing with linear probing and backward-shift deletion, carved from a
resource like everything else. `Slots` must be a power of two.

| Member | Description |
|--------|-------------|
| `max_size` | Flows it will hold: seven eighths of `Slots`. |
| `static result<table> carve(R& resource, hash_seed seed)` | Take the memory; buckets are SipHash-1-3 under `seed`. |
| `static result<table> carve(R& resource)` | Same, with a seed from `random_seed()`. Fails only if `getrandom` does. |
| `max_size` | Flows it will hold: three quarters of `Slots`. |
| `Value* find(key)` | The flow, or `nullptr`. |
| `Value* insert(key, value)` | Inserts or overwrites. Points at the stored value; `nullptr` means the table was full. |
| `bool erase(key)` | `false` if it was not there, which is not an error. Not `[[nodiscard]]`. |
Expand All @@ -118,6 +146,14 @@ rather than degrading: a nearly full open-addressed table turns every lookup
into a linear scan. Size `Slots` for the peak flow count you intend to serve and
treat a refusal as backpressure.

**Keys are attacker-chosen, so the hash is keyed.** A peer picks its source port,
most of an IPv6 address, and a QUIC client its own Initial connection id. Under
an unkeyed hash it could pick keys that share a bucket and turn every lookup into
a walk of one long probe chain. Each table hashes with SipHash-1-3 under its own
128-bit seed, so the layout is unpredictable and keys that collide in one table
spread in another. Pass a fixed seed only where reproducibility matters more,
such as tests and fuzzers, and never one a peer can learn.

Backward-shift deletion means no tombstones, so a long-lived table that churns
connections does not slowly fill with dead markers.

Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ checkout.
| Suite | Covers |
|-------|--------|
| `doc_examples.cpp` | Every snippet printed in `README.md` and `docs/`, compiled and run. A snippet in a document is untested code that looks authoritative; this is what stops one rotting. |
| `compile_checks.cpp` | Pure `static_assert`. Feature concepts, control-buffer sizing, batch geometry, which resources may be carved from, which metadata is readable. Compiling is the test; it is never run. |
| `compile_checks.cpp` | Pure `static_assert`. Feature concepts, control-buffer sizing, batch geometry, which resources may be carved from, which metadata is readable, and SipHash against its published vectors, split at every word boundary. Compiling is the test; it is never run. |
| `loopback_tests.cpp` | Batch reuse, truncation reporting (including a caller-supplied `MSG_TRUNC`), nonblocking receive, staging limits, a failed flush leaving the batch staged, the error combinators. |
| `metadata_tests.cpp` | Destination address and ECN over loopback, both families, including a feature that was never enabled and an undersized control buffer. |
| `malformed_control_tests.cpp` | Control buffers the kernel would never write. Every case here was found by the fuzzer first. |
| `offload_tests.cpp` | GRO and GSO over loopback, and the segmentation walk on its own: short tail, exact multiple, stride of zero, stride past the end, empty payload. |
| `demux_tests.cpp` | Key equality and hash agreement (including the flow-label and padding cases), the flow table under churn and at capacity, projections, routing a GRO slot whose datagrams belong to different flows, an `unmatched` callback claiming or declining a datagram, arguments forwarded to a sink that takes them and ignored by one that does not, and the per-slot count. |
| `demux_tests.cpp` | Key equality and hash agreement (including the flow-label and padding cases), keys crafted to collide under one seed spreading under another, the table bucketing by its own seed, the flow table under churn and at capacity, projections, routing a GRO slot whose datagrams belong to different flows, an `unmatched` callback claiming or declining a datagram, arguments forwarded to a sink that takes them and ignored by one that does not, and the per-slot count. |
| `timer_tests.cpp` | The timing wheel: every delay across every level boundary, stepped one tick at a time and jumped, from several epochs, plus a differential run against a naive reference. `next_deadline` gets its own differential run under churn, the structural case of a timer wrapped onto the cursor's slot, and the property a waiting loop needs: waking at the deadline always finds work. Rearming and cancelling from inside the fire callback, including timers due on the same tick. |
| `pacing_tests.cpp` | The rate arithmetic exhaustively (monotonicity, drift, extreme rates, constexpr), plus what the kernel side allows unprivileged. Real pacing needs `fq` and `CAP_NET_ADMIN`, so no test here asserts a datagram was actually delayed. |
| `concurrency_tests.cpp` | Several workers, each with its own socket, arena and batches, running concurrently. Meaningful only under `-s thread`. |
Expand Down
9 changes: 7 additions & 2 deletions fuzz/fuzz_demux.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ constexpr std::size_t slots{64};
using key_type = dgram::byte_key<4>;
using table_type = dgram::flow_table<key_type, std::uint32_t, slots>;

/* Fixed, so a crash reproduces from its input alone. */
constexpr dgram::hash_seed seed{0x243F6A8885A308D3ULL, 0x13198A2E03707344ULL};

key_type key_of(const std::uint8_t id) noexcept {
const std::array<std::byte, 1> raw{static_cast<std::byte>(id)};
return key_type{raw};
Expand All @@ -47,7 +50,9 @@ void project(const std::span<const std::byte> payload) noexcept {
if (payload.size() < 11) {
std::abort(); // and must only exist when the bytes were there
}
(void)hash_value(*key);
dgram::siphash h{seed};
hash_append(h, *key);
(void)h.finish();
} else if (payload.size() >= 11) {
std::abort(); // a long enough payload must always yield a key
}
Expand All @@ -56,7 +61,7 @@ void project(const std::span<const std::byte> payload) noexcept {
/** Drive the table against a reference and check they agree at every step. */
void exercise_table(const std::span<const std::byte> ops) noexcept {
libmem::arena arena{table_type::footprint()};
auto table{table_type::carve(arena)};
auto table{table_type::carve(arena, seed)};
if (!table) {
return;
}
Expand Down
40 changes: 18 additions & 22 deletions src/dgram/address.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export module dgram:address;

import std;
import :error;
import :hash;

namespace dgram {

Expand Down Expand Up @@ -147,26 +148,17 @@ public:
return std::ranges::equal(a.address_bytes(), b.address_bytes());
}

/** @brief Hash over exactly the fields `operator==` compares. */
[[nodiscard]] friend std::size_t hash_value(const endpoint& e) noexcept {
// FNV-1a: no allocation, no table, and adequate for a demux key.
std::uint64_t h{0xCBF29CE484222325ULL};
const auto mix = [&h](const std::uint8_t byte) noexcept {
h ^= byte;
h *= 0x100000001B3ULL;
};
mix(static_cast<std::uint8_t>(e.storage_.ss_family));
const auto port{e.port()};
mix(static_cast<std::uint8_t>(port));
mix(static_cast<std::uint8_t>(port >> 8));
for (const auto byte : e.address_bytes()) {
mix(static_cast<std::uint8_t>(byte));
}
const auto scope{e.scope_id()};
for (int shift{}; shift < 32; shift += 8) {
mix(static_cast<std::uint8_t>(scope >> shift));
}
return h;
/**
* @brief Feed exactly the fields `operator==` compares.
*
* The family fixes the address width, so the encoding is prefix-free and a
* key built from two endpoints cannot collide by shifting bytes between them.
*/
template <byte_hasher H> friend void hash_append(H& h, const endpoint& e) noexcept {
hash_append(h, e.storage_.ss_family);
hash_append(h, e.port());
h(e.address_bytes());
hash_append(h, e.scope_id());
}

private:
Expand All @@ -177,9 +169,13 @@ static_assert(std::is_trivially_destructible_v<endpoint>);

} // namespace dgram

/** @brief So an `endpoint` can key a standard container as well as ours. */
/** @brief So an `endpoint` can key a standard container as well as ours. Keyed per process. */
template <> struct std::hash<dgram::endpoint> {
[[nodiscard]] std::size_t operator()(const dgram::endpoint& e) const noexcept { return hash_value(e); }
[[nodiscard]] std::size_t operator()(const dgram::endpoint& e) const noexcept {
dgram::siphash h{dgram::detail::process_seed()};
hash_append(h, e);
return static_cast<std::size_t>(h.finish());
}
};

namespace dgram {} // namespace dgram
62 changes: 40 additions & 22 deletions src/dgram/demux.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import libmem;
import :address;
import :error;
import :feature;
import :hash;
import :metadata;
import :offload;
import :pacing;
Expand Down Expand Up @@ -93,16 +94,15 @@ public:

[[nodiscard]] friend constexpr bool operator==(const byte_key& a, const byte_key& b) noexcept { return std::ranges::equal(a.bytes_, b.bytes_); }

[[nodiscard]] friend constexpr std::size_t hash_value(const byte_key& k) noexcept {
std::uint64_t h{0xCBF29CE484222325ULL};
for (const auto byte : k.bytes_) {
h ^= static_cast<std::uint8_t>(byte);
h *= 0x100000001B3ULL;
}
return h;
/** @brief Length first, so a key composed of this and more fields stays prefix-free. */
template <byte_hasher H> friend constexpr void hash_append(H& h, const byte_key& k) noexcept {
hash_append(h, static_cast<length_type>(k.bytes_.size()));
h(k.bytes());
}

private:
using length_type = std::conditional_t<(MaxBytes <= std::numeric_limits<std::uint8_t>::max()), std::uint8_t, std::size_t>;

std::inplace_vector<std::byte, MaxBytes> bytes_{};
};

Expand All @@ -111,7 +111,7 @@ export struct peer_key {
endpoint remote{};

[[nodiscard]] friend bool operator==(const peer_key& a, const peer_key& b) noexcept { return a.remote == b.remote; }
[[nodiscard]] friend std::size_t hash_value(const peer_key& k) noexcept { return hash_value(k.remote); }
template <byte_hasher H> friend void hash_append(H& h, const peer_key& k) noexcept { hash_append(h, k.remote); }
};

/**
Expand All @@ -127,18 +127,21 @@ export struct flow_key {

[[nodiscard]] friend bool operator==(const flow_key& a, const flow_key& b) noexcept { return a.remote == b.remote && a.local == b.local; }

[[nodiscard]] friend std::size_t hash_value(const flow_key& k) noexcept {
const auto a{hash_value(k.remote)};
const auto b{hash_value(k.local)};
return a ^ (b + 0x9E3779B97F4A7C15ULL + (a << 6) + (a >> 2));
template <byte_hasher H> friend void hash_append(H& h, const flow_key& k) noexcept {
hash_append(h, k.remote);
hash_append(h, k.local);
}
};

/** @brief A key usable by `flow_table`: equality-comparable and hashable. */
/**
* @brief A key usable by `flow_table`: equality-comparable, and feeds its identity to a hasher.
*
* `hash_append` must feed exactly the fields `operator==` compares, in a
* prefix-free encoding: equal keys must hash equal, and two unequal keys that
* feed the same bytes collide under every seed.
*/
export template <typename K>
concept demux_key = std::equality_comparable<K> && std::is_trivially_destructible_v<K> && requires(const K& k) {
{ hash_value(k) } -> std::convertible_to<std::size_t>;
};
concept demux_key = std::equality_comparable<K> && std::is_trivially_destructible_v<K> && requires(siphash& h, const K& k) { hash_append(h, k); };

static_assert(demux_key<peer_key>);
static_assert(demux_key<flow_key>);
Expand Down Expand Up @@ -225,9 +228,12 @@ concept flow_lookup = requires(T& t, const T& ct, const Key& k, Value v) {
*
* Linear probing with backward-shift deletion, so there are no tombstones to
* accumulate and a long-lived table does not degrade as flows come and go.
* `Slots` must be a power of two; the table refuses to fill past seven eighths
* of it, because a nearly full open-addressed table degenerates into a linear
* scan and this one cannot grow.
* `Slots` must be a power of two; the table refuses to fill past three
* quarters of it, because a nearly full open-addressed table degenerates into a
* linear scan and this one cannot grow.
*
* Buckets come from SipHash under the table's own seed, so keys a peer picks to
* collide in one table spread out in another.
*
* Not thread-safe. One per thread, like everything else.
*/
Expand All @@ -240,14 +246,16 @@ public:
using value_type = Value;

/** @brief Flows the table will hold before refusing to grow. */
static constexpr std::size_t max_size{Slots / 8 * 7};
static constexpr std::size_t max_size{Slots / 4 * 3};

static constexpr std::size_t footprint() noexcept {
return Slots * sizeof(Key) + alignof(Key) - 1 + Slots * sizeof(Value) + alignof(Value) - 1 + Slots + alignof(std::byte) - 1;
}

template <libmem::aligned_monotonic_resource R> [[nodiscard]] static result<flow_table> carve(R& resource) noexcept {
/** @brief Carve the table, hashing under `seed`. A seed a peer can learn or guess makes it floodable. */
template <libmem::aligned_monotonic_resource R> [[nodiscard]] static result<flow_table> carve(R& resource, const hash_seed seed) noexcept {
flow_table t{};
t.seed_ = seed;
auto* keys{static_cast<Key*>(resource.allocate(Slots * sizeof(Key), alignof(Key)))};
auto* values{static_cast<Value*>(resource.allocate(Slots * sizeof(Value), alignof(Value)))};
auto* used{static_cast<std::uint8_t*>(resource.allocate(Slots, alignof(std::uint8_t)))};
Expand All @@ -263,6 +271,11 @@ public:
return t;
}

/** @brief Carve the table under a seed from `random_seed()`. */
template <libmem::aligned_monotonic_resource R> [[nodiscard]] static result<flow_table> carve(R& resource) noexcept {
return random_seed() | then([&resource](const hash_seed seed) { return carve(resource, seed); });
}

[[nodiscard]] Value* find(const Key& key) noexcept {
const auto slot{locate(key)};
return slot ? &values_[*slot] : nullptr;
Expand Down Expand Up @@ -349,7 +362,11 @@ public:
private:
flow_table() noexcept = default;

[[nodiscard]] std::size_t bucket(const Key& key) const noexcept { return hash_value(key) & (Slots - 1); }
[[nodiscard]] std::size_t bucket(const Key& key) const noexcept {
siphash h{seed_};
hash_append(h, key);
return static_cast<std::size_t>(h.finish()) & (Slots - 1);
}
[[nodiscard]] static constexpr std::size_t next(const std::size_t index) noexcept { return (index + 1) & (Slots - 1); }

[[nodiscard]] std::optional<std::size_t> locate(const Key& key) const noexcept {
Expand All @@ -370,6 +387,7 @@ private:
std::span<Value> values_{};
std::span<std::uint8_t> used_{};
std::size_t size_{};
hash_seed seed_{};
};

/* ============================================================================
Expand Down
1 change: 1 addition & 0 deletions src/dgram/dgram.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export import :demux;
export import :cmsg;
export import :error;
export import :feature;
export import :hash;
export import :metadata;
export import :offload;
export import :pacing;
Expand Down
Loading
Loading