summaryrefslogtreecommitdiff
path: root/src/ipcpd/unicast/ca
diff options
context:
space:
mode:
Diffstat (limited to 'src/ipcpd/unicast/ca')
-rw-r--r--src/ipcpd/unicast/ca/mb-ecn.c818
-rw-r--r--src/ipcpd/unicast/ca/mb-ecn.h59
-rw-r--r--src/ipcpd/unicast/ca/nop.c99
-rw-r--r--src/ipcpd/unicast/ca/nop.h55
-rw-r--r--src/ipcpd/unicast/ca/ops.h64
-rw-r--r--src/ipcpd/unicast/ca/pol.h24
-rw-r--r--src/ipcpd/unicast/ca/tests/CMakeLists.txt44
-rw-r--r--src/ipcpd/unicast/ca/tests/ca_test.c392
-rw-r--r--src/ipcpd/unicast/ca/tests/mb_ecn_test.c2588
9 files changed, 4143 insertions, 0 deletions
diff --git a/src/ipcpd/unicast/ca/mb-ecn.c b/src/ipcpd/unicast/ca/mb-ecn.c
new file mode 100644
index 00000000..e59aac88
--- /dev/null
+++ b/src/ipcpd/unicast/ca/mb-ecn.c
@@ -0,0 +1,818 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Multi-bit ECN Congestion Avoidance
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#if defined(__linux__) || defined(__CYGWIN__)
+#define _DEFAULT_SOURCE
+#else
+#define _POSIX_C_SOURCE 200809L
+#endif
+
+#include "config.h"
+
+#include <ouroboros/time.h>
+#include <ouroboros/utils.h>
+
+#include "cap.h"
+#include "mb-ecn.h"
+
+#include <inttypes.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdio.h>
+
+/*
+ * The sender paces with a token bucket at a rate driven by Δt-scaled
+ * AIMD: each step changes the rate proportional to elapsed wall-clock
+ * time, so the per-second dynamics do not depend on how often packets
+ * arrive. The receiver's averaging window and the sender's feedback
+ * staleness both stretch with the flow's byte rate, so a slow flow
+ * is measured and controlled like a fast one; CA_RATE_MIN only
+ * bounds those horizons (window <= CA_TW_ABSMAX, TTL ~8 s). There
+ * is no per-flow timer; the control runs on packet sends.
+ *
+ * The floor and the AI slope scale with the path: forwarders stamp
+ * their measured link capacity into the PCI (cap.c), the receiver
+ * feeds the path MIN back with the ece, and the sender derives
+ * rate_min = ai_rate = C / 32, clamped to [CA_RATE_MIN, CA_RMIN_MAX],
+ * falling back to those defaults when the signal goes stale.
+ */
+
+#define CA_SHFT 5 /* ece fixed point: 32 * ecn */
+#define CA_TW_MIN (1ULL << 20) /* min mean window ~1.05 ms */
+#define CA_TW_INIT (1ULL << 26) /* initial mean window ~67ms */
+#define CA_TW_ABSMAX (1ULL << 32) /* window ceiling ~4.3 s */
+/* Quiet horizon, in windows (1 << shift): gap restart and the TTLs. */
+#define CA_TW_GAP_SHFT 2
+#define CA_N_TARGET 16 /* target packets per window */
+#define CA_RX_WBYTES (CA_N_TARGET * 1000ULL) /* target bytes/window */
+#define CA_RX_WCLOSE (2 * CA_RX_WBYTES) /* byte-triggered early close */
+#define CA_TW_SM_SHFT 2 /* window EWMA weight 1/4 */
+#define CA_MARK_Q 4 /* mark quantum (packets) */
+
+#define CA_RATE_MIN (1ULL << 13) /* 8 KiB/s rate floor */
+#define CA_RATE_INIT (1ULL << 16) /* slow start seed 64 KiB/s */
+/* Rate cap; also keeps rate * dt and rate * rise below 2^64. */
+#define CA_RATE_MAX (1ULL << 37)
+#define CA_INV_SHFT 32 /* reciprocal-rate fixp */
+#define CA_AI_RATE (1ULL << 16) /* 64 KiB/s^2 additive inc */
+#define CA_PROBE_TC (8ULL * BILLION) /* proportional probe TC 8s */
+#define CA_ECE_REF (16 << CA_SHFT) /* full congestion: ecn 16 */
+#define CA_MD_KD_DIV 4 /* one-sided lead gain 1/4 */
+/* Must stay >= 1 ms: the MD term scales by dtc / MILLION (truncates). */
+#define CA_DT_CTRL (BILLION / 1000)
+#define CA_DT_CAP (BILLION / 20) /* idle-resume Δt clamp 50ms */
+/* Floor of the rate-relative feedback staleness (ctx->ece_ttl). */
+#define CA_ECE_TTL ((1 << CA_TW_GAP_SHFT) * CA_TW_INIT)
+#define CA_SS_TC (BILLION / 50)
+#define CA_WASH_BKT (BILLION / 32) /* washout bucket ~31 ms */
+#define CA_WASH_SHFT 2 /* damp 1/4 of bucket change */
+
+#define CA_CAP_SHFT 5 /* floor = capacity / 32 */
+#define CA_CAP_SM_SHFT 1 /* capacity EWMA weight 1/2 */
+/* Outlives ece_ttl 16x: onset-fresh fcap re-seeds each episode. */
+#define CA_CAP_TTL_SHFT 4
+#define CA_RMIN_MAX (1ULL << 32) /* derived floor ceiling */
+
+#define CA_SND_WIN CA_TW_INIT /* sender util window ~67ms */
+#define CA_USE_NUM 3 /* backlogged: offered >= */
+#define CA_USE_DEN 4 /* 3/4 * window-start rate */
+#define CA_HDRM_MARKS 4 /* ceiling ~2x offered load */
+#define CA_SND_DEC_SHFT 4 /* offered max-filter 1/16 */
+#define CA_SND_DEC_CAP 16 /* bound gapped-close decay */
+#define CA_SND_BYT_MAX (1ULL << 33) /* offered-byte saturation */
+
+/*
+ * Retuning invariants (pinned by the unit tests):
+ * - (1 << CA_TW_GAP_SHFT) * CA_TW_INIT > S * BILLION / CA_RATE_MIN, or
+ * a floor-rate flow's onset restart-loops (S ~ one MTU; both ns).
+ * - CA_RX_WBYTES * BILLION / CA_RATE_MIN < CA_TW_ABSMAX: the
+ * floor-rate window must clear the ceiling.
+ * - CA_RATE_MAX * CA_DT_CAP, the folded lead * inv_rate at
+ * CA_RATE_MIN, and owed * BILLION (owed clamped in mb_ecn_snd) all
+ * keep the pacer arithmetic below 2^64.
+ * - CA_DT_CTRL < CA_WASH_BKT < CA_DT_CAP: control cadence under the
+ * washout bucket under the sparse-step cutoff.
+ * - CA_RATE_MIN <= CA_RATE_INIT and CA_RMIN_MAX < CA_RATE_MAX.
+ */
+
+struct mb_ecn_ctx {
+ uint16_t rx_ece; /* smoothed congestion echo (32 * ecn) */
+ uint64_t rx_acc; /* window integral of ecn * dt */
+ uint64_t rx_byt; /* bytes arrived in current window */
+ uint64_t rx_ts; /* last packet arrival (ns) */
+ uint64_t rx_win; /* window start (ns) */
+ uint64_t rx_tw; /* adaptive averaging window (ns) */
+ uint8_t rx_cap; /* window bottleneck capacity code */
+
+ uint16_t tx_ece; /* congestion reported from downstream */
+ uint16_t tx_ecp; /* previous tx_ece (rise detection) */
+ uint8_t tx_loc; /* local first-hop ecn mark (fallback) */
+ uint8_t tx_cap; /* path capacity code fed back to us */
+ bool tx_cav; /* past slow start */
+ uint64_t rate; /* paced send rate (bytes/s) */
+ uint64_t rate_min; /* capacity-derived rate floor (B/s) */
+ uint64_t ai_rate; /* additive-increase slope (B/s^2) */
+ uint64_t ece_ttl; /* how long feedback stays valid (ns) */
+ uint64_t r_bkt; /* rate snapshot at last washout bucket */
+ uint64_t wash_acc; /* washout bucket time accumulator (ns) */
+ uint64_t inv_rate; /* fixed-point 1/rate for pacing */
+ uint64_t vt; /* virtual service clock (bytes) */
+ uint64_t lead; /* pacer lead of last send (bytes) */
+ uint64_t last_ts; /* last clock advance (ns) */
+ uint64_t last_ctrl; /* last rate update (ns) */
+ uint64_t last_fb; /* last feedback applied (ns) */
+ uint64_t last_loc; /* last local mark seen (ns) */
+ uint64_t last_cap; /* last capacity applied (ns) */
+
+ uint64_t n_ctrl; /* control steps taken */
+ uint64_t t_ctrl; /* wall time covered by steps (ns) */
+ uint64_t t_bank; /* increase time banked in steps (ns) */
+ uint64_t n_fb; /* feedback updates received */
+ uint64_t n_ttl; /* feedback aged out (TTL) */
+ uint64_t n_cap; /* capacity updates applied */
+ uint64_t ss_peak; /* peak rate in slow start (bytes/s) */
+
+ uint64_t snd_byt; /* bytes offered this window (capped) */
+ uint64_t snd_win; /* utilisation window start (ns) */
+ uint64_t snd_r0; /* rate at window start */
+ uint64_t snd_rate; /* max-filter of offered rate (B/s) */
+ bool backlogged; /* offered load keeps the pacer busy */
+ bool src_limited; /* rate held at offered-load ceiling */
+ bool started; /* a real send has occurred */
+};
+
+struct ca_ops mb_ecn_ca_ops = {
+ .ctx_create = mb_ecn_ctx_create,
+ .ctx_destroy = mb_ecn_ctx_destroy,
+ .ctx_update_snd = mb_ecn_ctx_update_snd,
+ .ctx_update_rcv = mb_ecn_ctx_update_rcv,
+ .ctx_update_ece = mb_ecn_ctx_update_ece,
+ .calc_ecn = mb_ecn_calc_ecn,
+ .marks_ecn = true,
+ .print_stats = mb_ecn_print_stats
+};
+
+static uint64_t mb_ecn_rate_inv(uint64_t rate)
+{
+ return ((uint64_t) BILLION << CA_INV_SHFT) / rate;
+}
+
+/*
+ * Feedback arrives once per receiver window, and the window tracks
+ * the flow's byte rate. Mirror it: age the signal out only past the
+ * quiet horizon at the current rate, floored for fast flows.
+ */
+static uint64_t mb_ecn_ece_ttl(uint64_t rate)
+{
+ uint64_t ttl;
+
+ ttl = (1 << CA_TW_GAP_SHFT) * CA_RX_WBYTES * BILLION / rate;
+
+ return ttl > (uint64_t) CA_ECE_TTL ? ttl : (uint64_t) CA_ECE_TTL;
+}
+
+void * mb_ecn_ctx_create(void)
+{
+ struct timespec now;
+ uint64_t t;
+ struct mb_ecn_ctx * ctx;
+
+ ctx = malloc(sizeof(*ctx));
+ if (ctx == NULL)
+ return NULL;
+
+ clock_gettime(PTHREAD_COND_CLOCK, &now);
+
+ memset(ctx, 0, sizeof(*ctx));
+
+ t = TS_TO_UINT64(now);
+
+ ctx->rate = CA_RATE_INIT;
+ ctx->rate_min = CA_RATE_MIN;
+ ctx->ai_rate = CA_AI_RATE;
+ ctx->ece_ttl = mb_ecn_ece_ttl(CA_RATE_INIT);
+ ctx->r_bkt = CA_RATE_INIT;
+ ctx->inv_rate = mb_ecn_rate_inv(CA_RATE_INIT);
+ ctx->rx_ts = t;
+ ctx->rx_win = t;
+ ctx->rx_tw = CA_TW_INIT;
+ ctx->last_ts = t;
+ ctx->last_ctrl = t;
+ ctx->last_fb = t;
+ ctx->last_loc = t;
+ ctx->last_cap = t;
+
+ /* snd_win/last_ts re-seeded lazily on the first real send. */
+ ctx->snd_r0 = CA_RATE_INIT;
+ ctx->snd_rate = CA_RATE_INIT;
+ ctx->backlogged = true;
+
+ return (void *) ctx;
+}
+
+void mb_ecn_ctx_destroy(void * ctx)
+{
+ free(ctx);
+}
+
+/* Local first-hop mark exits slow start and covers dead feedback. */
+static void mb_ecn_loc(struct mb_ecn_ctx * ctx,
+ uint8_t lecn,
+ uint64_t t)
+{
+ if (lecn == 0)
+ return;
+
+ ctx->tx_loc = lecn;
+ ctx->tx_cav = true;
+ ctx->last_loc = t;
+}
+
+/* Slow start: ramp only while backlogged. */
+static void mb_ecn_slow_start(struct mb_ecn_ctx * ctx,
+ uint64_t dta)
+{
+ if (ctx->backlogged)
+ ctx->rate += ctx->rate * dta / CA_SS_TC;
+
+ ctx->r_bkt = ctx->rate;
+}
+
+/* Additive increase plus a rate-independent proportional probe. */
+static void mb_ecn_increase(struct mb_ecn_ctx * ctx,
+ uint64_t dta)
+{
+ if (!ctx->backlogged)
+ return;
+
+ ctx->rate += ctx->ai_rate * dta / BILLION;
+ ctx->rate += ctx->rate * dta / CA_PROBE_TC;
+}
+
+/* Multiplicative decrease: proportional cut plus a one-sided lead. */
+static void mb_ecn_decrease(struct mb_ecn_ctx * ctx,
+ uint64_t dtc)
+{
+ uint64_t dtm;
+ uint64_t mark;
+ uint64_t rise;
+ uint64_t cut;
+ uint16_t m;
+
+ m = ctx->tx_ece > 0 ? ctx->tx_ece
+ : (uint16_t) (ctx->tx_loc << CA_SHFT);
+ if (m == 0) {
+ ctx->tx_ecp = 0;
+ return;
+ }
+
+ mark = MIN(m, CA_ECE_REF);
+ rise = 0;
+ if (m > ctx->tx_ecp)
+ rise = MIN(m - ctx->tx_ecp, CA_ECE_REF);
+
+ cut = ctx->rate * rise / (CA_ECE_REF * CA_MD_KD_DIV);
+
+ /* Honest elapsed ms, so a starved sender still cuts. */
+ dtm = dtc / MILLION;
+ if (mark * dtm >= CA_ECE_REF * 500)
+ cut += ctx->rate / 2;
+ else
+ cut += ctx->rate * mark * dtm / (CA_ECE_REF * 1000);
+
+ if (cut > ctx->rate / 2)
+ cut = ctx->rate / 2;
+
+ ctx->rate -= cut;
+ ctx->tx_ecp = m;
+}
+
+/*
+ * Washout: once per wall-clock bucket, damp a fixed fraction of the
+ * rate change over that bucket. Bucketed (not per-step) so it stays
+ * cadence-independent; bounded so it cannot reverse a ramp. A sparse
+ * step resets it, so a starved sender keeps its cut.
+ */
+static void mb_ecn_washout(struct mb_ecn_ctx * ctx,
+ uint64_t dtc,
+ uint64_t dta)
+{
+ if (dtc > (uint64_t) CA_DT_CAP) {
+ ctx->r_bkt = ctx->rate;
+ ctx->wash_acc = 0;
+ return;
+ }
+
+ ctx->wash_acc += dta;
+ if (ctx->wash_acc < (uint64_t) CA_WASH_BKT)
+ return;
+
+ if (ctx->rate > ctx->r_bkt)
+ ctx->rate -= (ctx->rate - ctx->r_bkt) >> CA_WASH_SHFT;
+ else
+ ctx->rate += (ctx->r_bkt - ctx->rate) >> CA_WASH_SHFT;
+
+ ctx->r_bkt = ctx->rate;
+ ctx->wash_acc = 0;
+}
+
+/* Offered-load ceiling backstop while source-limited. */
+static void mb_ecn_ceiling(struct mb_ecn_ctx * ctx)
+{
+ unsigned code;
+ uint64_t hi;
+
+ if (ctx->backlogged) {
+ ctx->src_limited = false;
+ return;
+ }
+
+ code = (unsigned) cap_enc(ctx->snd_rate) + CA_HDRM_MARKS;
+ if (code > UINT8_MAX) /* keep the cast lossless */
+ code = UINT8_MAX;
+
+ hi = cap_dec((uint8_t) code);
+ if (hi > CA_RATE_MAX)
+ hi = CA_RATE_MAX;
+
+ if (hi < CA_RATE_MIN)
+ hi = CA_RATE_MIN;
+
+ ctx->src_limited = ctx->rate > hi;
+ if (ctx->src_limited) {
+ ctx->rate = hi;
+ ctx->r_bkt = ctx->rate;
+ }
+}
+
+static void mb_ecn_ctrl(struct mb_ecn_ctx * ctx,
+ uint64_t dtc)
+{
+ uint64_t dta;
+ uint64_t lo;
+
+ /* AI and slow start bank at most CA_DT_CAP of idle time. */
+ dta = MIN(dtc, (uint64_t) CA_DT_CAP);
+
+ ctx->n_ctrl++;
+ ctx->t_ctrl += dtc;
+ ctx->t_bank += dta;
+
+ if (ctx->tx_cav) {
+ mb_ecn_increase(ctx, dta);
+ mb_ecn_decrease(ctx, dtc);
+ mb_ecn_washout(ctx, dtc, dta);
+ } else {
+ mb_ecn_slow_start(ctx, dta);
+ }
+
+ mb_ecn_ceiling(ctx);
+
+ /* Capacity floor only while backlogged; else the absolute floor. */
+ lo = ctx->backlogged ? ctx->rate_min : (uint64_t) CA_RATE_MIN;
+ if (ctx->rate < lo)
+ ctx->rate = lo;
+
+ if (ctx->rate > CA_RATE_MAX)
+ ctx->rate = CA_RATE_MAX;
+
+ ctx->inv_rate = mb_ecn_rate_inv(ctx->rate);
+ ctx->ece_ttl = mb_ecn_ece_ttl(ctx->rate);
+
+ if (!ctx->tx_cav && ctx->rate > ctx->ss_peak)
+ ctx->ss_peak = ctx->rate;
+}
+
+/* Fold offered into the max filter: rise at once, decay 1/16 per window. */
+static void mb_ecn_offered(struct mb_ecn_ctx * ctx,
+ uint64_t offered,
+ uint64_t elapsed)
+{
+ uint64_t n;
+
+ if (offered >= ctx->snd_rate) {
+ ctx->snd_rate = offered;
+ return;
+ }
+
+ n = MIN(elapsed / CA_SND_WIN, CA_SND_DEC_CAP);
+ while (n-- > 0 && ctx->snd_rate > offered)
+ ctx->snd_rate -= (ctx->snd_rate - offered) >> CA_SND_DEC_SHFT;
+}
+
+/*
+ * Close the utilisation window: set backlogged from the level test,
+ * fold offered into the max filter, then reset the window.
+ */
+static void mb_ecn_win(struct mb_ecn_ctx * ctx,
+ uint64_t t)
+{
+ uint64_t elapsed = t - ctx->snd_win;
+ uint64_t offered;
+
+ offered = ctx->snd_byt * BILLION / elapsed;
+
+ ctx->backlogged = offered * CA_USE_DEN >= ctx->snd_r0 * CA_USE_NUM;
+
+ mb_ecn_offered(ctx, offered, elapsed);
+
+ if (ctx->backlogged)
+ ctx->src_limited = false;
+
+ ctx->snd_win = t;
+ ctx->snd_byt = 0;
+ ctx->snd_r0 = ctx->rate;
+}
+
+/* Age out congestion, local-mark and capacity signals once stale. */
+static void mb_ecn_age(struct mb_ecn_ctx * ctx,
+ uint64_t t)
+{
+ if (t - ctx->last_fb > ctx->ece_ttl) {
+ if (ctx->tx_ece > 0)
+ ctx->n_ttl++;
+ ctx->tx_ece = 0;
+ }
+
+ if (t - ctx->last_loc > ctx->ece_ttl)
+ ctx->tx_loc = 0;
+
+ /* Stale capacity: fall back to the compile-time defaults. */
+ if (t - ctx->last_cap > ctx->ece_ttl << CA_CAP_TTL_SHFT) {
+ ctx->rate_min = CA_RATE_MIN;
+ ctx->ai_rate = CA_AI_RATE;
+ ctx->tx_cap = 0;
+ }
+}
+
+/* Advance the virtual clock; a gap past CA_DT_CAP credits a burst. */
+static void mb_ecn_advance(struct mb_ecn_ctx * ctx,
+ uint64_t dt,
+ size_t len,
+ uint64_t ftag)
+{
+ uint64_t burst;
+ uint64_t owed;
+
+ if (dt <= (uint64_t) CA_DT_CAP) {
+ ctx->vt += ctx->rate * dt / BILLION;
+ return;
+ }
+
+ burst = ctx->rate * CA_DT_CAP / BILLION;
+ if (burst < (uint64_t) len)
+ burst = len;
+
+ owed = ftag > ctx->vt ? ftag - ctx->vt + burst : burst;
+
+ /* Clamp so owed * BILLION cannot wrap (2^33 B backlog). */
+ if (owed > (1ULL << 33))
+ owed = 1ULL << 33;
+
+ if (dt >= owed * BILLION / ctx->rate)
+ ctx->vt += owed;
+ else
+ ctx->vt += ctx->rate * dt / BILLION;
+}
+
+static time_t mb_ecn_snd(struct mb_ecn_ctx * ctx,
+ size_t len,
+ uint64_t t,
+ uint64_t * ftag)
+{
+ uint64_t dt;
+ uint64_t dtc;
+ uint64_t s;
+
+ /* Lazy warm-up seed: packet #1 is never an idle resume. */
+ if (!ctx->started) {
+ ctx->started = true;
+ ctx->last_ts = t;
+ ctx->snd_win = t;
+ ctx->snd_r0 = ctx->rate;
+ }
+
+ dt = t - ctx->last_ts;
+ ctx->last_ts = t;
+
+ mb_ecn_age(ctx, t);
+
+ /* Offered-load estimator: accumulate, gate growth, size ceiling. */
+ ctx->snd_byt += len;
+ if (ctx->snd_byt > (uint64_t) CA_SND_BYT_MAX)
+ ctx->snd_byt = CA_SND_BYT_MAX;
+
+ if (dt > (uint64_t) CA_DT_CAP)
+ ctx->backlogged = false;
+
+ if (t - ctx->snd_win >= (uint64_t) CA_SND_WIN)
+ mb_ecn_win(ctx, t);
+
+ /* Rate update before the vt advance: burst uses the clamped rate. */
+ dtc = t - ctx->last_ctrl;
+ if (dtc >= (uint64_t) CA_DT_CTRL) {
+ ctx->last_ctrl = t;
+ mb_ecn_ctrl(ctx, dtc);
+ }
+
+ mb_ecn_advance(ctx, dt, len, *ftag);
+
+ /* SFQ start tag: behind the clock starts now, ahead waits. */
+ s = *ftag > ctx->vt ? *ftag : ctx->vt;
+ *ftag = s + len;
+
+ ctx->lead = s - ctx->vt;
+
+ /* Reciprocal pacing; folded so any lead * rate stays in range. */
+ if (s > ctx->vt)
+ return (time_t) ((ctx->lead * (ctx->inv_rate >> 16))
+ >> (CA_INV_SHFT - 16));
+
+ return 0;
+}
+
+time_t mb_ecn_ctx_update_snd(void * _ctx,
+ size_t len,
+ uint8_t lecn,
+ uint64_t * ftag)
+{
+ struct timespec now;
+ uint64_t t;
+ struct mb_ecn_ctx * ctx = _ctx;
+
+ clock_gettime(PTHREAD_COND_CLOCK, &now);
+
+ t = TS_TO_UINT64(now);
+
+ mb_ecn_loc(ctx, lecn, t);
+
+ return mb_ecn_snd(ctx, len, t, ftag);
+}
+
+/* Estimator idle, or a gap past ~4 current windows: restart fresh. */
+static bool mb_ecn_rcv_fresh(const struct mb_ecn_ctx * ctx,
+ uint64_t dt)
+{
+ if (ctx->rx_ece == 0 && ctx->rx_acc == 0)
+ return true;
+
+ return dt > ctx->rx_tw << CA_TW_GAP_SHFT;
+}
+
+/* Size the next averaging window to ~CA_N_TARGET packets at this rate. */
+static void mb_ecn_resize(struct mb_ecn_ctx * ctx,
+ uint64_t win)
+{
+ uint64_t tw = CA_RX_WBYTES * win / ctx->rx_byt;
+
+ if (tw > ctx->rx_tw)
+ ctx->rx_tw += (tw - ctx->rx_tw) >> CA_TW_SM_SHFT;
+ else
+ ctx->rx_tw -= (ctx->rx_tw - tw) >> CA_TW_SM_SHFT;
+
+ if (ctx->rx_tw < CA_TW_MIN)
+ ctx->rx_tw = CA_TW_MIN;
+
+ if (ctx->rx_tw > CA_TW_ABSMAX)
+ ctx->rx_tw = CA_TW_ABSMAX;
+}
+
+static bool mb_ecn_rcv(struct mb_ecn_ctx * ctx,
+ size_t len,
+ uint8_t ecn,
+ uint8_t cap,
+ uint16_t * ece,
+ uint8_t * fcap,
+ uint64_t t)
+{
+ uint64_t dt;
+ uint64_t win;
+
+ dt = t - ctx->rx_ts;
+ ctx->rx_ts = t;
+
+ if (ctx->rx_ece == 0 && ctx->rx_acc == 0 && ecn == 0)
+ return false;
+
+ /* Onset, or ~4 windows of silence: emit fresh, undiluted. */
+ if (mb_ecn_rcv_fresh(ctx, dt)) {
+ ctx->rx_win = t;
+ ctx->rx_acc = 0;
+ ctx->rx_byt = len;
+ ctx->rx_cap = cap; /* fresh, seeds the new window */
+ ctx->rx_ece = (uint16_t) (ecn << CA_SHFT);
+ *ece = ctx->rx_ece;
+ *fcap = ctx->rx_cap;
+ return true;
+ }
+
+ /* Dwell clamp: one packet weighs at most one window of mark. */
+ ctx->rx_acc += ecn * MIN(dt, ctx->rx_tw);
+ ctx->rx_byt += len;
+ ctx->rx_cap = cap_min(ctx->rx_cap, cap);
+
+ *ece = ctx->rx_ece;
+
+ win = t - ctx->rx_win;
+ if (win < ctx->rx_tw) {
+ /* Early close once 2x target bytes arrive (speed-up). */
+ if (ctx->rx_byt < CA_RX_WCLOSE)
+ return false;
+
+ if (win < CA_TW_MIN)
+ return false;
+ }
+
+ /* Time-integral mean over the actual window elapsed (never rx_tw). */
+ ctx->rx_ece = (uint16_t) ((ctx->rx_acc << CA_SHFT) / win);
+
+ if (ctx->rx_byt > 0)
+ mb_ecn_resize(ctx, win);
+
+ *fcap = ctx->rx_cap;
+
+ ctx->rx_win = t;
+ ctx->rx_acc = 0;
+ ctx->rx_byt = 0;
+ ctx->rx_cap = 0; /* the next window starts unknown */
+
+ *ece = ctx->rx_ece;
+
+ return true;
+}
+
+bool mb_ecn_ctx_update_rcv(void * _ctx,
+ size_t len,
+ uint8_t ecn,
+ uint8_t cap,
+ uint16_t * ece,
+ uint8_t * fcap)
+{
+ struct timespec now;
+ struct mb_ecn_ctx * ctx = _ctx;
+
+ clock_gettime(PTHREAD_COND_CLOCK, &now);
+
+ return mb_ecn_rcv(ctx, len, ecn, cap, ece, fcap, TS_TO_UINT64(now));
+}
+
+static void mb_ecn_ece(struct mb_ecn_ctx * ctx,
+ uint16_t ece,
+ uint8_t cap,
+ uint64_t t)
+{
+ uint64_t tgt;
+
+ ctx->tx_ece = ece;
+ ctx->tx_cav = true;
+ ctx->last_fb = t;
+ ctx->n_fb++;
+
+ /* Scale the floor and AI slope to the path bottleneck. */
+ if (cap != 0) {
+ tgt = cap_dec(cap) >> CA_CAP_SHFT;
+ if (tgt < CA_RATE_MIN)
+ tgt = CA_RATE_MIN;
+
+ if (tgt > CA_RMIN_MAX)
+ tgt = CA_RMIN_MAX;
+
+ if (tgt > ctx->rate_min)
+ ctx->rate_min += (tgt - ctx->rate_min)
+ >> CA_CAP_SM_SHFT;
+ else
+ ctx->rate_min -= (ctx->rate_min - tgt)
+ >> CA_CAP_SM_SHFT;
+
+ ctx->ai_rate = ctx->rate_min;
+ ctx->tx_cap = cap;
+ ctx->last_cap = t;
+ ctx->n_cap++;
+ }
+
+ /* Control from the feedback path: a starved sender recovers. */
+ if (t - ctx->last_ctrl < (uint64_t) CA_DT_CTRL)
+ return;
+
+ mb_ecn_ctrl(ctx, t - ctx->last_ctrl);
+
+ ctx->last_ctrl = t;
+}
+
+void mb_ecn_ctx_update_ece(void * _ctx,
+ uint16_t ece,
+ uint8_t cap)
+{
+ struct timespec now;
+ struct mb_ecn_ctx * ctx = _ctx;
+
+ clock_gettime(PTHREAD_COND_CLOCK, &now);
+
+ mb_ecn_ece(ctx, ece, cap, TS_TO_UINT64(now));
+}
+
+int mb_ecn_calc_ecn(size_t queued,
+ uint8_t * ecn,
+ qoscube_t qc,
+ size_t len)
+{
+ size_t q;
+ uint8_t mark;
+
+ (void) len;
+ (void) qc;
+
+ /* Saturate: a queue past 255 quanta must not wrap to a low mark. */
+ q = queued / CA_MARK_Q;
+ mark = q > 255 ? (uint8_t) 255 : (uint8_t) q;
+
+ if (mark > *ecn)
+ *ecn = mark;
+
+ return 0;
+}
+
+ssize_t mb_ecn_print_stats(void * _ctx,
+ char * buf,
+ size_t len)
+{
+ struct mb_ecn_ctx * ctx = _ctx;
+ char * regime;
+ uint64_t rate;
+ uint64_t peak;
+ int code;
+ uint16_t m;
+
+ if (len < 1024)
+ return 0;
+
+ /* No signal seen: the rate is unconstrained drift, not a target. */
+ rate = ctx->tx_cav ? ctx->rate : 0;
+ peak = ctx->tx_cav ? ctx->ss_peak : 0;
+
+ /* Match the controller: MD fires on m, incl. the local fallback. */
+ m = ctx->tx_ece > 0 ? ctx->tx_ece
+ : (uint16_t) (ctx->tx_loc << CA_SHFT);
+
+ if (!ctx->tx_cav) {
+ regime = "Slow start";
+ code = 0;
+ } else if (ctx->src_limited) {
+ regime = "Source limited";
+ code = 3;
+ } else if (m > 0) {
+ regime = "Proportional dec";
+ code = 2;
+ } else {
+ regime = "Additive inc";
+ code = 1;
+ }
+
+ sprintf(buf,
+ "Congestion avoidance algorithm: %20s\n"
+ "Upstream congestion level: %20u\n"
+ "Downstream congestion level: %20u\n"
+ "Paced rate (bytes/s): %20" PRIu64 "\n"
+ "Pacer lead (bytes): %20" PRIu64 "\n"
+ "Congestion regime (code): %20d\n"
+ "Current congestion regime: %20s\n"
+ "Control steps (count): %20" PRIu64 "\n"
+ "Control time elapsed (ns): %20" PRIu64 "\n"
+ "Control time banked (ns): %20" PRIu64 "\n"
+ "Feedback updates (count): %20" PRIu64 "\n"
+ "Feedback timeouts (count): %20" PRIu64 "\n"
+ "Path capacity (bytes/s): %20" PRIu64 "\n"
+ "Capacity rate floor (bytes/s): %20" PRIu64 "\n"
+ "Capacity updates (count): %20" PRIu64 "\n"
+ "Slow start peak rate (bytes/s): %20" PRIu64 "\n",
+ "Multi-bit ECN",
+ ctx->tx_ece,
+ ctx->rx_ece,
+ rate, ctx->lead, code,
+ regime,
+ ctx->n_ctrl, ctx->t_ctrl, ctx->t_bank,
+ ctx->n_fb, ctx->n_ttl,
+ cap_dec(ctx->tx_cap), ctx->rate_min, ctx->n_cap,
+ peak);
+
+ return strlen(buf);
+}
diff --git a/src/ipcpd/unicast/ca/mb-ecn.h b/src/ipcpd/unicast/ca/mb-ecn.h
new file mode 100644
index 00000000..7bf0b29f
--- /dev/null
+++ b/src/ipcpd/unicast/ca/mb-ecn.h
@@ -0,0 +1,59 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Multi-bit ECN Congestion Avoidance
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#ifndef OUROBOROS_IPCPD_UNICAST_CA_MB_ECN_H
+#define OUROBOROS_IPCPD_UNICAST_CA_MB_ECN_H
+
+#include "ops.h"
+
+void * mb_ecn_ctx_create(void);
+
+void mb_ecn_ctx_destroy(void * ctx);
+
+time_t mb_ecn_ctx_update_snd(void * ctx,
+ size_t len,
+ uint8_t lecn,
+ uint64_t * ftag);
+
+bool mb_ecn_ctx_update_rcv(void * ctx,
+ size_t len,
+ uint8_t ecn,
+ uint8_t cap,
+ uint16_t * ece,
+ uint8_t * fcap);
+
+void mb_ecn_ctx_update_ece(void * ctx,
+ uint16_t ece,
+ uint8_t cap);
+
+int mb_ecn_calc_ecn(size_t queued,
+ uint8_t * ecn,
+ qoscube_t qc,
+ size_t len);
+
+ssize_t mb_ecn_print_stats(void * ctx,
+ char * buf,
+ size_t len);
+
+extern struct ca_ops mb_ecn_ca_ops;
+
+#endif /* OUROBOROS_IPCPD_UNICAST_CA_MB_ECN_H */
diff --git a/src/ipcpd/unicast/ca/nop.c b/src/ipcpd/unicast/ca/nop.c
new file mode 100644
index 00000000..e6965297
--- /dev/null
+++ b/src/ipcpd/unicast/ca/nop.c
@@ -0,0 +1,99 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Dummy Congestion Avoidance
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#include "nop.h"
+
+#include <string.h>
+
+struct ca_ops nop_ca_ops = {
+ .ctx_create = nop_ctx_create,
+ .ctx_destroy = nop_ctx_destroy,
+ .ctx_update_snd = nop_ctx_update_snd,
+ .ctx_update_rcv = nop_ctx_update_rcv,
+ .ctx_update_ece = nop_ctx_update_ece,
+ .calc_ecn = nop_calc_ecn,
+ .marks_ecn = false,
+ .print_stats = NULL
+};
+
+void * nop_ctx_create(void)
+{
+ return (void *) 1;
+}
+
+void nop_ctx_destroy(void * ctx)
+{
+ (void) ctx;
+}
+
+time_t nop_ctx_update_snd(void * ctx,
+ size_t len,
+ uint8_t lecn,
+ uint64_t * ftag)
+{
+ (void) ctx;
+ (void) len;
+ (void) lecn;
+ (void) ftag;
+
+ return 0;
+}
+
+bool nop_ctx_update_rcv(void * ctx,
+ size_t len,
+ uint8_t ecn,
+ uint8_t cap,
+ uint16_t * ece,
+ uint8_t * fcap)
+{
+ (void) ctx;
+ (void) len;
+ (void) ecn;
+ (void) cap;
+ (void) ece;
+ (void) fcap;
+
+ return false;
+}
+
+void nop_ctx_update_ece(void * ctx,
+ uint16_t ece,
+ uint8_t cap)
+{
+ (void) ctx;
+ (void) ece;
+ (void) cap;
+}
+
+
+int nop_calc_ecn(size_t queued,
+ uint8_t * ecn,
+ qoscube_t qc,
+ size_t len)
+{
+ (void) queued;
+ (void) len;
+ (void) ecn;
+ (void) qc;
+
+ return 0;
+}
diff --git a/src/ipcpd/unicast/ca/nop.h b/src/ipcpd/unicast/ca/nop.h
new file mode 100644
index 00000000..6ca206df
--- /dev/null
+++ b/src/ipcpd/unicast/ca/nop.h
@@ -0,0 +1,55 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Dummy Congestion Avoidance
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#ifndef OUROBOROS_IPCPD_UNICAST_CA_NOP_H
+#define OUROBOROS_IPCPD_UNICAST_CA_NOP_H
+
+#include "ops.h"
+
+void * nop_ctx_create(void);
+
+void nop_ctx_destroy(void * ctx);
+
+time_t nop_ctx_update_snd(void * ctx,
+ size_t len,
+ uint8_t lecn,
+ uint64_t * ftag);
+
+bool nop_ctx_update_rcv(void * ctx,
+ size_t len,
+ uint8_t ecn,
+ uint8_t cap,
+ uint16_t * ece,
+ uint8_t * fcap);
+
+void nop_ctx_update_ece(void * ctx,
+ uint16_t ece,
+ uint8_t cap);
+
+int nop_calc_ecn(size_t queued,
+ uint8_t * ecn,
+ qoscube_t qc,
+ size_t len);
+
+extern struct ca_ops nop_ca_ops;
+
+#endif /* OUROBOROS_IPCPD_UNICAST_CA_NOP_H */
diff --git a/src/ipcpd/unicast/ca/ops.h b/src/ipcpd/unicast/ca/ops.h
new file mode 100644
index 00000000..a86c1b3b
--- /dev/null
+++ b/src/ipcpd/unicast/ca/ops.h
@@ -0,0 +1,64 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Congestion avoidance policy ops
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#ifndef OUROBOROS_IPCPD_UNICAST_CA_OPS_H
+#define OUROBOROS_IPCPD_UNICAST_CA_OPS_H
+
+#include "ca.h"
+
+struct ca_ops {
+ void * (* ctx_create)(void);
+
+ void (* ctx_destroy)(void * ctx);
+
+ time_t (* ctx_update_snd)(void * ctx,
+ size_t len,
+ uint8_t lecn,
+ uint64_t * ftag);
+
+ bool (* ctx_update_rcv)(void * ctx,
+ size_t len,
+ uint8_t ecn,
+ uint8_t cap,
+ uint16_t * ece,
+ uint8_t * fcap);
+
+ void (* ctx_update_ece)(void * ctx,
+ uint16_t ece,
+ uint8_t cap);
+
+ int (* calc_ecn)(size_t queued,
+ uint8_t * ecn,
+ qoscube_t qc,
+ size_t len);
+
+ /* True if calc_ecn inspects the queue; gates the lookup. */
+ bool marks_ecn;
+
+ /* Optional, can be NULL */
+ ssize_t (* print_stats)(void * ctx,
+ char * buf,
+ size_t len);
+
+};
+
+#endif /* OUROBOROS_IPCPD_UNICAST_CA_OPS_H */
diff --git a/src/ipcpd/unicast/ca/pol.h b/src/ipcpd/unicast/ca/pol.h
new file mode 100644
index 00000000..bfb9cc2d
--- /dev/null
+++ b/src/ipcpd/unicast/ca/pol.h
@@ -0,0 +1,24 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Congestion avoidance policies
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#include "mb-ecn.h"
+#include "nop.h"
diff --git a/src/ipcpd/unicast/ca/tests/CMakeLists.txt b/src/ipcpd/unicast/ca/tests/CMakeLists.txt
new file mode 100644
index 00000000..6e42163d
--- /dev/null
+++ b/src/ipcpd/unicast/ca/tests/CMakeLists.txt
@@ -0,0 +1,44 @@
+get_filename_component(CURRENT_SOURCE_PARENT_DIR
+ ${CMAKE_CURRENT_SOURCE_DIR} DIRECTORY)
+get_filename_component(CURRENT_BINARY_PARENT_DIR
+ ${CMAKE_CURRENT_BINARY_DIR} DIRECTORY)
+
+get_filename_component(UNICAST_SOURCE_DIR ${CURRENT_SOURCE_PARENT_DIR} DIRECTORY)
+get_filename_component(UNICAST_BINARY_DIR ${CURRENT_BINARY_PARENT_DIR} DIRECTORY)
+
+get_filename_component(PARENT_PATH ${CMAKE_CURRENT_SOURCE_DIR} DIRECTORY)
+get_filename_component(PARENT_DIR ${PARENT_PATH} NAME)
+
+compute_test_prefix()
+
+create_test_sourcelist(${PARENT_DIR}_tests test_suite.c
+ # Add new tests here
+ mb_ecn_test.c
+ ca_test.c
+ )
+
+add_executable(${PARENT_DIR}_test ${${PARENT_DIR}_tests}
+ ${UNICAST_SOURCE_DIR}/ca.c
+ ${UNICAST_SOURCE_DIR}/cap.c
+ ${CURRENT_SOURCE_PARENT_DIR}/nop.c
+ )
+
+target_include_directories(${PARENT_DIR}_test PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_CURRENT_BINARY_DIR}
+ ${CURRENT_SOURCE_PARENT_DIR}
+ ${CURRENT_BINARY_PARENT_DIR}
+ ${UNICAST_SOURCE_DIR}
+ ${UNICAST_BINARY_DIR}
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_BINARY_DIR}/include
+ ${CMAKE_SOURCE_DIR}/src/ipcpd
+ ${CMAKE_BINARY_DIR}/src/ipcpd
+)
+
+disable_test_logging_for_target(${PARENT_DIR}_test)
+target_link_libraries(${PARENT_DIR}_test PRIVATE ouroboros-common)
+
+add_dependencies(build_tests ${PARENT_DIR}_test)
+
+ouroboros_register_tests(TARGET ${PARENT_DIR}_test TESTS ${${PARENT_DIR}_tests})
diff --git a/src/ipcpd/unicast/ca/tests/ca_test.c b/src/ipcpd/unicast/ca/tests/ca_test.c
new file mode 100644
index 00000000..4c44d29e
--- /dev/null
+++ b/src/ipcpd/unicast/ca/tests/ca_test.c
@@ -0,0 +1,392 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Unit tests for the congestion-avoidance interface
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#include "config.h"
+
+#include "ca.h"
+
+#include <test/test.h>
+
+#define ADDR_A 0x1111ULL
+#define ADDR_B 0x2222ULL
+
+static const struct {
+ enum pol_cong_avoid pol;
+ const char * name;
+} ca_pols[] = {
+ { CA_NONE, "none" },
+ { CA_MB_ECN, "mb-ecn" }
+};
+
+#define CA_POLS (sizeof(ca_pols) / sizeof(ca_pols[0]))
+
+static int test_ca_init_fini(enum pol_cong_avoid pol,
+ const char * name)
+{
+ TEST_START("(%s)", name);
+
+ if (ca_init(pol) < 0) {
+ printf("Failed to init ca for %s.\n", name);
+ goto fail;
+ }
+
+ ca_fini();
+
+ TEST_SUCCESS("(%s)", name);
+
+ return TEST_RC_SUCCESS;
+ fail:
+ TEST_FAIL("(%s)", name);
+ return TEST_RC_FAIL;
+}
+
+static int test_ca_init_fini_all(void)
+{
+ int ret = 0;
+ size_t i;
+
+ for (i = 0; i < CA_POLS; i++)
+ ret |= test_ca_init_fini(ca_pols[i].pol, ca_pols[i].name);
+
+ return ret;
+}
+
+static int test_ca_init_invalid(void)
+{
+ TEST_START();
+
+ if (ca_init(CA_INVALID) == 0) {
+ printf("Init accepted an invalid policy.\n");
+ ca_fini();
+ goto fail;
+ }
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_ca_ctx_share(enum pol_cong_avoid pol,
+ const char * name)
+{
+ void * c1;
+ void * c2;
+
+ TEST_START("(%s)", name);
+
+ if (ca_init(pol) < 0) {
+ printf("Failed to init ca for %s.\n", name);
+ goto fail;
+ }
+
+ c1 = ca_ctx_get(ADDR_A, QOS_CUBE_BE);
+ if (c1 == NULL) {
+ printf("Failed to get ctx.\n");
+ goto fail_init;
+ }
+
+ c2 = ca_ctx_get(ADDR_A, QOS_CUBE_BE);
+ if (c2 == NULL) {
+ printf("Failed to get second ctx.\n");
+ goto fail_c1;
+ }
+
+#ifdef IPCP_CA_PER_FLOW
+ if (c1 == c2) {
+ printf("Per-flow build shared a ctx across flows.\n");
+ goto fail_c2;
+ }
+#else
+ if (c1 != c2) {
+ printf("Aggregate build did not share ctx per (addr, qc).\n");
+ goto fail_c2;
+ }
+#endif
+ ca_ctx_put(c2);
+ ca_ctx_put(c1);
+
+ ca_fini();
+
+ TEST_SUCCESS("(%s)", name);
+
+ return TEST_RC_SUCCESS;
+ fail_c2:
+ ca_ctx_put(c2);
+ fail_c1:
+ ca_ctx_put(c1);
+ fail_init:
+ ca_fini();
+ fail:
+ TEST_FAIL("(%s)", name);
+ return TEST_RC_FAIL;
+}
+
+static int test_ca_ctx_share_all(void)
+{
+ int ret = 0;
+ size_t i;
+
+ for (i = 0; i < CA_POLS; i++)
+ ret |= test_ca_ctx_share(ca_pols[i].pol, ca_pols[i].name);
+
+ return ret;
+}
+
+static int test_ca_ctx_distinct(void)
+{
+ void * a_be;
+ void * b_be;
+ void * a_video;
+
+ TEST_START();
+
+ if (ca_init(CA_NONE) < 0) {
+ printf("Failed to init ca.\n");
+ goto fail;
+ }
+
+ a_be = ca_ctx_get(ADDR_A, QOS_CUBE_BE);
+ if (a_be == NULL) {
+ printf("Failed to get ctx.\n");
+ goto fail_init;
+ }
+
+ b_be = ca_ctx_get(ADDR_B, QOS_CUBE_BE);
+ if (b_be == NULL) {
+ printf("Failed to get ctx.\n");
+ goto fail_a_be;
+ }
+
+ a_video = ca_ctx_get(ADDR_A, QOS_CUBE_VIDEO);
+ if (a_video == NULL) {
+ printf("Failed to get ctx.\n");
+ goto fail_b_be;
+ }
+
+ if (a_be == b_be) {
+ printf("Distinct addresses shared a ctx.\n");
+ goto fail_a_video;
+ }
+
+ if (a_be == a_video) {
+ printf("Distinct qos cubes shared a ctx.\n");
+ goto fail_a_video;
+ }
+
+ ca_ctx_put(a_video);
+ ca_ctx_put(b_be);
+ ca_ctx_put(a_be);
+
+ ca_fini();
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_a_video:
+ ca_ctx_put(a_video);
+ fail_b_be:
+ ca_ctx_put(b_be);
+ fail_a_be:
+ ca_ctx_put(a_be);
+ fail_init:
+ ca_fini();
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Refcount survival is an aggregate-only property. */
+#ifndef IPCP_CA_PER_FLOW
+static int test_ca_ctx_refcount(void)
+{
+ void * c1;
+ void * c3;
+
+ TEST_START();
+
+ if (ca_init(CA_NONE) < 0) {
+ printf("Failed to init ca.\n");
+ goto fail;
+ }
+
+ c1 = ca_ctx_get(ADDR_A, QOS_CUBE_BE); /* refs = 1 */
+ if (c1 == NULL) {
+ printf("Failed to get ctx.\n");
+ goto fail_init;
+ }
+
+ if (ca_ctx_get(ADDR_A, QOS_CUBE_BE) == NULL) { /* refs = 2 */
+ printf("Failed to get second ref.\n");
+ goto fail_c1;
+ }
+
+ ca_ctx_put(c1); /* refs = 1 */
+
+ c3 = ca_ctx_get(ADDR_A, QOS_CUBE_BE); /* refs = 2 */
+ if (c3 == NULL) {
+ printf("Failed to get third ref.\n");
+ goto fail_c1;
+ }
+
+ if (c3 != c1) {
+ printf("Refcounted ctx freed while still referenced.\n");
+ goto fail_c3;
+ }
+
+ ca_ctx_put(c3);
+ ca_ctx_put(c1);
+
+ ca_fini();
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_c3:
+ ca_ctx_put(c3);
+ fail_c1:
+ ca_ctx_put(c1);
+ fail_init:
+ ca_fini();
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* The last put frees the interned ctx; a fresh get recreates it. */
+static int test_ca_ctx_recreate(void)
+{
+ void * c1;
+ void * c2;
+ void * c3;
+
+ TEST_START();
+
+ if (ca_init(CA_NONE) < 0) {
+ printf("Failed to init ca.\n");
+ goto fail;
+ }
+
+ c1 = ca_ctx_get(ADDR_A, QOS_CUBE_BE); /* refs = 1 */
+ if (c1 == NULL) {
+ printf("Failed to get ctx.\n");
+ goto fail_init;
+ }
+
+ ca_ctx_put(c1); /* refs = 0: freed and de-interned */
+
+ c2 = ca_ctx_get(ADDR_A, QOS_CUBE_BE); /* fresh entry */
+ if (c2 == NULL) {
+ printf("Get after release did not recreate.\n");
+ goto fail_init;
+ }
+
+ c3 = ca_ctx_get(ADDR_A, QOS_CUBE_BE); /* refs = 2: shares */
+ if (c3 == NULL) {
+ printf("Failed to share recreated ctx.\n");
+ goto fail_c2;
+ }
+
+ if (c3 != c2) {
+ printf("Recreated ctx did not intern.\n");
+ goto fail_c3;
+ }
+
+ ca_ctx_put(c3);
+ ca_ctx_put(c2);
+
+ ca_fini();
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_c3:
+ ca_ctx_put(c3);
+ fail_c2:
+ ca_ctx_put(c2);
+ fail_init:
+ ca_fini();
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* ca_fini drains a ctx a flow left interned, with no leak. */
+static int test_ca_fini_drains(void)
+{
+ void * c1;
+ void * c2;
+
+ TEST_START();
+
+ if (ca_init(CA_NONE) < 0) {
+ printf("Failed to init ca.\n");
+ goto fail;
+ }
+
+ c1 = ca_ctx_get(ADDR_A, QOS_CUBE_BE); /* refs = 1 */
+ if (c1 == NULL) {
+ printf("Failed to get ctx.\n");
+ goto fail_init;
+ }
+
+ c2 = ca_ctx_get(ADDR_A, QOS_CUBE_BE); /* refs = 2 */
+ if (c2 == NULL) {
+ printf("Failed to get second ref.\n");
+ goto fail_init;
+ }
+
+ /* Leave both refs live: ca_fini must drain and free the ctx. */
+ ca_fini();
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_init:
+ ca_fini();
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+#endif /* !IPCP_CA_PER_FLOW */
+
+int ca_test(int argc,
+ char ** argv)
+{
+ int ret = 0;
+
+ (void) argc;
+ (void) argv;
+
+ ret |= test_ca_init_fini_all();
+ ret |= test_ca_init_invalid();
+ ret |= test_ca_ctx_share_all();
+ ret |= test_ca_ctx_distinct();
+#ifndef IPCP_CA_PER_FLOW
+ ret |= test_ca_ctx_refcount();
+ ret |= test_ca_ctx_recreate();
+ ret |= test_ca_fini_drains();
+#endif
+ return ret;
+}
diff --git a/src/ipcpd/unicast/ca/tests/mb_ecn_test.c b/src/ipcpd/unicast/ca/tests/mb_ecn_test.c
new file mode 100644
index 00000000..8e3a73da
--- /dev/null
+++ b/src/ipcpd/unicast/ca/tests/mb_ecn_test.c
@@ -0,0 +1,2588 @@
+/*
+ * Ouroboros - Copyright (C) 2016 - 2026
+ *
+ * Unit tests for multi-bit ECN congestion avoidance
+ *
+ * Dimitri Staessens <dimitri@ouroboros.rocks>
+ * Sander Vrijders <sander@ouroboros.rocks>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., http://www.fsf.org/about/contact/.
+ */
+
+#include "mb-ecn.c"
+
+#include <test/test.h>
+
+#define MS (MILLION) /* one millisecond in ns */
+#define LEN 1000 /* default packet size (bytes) */
+
+/* Create a context with the clock zeroed for deterministic time steps. */
+static struct mb_ecn_ctx * mk_ctx(void)
+{
+ struct mb_ecn_ctx * ctx;
+
+ ctx = mb_ecn_ctx_create();
+ if (ctx == NULL)
+ return NULL;
+
+ ctx->rx_ts = 0;
+ ctx->rx_win = 0;
+ ctx->last_ts = 0;
+ ctx->last_ctrl = 0;
+ ctx->last_fb = 0;
+ ctx->last_loc = 0;
+ ctx->last_cap = 0;
+
+ ctx->snd_byt = 0;
+ ctx->snd_win = 0;
+ ctx->snd_r0 = CA_RATE_INIT;
+ ctx->snd_rate = CA_RATE_INIT;
+ ctx->backlogged = true;
+ ctx->src_limited = false;
+ ctx->started = false;
+
+ return ctx;
+}
+
+/*
+ * Drive ctx as a fully backlogged flow: offer a packet every paced
+ * wait, so the offered load tracks the paced rate. Returns end time.
+ */
+static uint64_t drive_backlogged(struct mb_ecn_ctx * ctx,
+ uint64_t * ftag,
+ uint64_t t,
+ uint64_t dur,
+ size_t len)
+{
+ uint64_t end = t + dur;
+ time_t w;
+
+ while (t < end) {
+ w = mb_ecn_snd(ctx, len, t, ftag);
+ t += w > 0 ? (uint64_t) w : 1;
+ }
+
+ return t;
+}
+
+static int test_mb_ecn_ctx_create_destroy(void)
+{
+ struct mb_ecn_ctx * ctx;
+
+ TEST_START();
+
+ ctx = mb_ecn_ctx_create();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ if (ctx->rate != CA_RATE_INIT) {
+ printf("Bad initial rate %" PRIu64 ".\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ if (ctx->rate_min != CA_RATE_MIN) {
+ printf("Bad initial floor %" PRIu64 ".\n", ctx->rate_min);
+ goto fail_ctx;
+ }
+
+ if (ctx->vt != 0) {
+ printf("Bad initial virtual clock %" PRIu64 ".\n", ctx->vt);
+ goto fail_ctx;
+ }
+
+ if (ctx->tx_cav) {
+ printf("Context did not start in slow start.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_mb_ecn_calc_ecn(void)
+{
+ uint8_t ecn;
+
+ TEST_START();
+
+ /* A queue below one ECN quantum marks nothing. */
+ ecn = 0;
+ mb_ecn_calc_ecn(CA_MARK_Q - 1, &ecn, QOS_CUBE_BE, 0);
+ if (ecn != 0) {
+ printf("Sub-quantum queue marked %u.\n", ecn);
+ goto fail;
+ }
+
+ /* Queue depth maps to ecn = queued / CA_MARK_Q. */
+ ecn = 0;
+ mb_ecn_calc_ecn(5 * CA_MARK_Q, &ecn, QOS_CUBE_BE, 0);
+ if (ecn != 5) {
+ printf("Expected ecn 5, got %u.\n", ecn);
+ goto fail;
+ }
+
+ /* MAX keeps the larger value; a smaller mark cannot raise it. */
+ ecn = 0x80;
+ mb_ecn_calc_ecn(CA_MARK_Q, &ecn, QOS_CUBE_BE, 0);
+ if (ecn != 0x80) {
+ printf("Expected ecn 0x80, got 0x%x.\n", ecn);
+ goto fail;
+ }
+
+ ecn = 3;
+ mb_ecn_calc_ecn(4 * CA_MARK_Q, &ecn, QOS_CUBE_BE, 0);
+ if (ecn != 4) {
+ printf("Expected ecn 4, got %u.\n", ecn);
+ goto fail;
+ }
+
+ /* A queue past 255 quanta saturates, it does not wrap to a low mark. */
+ ecn = 0;
+ mb_ecn_calc_ecn(256 * CA_MARK_Q, &ecn, QOS_CUBE_BE, 0);
+ if (ecn != 255) {
+ printf("Deep queue wrapped: exp 255, got %u.\n", ecn);
+ goto fail;
+ }
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* The first mark after idle emits the raw value with zero latency. */
+static int test_mb_ecn_rcv_onset_immediate(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ if (!mb_ecn_rcv(ctx, LEN, 4, 0, &ece, &fcap, MS)) {
+ printf("Onset did not update.\n");
+ goto fail_ctx;
+ }
+
+ if (ece != 4 << CA_SHFT) {
+ printf("Onset ece: exp %u, got %u.\n", 4 << CA_SHFT, ece);
+ goto fail_ctx;
+ }
+
+ if (mb_ecn_rcv(ctx, LEN, 4, 0, &ece, &fcap, 2 * MS)) {
+ printf("Mid-window packet updated.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* A 50% duty mark square wave emits the time mean, not the last peak. */
+static int test_mb_ecn_rcv_window_mean(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ size_t upd;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ mb_ecn_rcv(ctx, LEN, 8, 0, &ece, &fcap, MS);
+
+ /* The byte trigger closes every ~32 packets: two windows. */
+ upd = 0;
+ for (i = 1; i <= 68; i++) {
+ time_t ecn = (i & 1) ? 8 : 0;
+ time_t t = MS + i * MS;
+ if (mb_ecn_rcv(ctx, LEN, ecn, 0, &ece, &fcap, t))
+ upd++;
+ }
+
+ if (upd != 2) {
+ printf("%zu updates in two windows.\n", upd);
+ goto fail_ctx;
+ }
+
+ if (ece < 120 || ece > 136) {
+ printf("window mean: exp ~128, got %u.\n", ece);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * Two flows on the same wall-clock mark timeline, 15x apart in byte
+ * rate: the same congestion estimate, but the faster flow's window is
+ * shorter, so it feeds back more often (cadence tracks byte rate).
+ */
+static int test_mb_ecn_rcv_rate_independent(void)
+{
+ struct mb_ecn_ctx * a;
+ struct mb_ecn_ctx * b;
+ uint16_t ea;
+ uint16_t eb;
+ uint8_t fcap;
+ size_t ua;
+ size_t ub;
+ size_t i;
+
+ TEST_START();
+
+ a = mk_ctx();
+ b = mk_ctx();
+ if (a == NULL || b == NULL) {
+ printf("Failed to create contexts.\n");
+ goto fail_ctx;
+ }
+
+ ea = 0;
+ eb = 0;
+ ua = 0;
+ ub = 0;
+
+ /* 300 ms of sustained mark 8; a at 1 kpps, b at ~66 pps. */
+ for (i = 1; i <= 300; i++) {
+ ua += mb_ecn_rcv(a, LEN, 8, 0, &ea, &fcap, i * MS) ? 1 : 0;
+ if (i % 15 != 0)
+ continue;
+
+ ub += mb_ecn_rcv(b, LEN, 8, 0, &eb, &fcap, i * MS) ? 1 : 0;
+ }
+
+ if (ea > eb + 32 || eb > ea + 32) {
+ printf("estimates diverge: %u vs %u.\n", ea, eb);
+ goto fail_ctx;
+ }
+
+ if (ua < ub + 2) {
+ printf("cadence not rate-scaled: %zu vs %zu.\n", ua, ub);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * Two flows on one bottleneck, equal byte rate but 7.5x apart in
+ * packet size: the same congestion estimate and the same feedback
+ * cadence. Framing does not skew the control signal (fair share).
+ */
+static int test_mb_ecn_rcv_size_fair(void)
+{
+ struct mb_ecn_ctx * a;
+ struct mb_ecn_ctx * b;
+ uint16_t ea;
+ uint16_t eb;
+ uint8_t fcap;
+ size_t ua;
+ size_t ub;
+ uint64_t ta;
+ uint64_t tb;
+
+ TEST_START();
+
+ a = mk_ctx();
+ b = mk_ctx();
+ if (a == NULL || b == NULL) {
+ printf("Failed to create contexts.\n");
+ goto fail_ctx;
+ }
+
+ ea = 0;
+ eb = 0;
+ ua = 0;
+ ub = 0;
+ ta = 0;
+ tb = 0;
+
+ /* 1 MB/s each: a at 200 B / 200 us, b at 1500 B / 1.5 ms. */
+ while (ta < 500 * MS) {
+ ta += 200 * 1000;
+ ua += mb_ecn_rcv(a, 200, 8, 0, &ea, &fcap, ta) ? 1 : 0;
+ }
+
+ while (tb < 500 * MS) {
+ tb += 1500 * 1000;
+ ub += mb_ecn_rcv(b, 1500, 8, 0, &eb, &fcap, tb) ? 1 : 0;
+ }
+
+ if (ea != 8 << CA_SHFT || eb != 8 << CA_SHFT) {
+ printf("size-skewed estimate: %u vs %u.\n", ea, eb);
+ goto fail_ctx;
+ }
+
+ if (ua > ub + 3 || ub > ua + 3) {
+ printf("cadence skewed by size: %zu vs %zu.\n", ua, ub);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Release emits exactly one 0 and leaves the estimator fully idle. */
+static int test_mb_ecn_rcv_release_exact_zero(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ size_t ends;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ for (i = 1; i <= 140; i++)
+ mb_ecn_rcv(ctx, LEN, 15, 0, &ece, &fcap, i * MS);
+
+ ends = 0;
+ for (i = 141; i <= 350; i++) {
+ if (!mb_ecn_rcv(ctx, LEN, 0, 0, &ece, &fcap, i * MS))
+ continue;
+
+ if (ece == 0)
+ ends++;
+ }
+
+ if (ends != 1) {
+ printf("end of congestion fired %zu times.\n", ends);
+ goto fail_ctx;
+ }
+
+ if (ctx->rx_ece != 0 || ctx->rx_acc != 0) {
+ printf("estimator not idle: ece %u acc %" PRIu64 ".\n",
+ ctx->rx_ece, ctx->rx_acc);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* A gap past the window restarts fresh: no stale, diluted estimate. */
+static int test_mb_ecn_rcv_gap_restart(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ uint64_t t;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ mb_ecn_rcv(ctx, LEN, 6, 0, &ece, &fcap, MS);
+ mb_ecn_rcv(ctx, LEN, 6, 0, &ece, &fcap, 2 * MS);
+
+ t = 2 * MS + 10 * CA_TW_INIT;
+ if (!mb_ecn_rcv(ctx, LEN, 5, 0, &ece, &fcap, t)) {
+ printf("gap restart did not update.\n");
+ goto fail_ctx;
+ }
+
+ if (ece != 5 << CA_SHFT) {
+ printf("gap restart: exp %u, got %u.\n", 5 << CA_SHFT, ece);
+ goto fail_ctx;
+ }
+
+ t += 10 * CA_TW_INIT;
+ if (!mb_ecn_rcv(ctx, LEN, 0, 0, &ece, &fcap, t) || ece != 0) {
+ printf("gap with clean packet did not end: %u.\n", ece);
+ goto fail_ctx;
+ }
+
+ if (mb_ecn_rcv(ctx, LEN, 0, 0, &ece, &fcap, t + MS)) {
+ printf("idle packet updated.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Max marks at max gaps: exact ceiling, no overflow past the edge. */
+static int test_mb_ecn_rcv_accum_bounds(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ uint64_t t;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ mb_ecn_rcv(ctx, LEN, 15, 0, &ece, &fcap, MS);
+
+ /* Two packets at dt just under CA_TW_INIT straddle the boundary. */
+ t = MS + CA_TW_INIT - 1;
+ if (mb_ecn_rcv(ctx, LEN, 15, 0, &ece, &fcap, t)) {
+ printf("update before the window closed.\n");
+ goto fail_ctx;
+ }
+
+ t += CA_TW_INIT - 1;
+ if (!mb_ecn_rcv(ctx, LEN, 15, 0, &ece, &fcap, t)) {
+ printf("no update at the window boundary.\n");
+ goto fail_ctx;
+ }
+
+ if (ece != 15 << CA_SHFT) {
+ printf("ceiling: exp %u, got %u.\n", 15 << CA_SHFT, ece);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Scale-free density: the window holds ~CA_N_TARGET packets at any rate. */
+static int test_mb_ecn_rcv_window_holds_target(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ uint16_t last_ece;
+ uint64_t rates[4];
+ uint64_t ia;
+ uint64_t t;
+ size_t closes;
+ size_t since;
+ size_t count;
+ size_t ri;
+
+ TEST_START();
+
+ rates[0] = 5000000;
+ rates[1] = 10000000;
+ rates[2] = 50000000;
+ rates[3] = 100000000;
+
+ ctx = NULL;
+ for (ri = 0; ri < 4; ri++) {
+ ia = 8000ULL * BILLION / rates[ri];
+ t = 0;
+ closes = 0;
+ since = 0;
+ count = 0;
+ last_ece = 0;
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* Warm past the ramp from CA_TW_INIT, then time one gap. */
+ while (closes < 42) {
+ t += ia;
+ since++;
+ if (!mb_ecn_rcv(ctx, LEN, 8, 0, &ece, &fcap, t))
+ continue;
+ closes++;
+ if (closes == 41) {
+ since = 0;
+ } else if (closes == 42) {
+ count = since - 1;
+ last_ece = ece;
+ }
+ }
+
+ if (count < 8 || count > 32) {
+ printf("rate %" PRIu64 ": %zu pkts/window.\n",
+ rates[ri], count);
+ goto fail_ctx;
+ }
+
+ if (last_ece < 224 || last_ece > 288) {
+ printf("rate %" PRIu64 ": ece %u ~256.\n",
+ rates[ri], last_ece);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+ ctx = NULL;
+ }
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * The window floors at CA_TW_MIN, tracks the rate below the old knee,
+ * and only a pathological fold hits the CA_TW_ABSMAX ceiling.
+ */
+static int test_mb_ecn_rcv_window_clip_bounds(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ uint64_t ia;
+ uint64_t t;
+ size_t closes;
+
+ TEST_START();
+
+ /* 1 GbE is above the high knee: the window floors at CA_TW_MIN. */
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ia = 8000ULL * BILLION / 1000000000ULL;
+ t = 0;
+ closes = 0;
+ while (closes < 40) {
+ t += ia;
+ if (mb_ecn_rcv(ctx, LEN, 8, 0, &ece, &fcap, t))
+ closes++;
+ }
+
+ if (ctx->rx_tw != CA_TW_MIN) {
+ printf("high-rate window: exp %" PRIu64 ", got %" PRIu64
+ ".\n", (uint64_t) CA_TW_MIN, ctx->rx_tw);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ /* 1 Mbps: past the old knee, ~16 pkts = 16 * 8 ms = 131 ms. */
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ia = 8000ULL * BILLION / 1000000ULL;
+ t = 0;
+ closes = 0;
+ while (closes < 40) {
+ t += ia;
+ if (mb_ecn_rcv(ctx, LEN, 8, 0, &ece, &fcap, t))
+ closes++;
+ }
+
+ if (ctx->rx_tw < 120 * MS || ctx->rx_tw > 140 * MS) {
+ printf("low-rate window: exp ~131 ms, got %" PRIu64 ".\n",
+ ctx->rx_tw);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ /* A near-empty window folds a huge target: ceiling holds. */
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ mb_ecn_rcv(ctx, 10, 8, 0, &ece, &fcap, MS);
+ mb_ecn_rcv(ctx, 10, 8, 0, &ece, &fcap, MS + CA_TW_INIT);
+
+ if (ctx->rx_tw != CA_TW_ABSMAX) {
+ printf("window ceiling breached: %" PRIu64 ".\n",
+ ctx->rx_tw);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* A CA-limited slow flow grows its window to hold ~16 packets. */
+static int test_mb_ecn_rcv_slow_window(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* 1400 B every 171 ms (~8 KB/s), sustained mark 8. */
+ for (i = 1; i <= 100; i++)
+ mb_ecn_rcv(ctx, 1400, 8, 0, &ece, &fcap, i * 171 * MS);
+
+ /* Target window 16 * 1000 B at 8187 B/s ~= 2.0 s. */
+ if (ctx->rx_tw < 1400 * MS || ctx->rx_tw > 2800 * MS) {
+ printf("slow window: exp ~2 s, got %" PRIu64 ".\n",
+ ctx->rx_tw);
+ goto fail_ctx;
+ }
+
+ /* Steady mark 8 emits exactly 256 once the window settles. */
+ if (ece != 8 << CA_SHFT) {
+ printf("slow-flow estimate: exp %u, got %u.\n",
+ 8 << CA_SHFT, ece);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* A physically maximal window must fold without overflow or wrap. */
+static int test_mb_ecn_rcv_no_overflow_highrate(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ bool ok;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* Open a window, then inject a maximal byte count and span. */
+ mb_ecn_rcv(ctx, LEN, 15, 0, &ece, &fcap, 0);
+ ctx->rx_byt = CA_RATE_MAX / 8;
+ ctx->rx_ts = 2 * CA_TW_INIT - 2;
+
+ ok = mb_ecn_rcv(ctx, LEN, 15, 0, &ece, &fcap, 2 * CA_TW_INIT - 1);
+
+ if (!ok) {
+ printf("max-window close did not fire.\n");
+ goto fail_ctx;
+ }
+
+ if (ece > 8160) {
+ printf("estimate %u wrapped.\n", ece);
+ goto fail_ctx;
+ }
+
+ /* A wrapped numerator drives rx_tw to MAX; it must descend. */
+ if (ctx->rx_tw >= CA_TW_INIT || ctx->rx_tw < CA_TW_MIN) {
+ printf("window %" PRIu64 " did not descend.\n", ctx->rx_tw);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * The sender holds a mark across the full inter-feedback gap (TTL >
+ * 2 * CA_TW_INIT) and a repeated mark must not re-fire the one-sided lead.
+ */
+static int test_mb_ecn_ece_ttl_covers_cadence(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t prev;
+ uint64_t t;
+ uint64_t ftag = 0;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = (uint64_t) 100 << 20;
+ ctx->tx_cav = true;
+
+ mb_ecn_ece(ctx, 100, 0, MS);
+ mb_ecn_snd(ctx, LEN, MS, &ftag);
+
+ /* Sends between feedbacks spaced 2 * CA_TW_INIT + 5 ms apart. */
+ t = MS;
+ for (i = 0; i < 4; i++) {
+ t += (2 * CA_TW_INIT + 5 * MS) / 4;
+ mb_ecn_snd(ctx, LEN, t, &ftag);
+ if (ctx->tx_ece == 0) {
+ printf("mark cleared inside the feedback gap.\n");
+ goto fail_ctx;
+ }
+ }
+
+ /* Same mark again: rise 0, so only the proportional cut. */
+ prev = ctx->rate;
+ t += MS;
+ mb_ecn_ece(ctx, 100, 0, t);
+ mb_ecn_snd(ctx, LEN, t, &ftag);
+
+ if (prev - ctx->rate > prev / 100) {
+ printf("phantom lead cut: %" PRIu64 " -> %" PRIu64 ".\n",
+ prev, ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_mb_ecn_slow_start(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t prev;
+ uint64_t t;
+ uint64_t ftag = 0;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ prev = ctx->rate;
+ t = 0;
+
+ /* No feedback: the flow stays in slow start and grows each step. */
+ for (i = 0; i < 16; i++) {
+ t += MS;
+ mb_ecn_snd(ctx, LEN, t, &ftag);
+ if (ctx->rate <= prev) {
+ printf("rate did not grow: %" PRIu64 ".\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ prev = ctx->rate;
+ }
+
+ /* Exponential ramp doubles in ~ln2 * CA_SS_TC ~= 14 ms. */
+ if (ctx->rate < 2 * CA_RATE_INIT) {
+ printf("slow start too slow: %" PRIu64 ".\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_mb_ecn_dt_scaling_invariant(void)
+{
+ struct mb_ecn_ctx * a;
+ struct mb_ecn_ctx * b;
+ uint64_t inc_a;
+ uint64_t inc_b;
+ uint64_t t;
+ uint64_t fta = 0;
+ uint64_t ftb = 0;
+ size_t i;
+
+ TEST_START();
+
+ a = mk_ctx();
+ b = mk_ctx();
+ if (a == NULL || b == NULL) {
+ printf("Failed to create contexts.\n");
+ goto fail_ctx;
+ }
+
+ /* Leave slow start; seed a realistic rate (truncation-free). */
+ mb_ecn_ece(a, 0, 0, 0);
+ mb_ecn_ece(b, 0, 0, 0);
+ a->rate = (uint64_t) 10 << 20;
+ b->rate = (uint64_t) 10 << 20;
+ a->r_bkt = a->rate;
+ b->r_bkt = b->rate;
+
+ /* a: one 30 ms step (under one washout bucket, so it stays out). */
+ mb_ecn_snd(a, LEN, 30 * MS, &fta);
+
+ /* b: thirty 1 ms steps over the same 30 ms. */
+ t = 0;
+ for (i = 0; i < 30; i++) {
+ t += MS;
+ mb_ecn_snd(b, LEN, t, &ftb);
+ }
+
+ inc_a = a->rate - ((uint64_t) 10 << 20);
+ inc_b = b->rate - ((uint64_t) 10 << 20);
+
+ /* Equal within 1 %; the small gap is per-step integer truncation. */
+ if (inc_a == 0 || inc_b == 0) {
+ printf("no additive increase: %" PRIu64 " %" PRIu64 ".\n",
+ inc_a, inc_b);
+ goto fail_ctx;
+ }
+
+ if (inc_a > inc_b + inc_a / 100 || inc_b > inc_a + inc_a / 100) {
+ printf("cadence-dependent AI: %" PRIu64 " vs %" PRIu64 ".\n",
+ inc_a, inc_b);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_mb_ecn_multiplicative_decrease(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t prev;
+ uint64_t t;
+ uint64_t ftag = 0;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = (uint64_t) 100 << 20;
+ prev = ctx->rate;
+ t = 0;
+
+ for (i = 0; i < 10; i++) {
+ t += MS;
+ mb_ecn_ece(ctx, CA_ECE_REF, 0, t);
+ mb_ecn_snd(ctx, LEN, t, &ftag);
+ if (ctx->rate >= prev) {
+ printf("rate did not shrink: %" PRIu64 ".\n",
+ ctx->rate);
+ goto fail_ctx;
+ }
+
+ prev = ctx->rate;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_mb_ecn_rate_floor(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* Cut larger than headroom must clamp; stay inside CA_ECE_TTL. */
+ ctx->rate = CA_RATE_MIN + 1000;
+ mb_ecn_ece(ctx, CA_ECE_REF, 0, 0);
+ mb_ecn_snd(ctx, LEN, 30 * MS, &ftag);
+
+ if (ctx->rate != CA_RATE_MIN) {
+ printf("rate floor breached: %" PRIu64 ".\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+static int test_mb_ecn_fixed_point(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t exp;
+ uint64_t t;
+ uint64_t ftag = 0;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ exp = CA_AI_RATE * CA_ECE_REF /
+ (128 - CA_ECE_REF * BILLION / CA_PROBE_TC);
+ t = 0;
+
+ /* ~20 s: the probe raises the loop time constant to CA_PROBE_TC. */
+ for (i = 0; i < 20000; i++) {
+ t += MS;
+ mb_ecn_ece(ctx, 128, 0, t);
+ mb_ecn_snd(ctx, LEN, t, &ftag);
+ }
+
+ if (ctx->rate < exp - exp / 4 || ctx->rate > exp + exp / 4) {
+ printf("no fixed point: exp ~%" PRIu64 ", got %" PRIu64 ".\n",
+ exp, ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * One-sided lead: a mark rise cuts rate * rise / (REF * DEN) once;
+ * a flat or falling mark leaves only the dt-scaled proportional cut.
+ */
+static int test_mb_ecn_lead_cut(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t prev;
+ uint64_t drop;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = (uint64_t) 100 << 20;
+ ctx->tx_cav = true;
+
+ /* Rise 0 -> 256: lead cuts ~rise/(REF*DEN) = 1/8 of the rate. */
+ prev = ctx->rate;
+ mb_ecn_ece(ctx, 256, 0, MS);
+ mb_ecn_snd(ctx, LEN, MS, &ftag);
+ drop = prev - ctx->rate;
+ if (drop < prev / 10) {
+ printf("lead cut missing: dropped %" PRIu64 ".\n", drop);
+ goto fail_ctx;
+ }
+
+ /* Flat mark: rise 0, only the ~0.05%% proportional cut. */
+ prev = ctx->rate;
+ mb_ecn_ece(ctx, 256, 0, 2 * MS);
+ mb_ecn_snd(ctx, LEN, 2 * MS, &ftag);
+ drop = prev - ctx->rate;
+ if (drop > prev / 100) {
+ printf("flat mark over-cut: dropped %" PRIu64 ".\n", drop);
+ goto fail_ctx;
+ }
+
+ /* Falling mark: one-sided lead must not fire. */
+ prev = ctx->rate;
+ mb_ecn_ece(ctx, 64, 0, 3 * MS);
+ mb_ecn_snd(ctx, LEN, 3 * MS, &ftag);
+ drop = prev > ctx->rate ? prev - ctx->rate : 0;
+ if (drop > prev / 100) {
+ printf("falling mark cut: dropped %" PRIu64 ".\n", drop);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* A local first-hop mark exits slow start with no feedback needed. */
+static int test_mb_ecn_slow_start_local_brake(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t prev;
+ uint64_t ftag = 0;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ for (i = 1; i <= 50; i++)
+ mb_ecn_snd(ctx, LEN, i * MS, &ftag);
+
+ if (ctx->tx_cav) {
+ printf("Left slow start without any signal.\n");
+ goto fail_ctx;
+ }
+
+ prev = ctx->rate;
+ mb_ecn_loc(ctx, 1, 50 * MS);
+ if (!ctx->tx_cav) {
+ printf("Local mark did not exit slow start.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_snd(ctx, LEN, 51 * MS, &ftag);
+ if (ctx->rate > prev + prev / 20) {
+ printf("SS ramp survived the brake: %" PRIu64 ".\n",
+ ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* A clean path still ramps to line rate in well under a second. */
+static int test_mb_ecn_slow_start_clean_ramp(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* Backlogged, no marks: slow start sprints in a couple windows. */
+ drive_backlogged(ctx, &ftag, MS, 2 * CA_SND_WIN, LEN);
+
+ if (ctx->rate < (1ULL << 24)) {
+ printf("backlogged slow start too slow: %" PRIu64 ".\n",
+ ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * A sender starved of send-path control steps recovers through the
+ * feedback path: honest elapsed time, at most a 50% cut per step.
+ */
+static int test_mb_ecn_starved_decrease_escape(void)
+{
+ struct mb_ecn_ctx * ctx;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = (uint64_t) 100 << 20;
+ ctx->tx_cav = true;
+
+ /* Feedback arrives once per second; no sends at all. */
+ for (i = 1; i <= 6; i++)
+ mb_ecn_ece(ctx, 480, 0, i * BILLION);
+
+ if (ctx->rate > (5ULL << 19)) {
+ printf("still wedged at %" PRIu64 " B/s.\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ if (ctx->rate < CA_RATE_MIN) {
+ printf("rate floor breached: %" PRIu64 ".\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* With feedback fully dead, the local mark alone recovers the rate. */
+static int test_mb_ecn_starved_local_fallback(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t t;
+ uint64_t ftag = 0;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = (uint64_t) 100 << 20;
+
+ for (i = 1; i <= 7; i++) {
+ t = i * BILLION;
+ mb_ecn_loc(ctx, 15, t);
+ mb_ecn_snd(ctx, LEN, t, &ftag);
+ }
+
+ if (!ctx->tx_cav) {
+ printf("Local mark did not exit slow start.\n");
+ goto fail_ctx;
+ }
+
+ if (ctx->rate > (5ULL << 19)) {
+ printf("still wedged at %" PRIu64 " B/s.\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * MD Δt-invariance: the same elapsed time under the same mark cuts
+ * the same, in one big step or five small ones.
+ */
+static int test_mb_ecn_decrease_dt_invariant(void)
+{
+ struct mb_ecn_ctx * a;
+ struct mb_ecn_ctx * b;
+ uint64_t cut_a;
+ uint64_t cut_b;
+ uint64_t r0;
+ uint64_t fta = 0;
+ uint64_t ftb = 0;
+ size_t i;
+
+ TEST_START();
+
+ a = mk_ctx();
+ b = mk_ctx();
+ if (a == NULL || b == NULL) {
+ printf("Failed to create contexts.\n");
+ goto fail_ctx;
+ }
+
+ r0 = (uint64_t) 100 << 20;
+ a->rate = r0;
+ b->rate = r0;
+
+ mb_ecn_ece(a, 256, 0, 0);
+ mb_ecn_ece(b, 256, 0, 0);
+
+ /* a: one 50 ms step; b: five 10 ms steps (both within DT_CAP). */
+ mb_ecn_snd(a, LEN, 50 * MS, &fta);
+
+ for (i = 1; i <= 5; i++)
+ mb_ecn_snd(b, LEN, i * 10 * MS, &ftb);
+
+ cut_a = r0 - a->rate;
+ cut_b = r0 - b->rate;
+
+ if (cut_a == 0 || cut_b == 0) {
+ printf("no cut: %" PRIu64 " %" PRIu64 ".\n", cut_a, cut_b);
+ goto fail_ctx;
+ }
+
+ /* Within 10%: residual is Euler compounding of MD and the probe. */
+ if (cut_a > cut_b + cut_a / 10 || cut_b > cut_a + cut_a / 10) {
+ printf("cadence-dependent MD: %" PRIu64 " vs %" PRIu64
+ ".\n", cut_a, cut_b);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Resuming after a long idle gap: bounded AI, no cut from stale marks. */
+static int test_mb_ecn_idle_resume_bounded(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t prev;
+ uint64_t bump;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = (uint64_t) 10 << 20;
+
+ mb_ecn_loc(ctx, 15, MS);
+ mb_ecn_ece(ctx, 480, 0, MS);
+
+ prev = ctx->rate;
+
+ /* 600 s later: both signals stale; one capped AI + probe step. */
+ mb_ecn_snd(ctx, LEN, 600 * BILLION, &ftag);
+
+ if (ctx->rate < prev) {
+ printf("stale mark cut the rate: %" PRIu64 ".\n",
+ ctx->rate);
+ goto fail_ctx;
+ }
+
+ bump = CA_AI_RATE * CA_DT_CAP / BILLION;
+ bump += (prev + bump) * CA_DT_CAP / CA_PROBE_TC;
+
+ if (ctx->rate > prev + bump + 2) {
+ printf("idle resume cap breached: %" PRIu64 ".\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* The congestion signal ages out on wall-clock time, not packet count. */
+static int test_mb_ecn_ece_staleness(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* A fast flow's control step caches the floor TTL. */
+ ctx->rate = (uint64_t) 1 << 20;
+ ctx->tx_cav = true;
+ mb_ecn_snd(ctx, LEN, MS, &ftag);
+
+ if (ctx->ece_ttl != CA_ECE_TTL) {
+ printf("Fast-flow TTL: exp %" PRIu64 ", got %" PRIu64 ".\n",
+ (uint64_t) CA_ECE_TTL, ctx->ece_ttl);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ece(ctx, CA_ECE_REF, 0, 2 * MS);
+
+ /* Just inside the TTL: the signal is still held. */
+ mb_ecn_snd(ctx, LEN, 2 * MS + ctx->ece_ttl, &ftag);
+ if (ctx->tx_ece == 0) {
+ printf("signal aged out too early.\n");
+ goto fail_ctx;
+ }
+
+ /* Past the TTL without feedback: the signal is cleared. */
+ mb_ecn_snd(ctx, LEN, 2 * MS + ctx->ece_ttl + 1, &ftag);
+ if (ctx->tx_ece != 0) {
+ printf("stale signal not cleared: %u.\n", ctx->tx_ece);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* The staleness horizon stretches with a slow flow's window. */
+static int test_mb_ecn_ece_ttl_tracks_rate(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t want;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = 8192;
+ ctx->rate_min = 8192;
+ ctx->ai_rate = 0;
+ ctx->tx_cav = true;
+ mb_ecn_snd(ctx, 1400, MS, &ftag);
+
+ want = (1 << CA_TW_GAP_SHFT) * CA_RX_WBYTES * BILLION / ctx->rate;
+ if (ctx->ece_ttl != want) {
+ printf("Slow-flow TTL: exp %" PRIu64 ", got %" PRIu64 ".\n",
+ want, ctx->ece_ttl);
+ goto fail_ctx;
+ }
+
+ if (ctx->ece_ttl < 7 * (uint64_t) BILLION) {
+ printf("TTL did not stretch: %" PRIu64 ".\n",
+ ctx->ece_ttl);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * First packet of a flow starts at the clock; a same-instant second
+ * packet leads by its length and is paced by lead / rate.
+ */
+static int test_mb_ecn_sfq_pace(void)
+{
+ struct mb_ecn_ctx * ctx;
+ time_t wait;
+ uint64_t ftag = 0;
+ time_t want;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = 1U << 20;
+ ctx->inv_rate = mb_ecn_rate_inv(ctx->rate);
+
+ /* First send: start tag equals the clock, so no wait. */
+ wait = mb_ecn_snd(ctx, 1500, 0, &ftag);
+ if (wait != 0) {
+ printf("first packet waited %ld, expected 0.\n", (long) wait);
+ goto fail_ctx;
+ }
+
+ /* Same instant (dt = 0): the flow now leads by 1500 B. */
+ wait = mb_ecn_snd(ctx, 1500, 0, &ftag);
+ want = (time_t) ((uint64_t) 1500 * BILLION / ctx->rate);
+
+ if (wait != want) {
+ printf("paced wait %ld, expected %ld.\n",
+ (long) wait, (long) want);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * The proportional probe grows the rate by the same fraction per unit
+ * time regardless of the absolute rate: two clean flows 100x apart in
+ * rate grow by the same ratio. Deleting the probe leaves only the tiny
+ * additive increase, failing the growth floor.
+ */
+static int test_mb_ecn_probe_scale_invariant(void)
+{
+ struct mb_ecn_ctx * a;
+ struct mb_ecn_ctx * b;
+ uint64_t ra0;
+ uint64_t rb0;
+ double ga;
+ double gb;
+ uint64_t t;
+ size_t i;
+
+ TEST_START();
+
+ a = mk_ctx();
+ b = mk_ctx();
+ if (a == NULL || b == NULL) {
+ printf("Failed to create contexts.\n");
+ goto fail_ctx;
+ }
+
+ /* Clean path (mark 0), out of slow start, backlogged, 100x apart. */
+ mb_ecn_ece(a, 0, 0, 0);
+ mb_ecn_ece(b, 0, 0, 0);
+ a->rate = (uint64_t) 10 << 20;
+ b->rate = (uint64_t) 1000 << 20;
+ a->r_bkt = a->rate;
+ b->r_bkt = b->rate;
+ a->backlogged = true;
+ b->backlogged = true;
+ ra0 = a->rate;
+ rb0 = b->rate;
+
+ /* Drive control via the feedback path so backlogged stays set. */
+ t = 0;
+ for (i = 0; i < 500; i++) {
+ t += MS;
+ mb_ecn_ece(a, 0, 0, t);
+ mb_ecn_ece(b, 0, 0, t);
+ }
+
+ ga = (double) a->rate / ra0;
+ gb = (double) b->rate / rb0;
+
+ if (ga < gb - gb / 50 || gb < ga - ga / 50) {
+ printf("probe not scale-invariant: %.4f vs %.4f.\n", ga, gb);
+ goto fail_ctx;
+ }
+
+ /* And it must actually grow: the probe is present, not deleted. */
+ if (ga < 1.05) {
+ printf("probe did not grow the rate: %.4f.\n", ga);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(a);
+ mb_ecn_ctx_destroy(b);
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * The proportional probe e-folds the rate over CA_PROBE_TC: a clean flow
+ * grows by ~e in 8 s. Pinned to a literal e-band so a mistuned
+ * CA_PROBE_TC (e.g. 4 s gives e^2) fails.
+ */
+static int test_mb_ecn_probe_time_constant(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t r0;
+ double ratio;
+ uint64_t t;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* Clean path, out of slow start, backlogged, below the ceiling. */
+ mb_ecn_ece(ctx, 0, 0, 0);
+ ctx->rate = (uint64_t) 1 << 30;
+ ctx->r_bkt = ctx->rate;
+ ctx->backlogged = true;
+ r0 = ctx->rate;
+
+ /* 8000 x 1 ms of clean growth, driven via the feedback path. */
+ t = 0;
+ for (i = 0; i < 8000; i++) {
+ t += MS;
+ mb_ecn_ece(ctx, 0, 0, t);
+ }
+
+ /* Washout damps the probe to ~4/3 TC, so 8 s -> ~2.12x. */
+ ratio = (double) ctx->rate / r0;
+ if (ratio < 2.0 || ratio > 2.25) {
+ printf("probe TC off: exp ~2.12, got %.3fx over 8 s.\n",
+ ratio);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* The fed-back capacity is the MIN of the nonzero caps in the window. */
+static int test_mb_ecn_rcv_cap_window_min(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ uint64_t t;
+ bool upd;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* Onset packet carries no capacity: feed back unknown. */
+ if (!mb_ecn_rcv(ctx, LEN, 8, 0, &ece, &fcap, MS)) {
+ printf("Onset did not update.\n");
+ goto fail_ctx;
+ }
+
+ if (fcap != 0) {
+ printf("Onset fed back cap: exp 0, got %u.\n", fcap);
+ goto fail_ctx;
+ }
+
+ mb_ecn_rcv(ctx, LEN, 8, 40, &ece, &fcap, 2 * MS);
+ mb_ecn_rcv(ctx, LEN, 8, 36, &ece, &fcap, 3 * MS);
+
+ t = 3 * MS + CA_TW_INIT;
+ if (!mb_ecn_rcv(ctx, LEN, 8, 0, &ece, &fcap, t)) {
+ printf("Window did not close.\n");
+ goto fail_ctx;
+ }
+
+ if (fcap != 36) {
+ printf("Window min cap: exp 36, got %u.\n", fcap);
+ goto fail_ctx;
+ }
+
+ /* The next window starts unknown; follow the adapted rx_tw. */
+ upd = false;
+ for (i = 0; i < 128 && !upd; i++) {
+ t += CA_TW_INIT;
+ upd = mb_ecn_rcv(ctx, LEN, 8, 0, &ece, &fcap, t);
+ }
+
+ if (!upd) {
+ printf("Second window did not close.\n");
+ goto fail_ctx;
+ }
+
+ if (fcap != 0) {
+ printf("Stale cap %u leaked into the next window.\n", fcap);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Onset and gap restarts emit the triggering packet's cap, fresh. */
+static int test_mb_ecn_rcv_cap_onset_fresh(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint16_t ece;
+ uint8_t fcap;
+ uint64_t t;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ if (!mb_ecn_rcv(ctx, LEN, 4, 77, &ece, &fcap, MS)) {
+ printf("Onset did not update.\n");
+ goto fail_ctx;
+ }
+
+ if (fcap != 77) {
+ printf("Onset cap: exp 77, got %u.\n", fcap);
+ goto fail_ctx;
+ }
+
+ mb_ecn_rcv(ctx, LEN, 4, 50, &ece, &fcap, 2 * MS);
+
+ /* A gap restart must not fold in the stale window min. */
+ t = 2 * MS + 5 * CA_TW_INIT;
+ if (!mb_ecn_rcv(ctx, LEN, 4, 90, &ece, &fcap, t)) {
+ printf("Gap restart did not update.\n");
+ goto fail_ctx;
+ }
+
+ if (fcap != 90) {
+ printf("Gap restart cap: exp 90, got %u.\n", fcap);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Fed-back capacity derives the floor and slope: EWMA toward C/32. */
+static int test_mb_ecn_ece_cap_derives_rates(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t tgt;
+ uint64_t want;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* Code 120 = 2^30 B/s; target floor = 2^25 B/s. */
+ tgt = cap_dec(120) >> CA_CAP_SHFT;
+
+ mb_ecn_ece(ctx, 100, 120, MS);
+
+ want = CA_RATE_MIN + ((tgt - CA_RATE_MIN) >> CA_CAP_SM_SHFT);
+ if (ctx->rate_min != want) {
+ printf("Floor: exp %" PRIu64 ", got %" PRIu64 ".\n",
+ want, ctx->rate_min);
+ goto fail_ctx;
+ }
+
+ if (ctx->ai_rate != ctx->rate_min) {
+ printf("AI slope did not track the floor.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_ece(ctx, 100, 120, 2 * MS);
+
+ want += (tgt - want) >> CA_CAP_SM_SHFT;
+ if (ctx->rate_min != want) {
+ printf("Floor EWMA: exp %" PRIu64 ", got %" PRIu64 ".\n",
+ want, ctx->rate_min);
+ goto fail_ctx;
+ }
+
+ if (ctx->n_cap != 2) {
+ printf("Capacity updates: exp 2, got %" PRIu64 ".\n",
+ ctx->n_cap);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Feedback without a capacity leaves the derived rates untouched. */
+static int test_mb_ecn_ece_cap_zero_keeps_rates(void)
+{
+ struct mb_ecn_ctx * ctx;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ mb_ecn_ece(ctx, 100, 0, MS);
+
+ if (ctx->rate_min != CA_RATE_MIN || ctx->ai_rate != CA_AI_RATE) {
+ printf("Unknown cap moved the derived rates.\n");
+ goto fail_ctx;
+ }
+
+ if (ctx->n_cap != 0) {
+ printf("Unknown cap counted as an update.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* The derived floor clamps to [CA_RATE_MIN, CA_RMIN_MAX]. */
+static int test_mb_ecn_ece_cap_clamps(void)
+{
+ struct mb_ecn_ctx * ctx;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* A path slower than the default floor cannot lower it. */
+ mb_ecn_ece(ctx, 100, 1, MS);
+
+ if (ctx->rate_min != CA_RATE_MIN) {
+ printf("Slow path lowered the floor: %" PRIu64 ".\n",
+ ctx->rate_min);
+ goto fail_ctx;
+ }
+
+ /* A absurdly fast path saturates at the ceiling. */
+ for (i = 1; i <= 40; i++)
+ mb_ecn_ece(ctx, 100, 255, (1 + i) * MS);
+
+ if (ctx->rate_min > CA_RMIN_MAX) {
+ printf("Floor above the ceiling: %" PRIu64 ".\n",
+ ctx->rate_min);
+ goto fail_ctx;
+ }
+
+ if (ctx->rate_min < CA_RMIN_MAX - 4) {
+ printf("Floor did not reach the ceiling: %" PRIu64 ".\n",
+ ctx->rate_min);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* Stale capacity reverts the derived rates to the defaults. */
+static int test_mb_ecn_cap_ttl_reverts(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ mb_ecn_ece(ctx, 100, 120, MS);
+
+ if (ctx->rate_min == CA_RATE_MIN) {
+ printf("Capacity did not derive a floor.\n");
+ goto fail_ctx;
+ }
+
+ /* Just inside the TTL: the derived rates hold. */
+ mb_ecn_snd(ctx, LEN, MS + (ctx->ece_ttl << CA_CAP_TTL_SHFT), &ftag);
+
+ if (ctx->rate_min == CA_RATE_MIN) {
+ printf("Derived rates reverted too early.\n");
+ goto fail_ctx;
+ }
+
+ /* Past the TTL: back to the defaults. */
+ mb_ecn_snd(ctx, LEN, MS + (ctx->ece_ttl << CA_CAP_TTL_SHFT) + 1,
+ &ftag);
+
+ if (ctx->rate_min != CA_RATE_MIN || ctx->ai_rate != CA_AI_RATE) {
+ printf("Stale capacity kept the derived rates.\n");
+ goto fail_ctx;
+ }
+
+ if (ctx->tx_cap != 0) {
+ printf("Stale capacity code not cleared.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* The control law uses the per-ctx AI slope. */
+static int test_mb_ecn_ctrl_per_ctx_ai(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t want;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* Leave slow start; raise the slope as capacity would. */
+ mb_ecn_ece(ctx, 0, 0, 0);
+ ctx->rate = (uint64_t) 10 << 20;
+ ctx->r_bkt = ctx->rate;
+ ctx->ai_rate = 16 * CA_AI_RATE;
+
+ want = ctx->rate + ctx->ai_rate * (30 * MS) / BILLION;
+ want += want * (30 * MS) / CA_PROBE_TC;
+
+ mb_ecn_snd(ctx, LEN, 30 * MS, &ftag);
+
+ if (ctx->rate != want) {
+ printf("AI not per-ctx: exp %" PRIu64 ", got %" PRIu64 ".\n",
+ want, ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* The rate clamp honours the per-ctx derived floor. */
+static int test_mb_ecn_ctrl_per_ctx_floor(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate_min = (uint64_t) 1 << 20;
+ ctx->rate = ((uint64_t) 1 << 20) + 1000;
+
+ mb_ecn_ece(ctx, CA_ECE_REF, 0, 0);
+ mb_ecn_snd(ctx, LEN, 30 * MS, &ftag);
+
+ if (ctx->rate != ctx->rate_min) {
+ printf("Floor not per-ctx: %" PRIu64 ".\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * A paced flow slower than one packet per CA_DT_CAP must not decay:
+ * the gap credit law grants its true elapsed service, so the lead
+ * stays pinned at ~one packet instead of growing without bound.
+ */
+static int test_mb_ecn_snd_slow_rate_paced(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+ uint64_t t = 0;
+ time_t wait;
+ size_t i;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* 8 KB/s, 1400 B packets: inter-send gap ~171 ms > CA_DT_CAP. */
+ ctx->rate = 8192;
+ ctx->rate_min = 8192;
+ ctx->ai_rate = 0;
+ ctx->inv_rate = mb_ecn_rate_inv(8192);
+ ctx->tx_cav = true;
+
+ for (i = 0; i < 50; i++) {
+ wait = mb_ecn_snd(ctx, 1400, t, &ftag);
+ t += wait > 0 ? (uint64_t) wait : 1;
+ }
+
+ if (ctx->lead > 2 * 1400) {
+ printf("Pacer starves a slow flow: lead %" PRIu64 ".\n",
+ ctx->lead);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * A long idle makes the aggregate source-limited, so the offered-load
+ * ceiling bounds the resume rate (hence the burst) well below the
+ * pre-idle rate.
+ */
+static int test_mb_ecn_snd_idle_burst_bound(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = (uint64_t) 1 << 20;
+ ctx->inv_rate = mb_ecn_rate_inv(ctx->rate);
+ ctx->tx_cav = true;
+
+ mb_ecn_snd(ctx, 1400, 0, &ftag); /* warm-up: sets started */
+
+ /* 600 s idle. */
+ mb_ecn_snd(ctx, 1400, 600 * BILLION, &ftag);
+
+ if (ctx->backlogged) {
+ printf("long idle did not clear backlogged.\n");
+ goto fail_ctx;
+ }
+
+ if (ctx->rate >= ((uint64_t) 1 << 20)) {
+ printf("idle resume rate not ceiling-bounded: %" PRIu64
+ ".\n", ctx->rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* A deep backlog at a low rate must not wrap the wait computation. */
+static int test_mb_ecn_snd_wait_no_overflow(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t want;
+ uint64_t ftag;
+ time_t wait;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->rate = 8192;
+ ctx->inv_rate = mb_ecn_rate_inv(8192);
+
+ /* 128 flows x 1400 B of SFQ lead at the floor rate. */
+ ftag = 128 * 1400;
+
+ wait = mb_ecn_snd(ctx, 1400, 0, &ftag);
+ want = (uint64_t) 128 * 1400 * BILLION / 8192;
+
+ if ((uint64_t) wait < want - want / 100 ||
+ (uint64_t) wait > want + want / 100) {
+ printf("Wait wrapped: exp ~%" PRIu64 ", got %" PRIu64 ".\n",
+ want, (uint64_t) wait);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* A fully paced-backlogged flow reads backlogged after a window. */
+static int test_mb_ecn_backlogged_paced(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->backlogged = false; /* prove a window close re-earns it */
+
+ drive_backlogged(ctx, &ftag, MS, 4 * CA_SND_WIN, LEN);
+
+ if (!ctx->backlogged) {
+ printf("paced-backlogged flow read source-limited.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/*
+ * A source-limited flow is capped to the next quarter-log2 headroom
+ * above the offered estimate, and NOT re-floored to a high capacity
+ * rate_min.
+ */
+static int test_mb_ecn_source_limited_ceiling(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+ uint64_t t = 10 * MS;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->tx_cav = true;
+ ctx->started = true;
+ ctx->backlogged = false;
+ ctx->rate = (uint64_t) 100 << 20;
+ ctx->inv_rate = mb_ecn_rate_inv(ctx->rate);
+ ctx->rate_min = (uint64_t) 50 << 20;
+ ctx->snd_rate = (uint64_t) 1 << 20;
+ ctx->snd_r0 = ctx->rate;
+ ctx->snd_win = t;
+ ctx->last_ts = t;
+ ctx->last_ctrl = t;
+
+ mb_ecn_snd(ctx, LEN, t + 2 * MS, &ftag);
+
+ if (ctx->rate != ((uint64_t) 2 << 20)) {
+ printf("ceiling: exp %" PRIu64 ", got %" PRIu64 ".\n",
+ (uint64_t) 2 << 20, ctx->rate);
+ goto fail_ctx;
+ }
+
+ if (!ctx->src_limited) {
+ printf("ceiling bound but src_limited not set.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* One quiet window must not collapse the max-filter; it decays ~1/16. */
+static int test_mb_ecn_max_filter(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+ uint64_t hi = (uint64_t) 10 << 20;
+ uint64_t t = 10 * MS;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->started = true;
+ ctx->snd_rate = hi;
+ ctx->snd_r0 = hi;
+ ctx->snd_byt = 0;
+ ctx->snd_win = t;
+ ctx->last_ts = t;
+ ctx->last_ctrl = t;
+
+ /* Close one window with almost no bytes offered. */
+ mb_ecn_snd(ctx, LEN, t + CA_SND_WIN + 1, &ftag);
+
+ if (ctx->snd_rate >= hi || ctx->snd_rate < hi - hi / 8) {
+ printf("max-filter: exp ~15/16 of %" PRIu64 ", got %"
+ PRIu64 " after one quiet window.\n",
+ hi, ctx->snd_rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* A >CA_DT_CAP gap clears backlogged without touching the estimate. */
+static int test_mb_ecn_idle_clears_backlogged(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+ uint64_t snd_rate = (uint64_t) 5 << 20;
+ uint64_t t = 10 * MS;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ ctx->started = true;
+ ctx->backlogged = true;
+ ctx->snd_rate = snd_rate;
+ ctx->snd_win = t;
+ ctx->last_ts = t;
+ ctx->last_ctrl = t;
+
+ /* 55 ms gap: past CA_DT_CAP, under CA_SND_WIN (no window close). */
+ mb_ecn_snd(ctx, LEN, t + 55 * MS, &ftag);
+
+ if (ctx->backlogged) {
+ printf("idle gap did not clear backlogged.\n");
+ goto fail_ctx;
+ }
+
+ if (ctx->snd_rate != snd_rate) {
+ printf("idle step altered snd_rate %" PRIu64 ".\n",
+ ctx->snd_rate);
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+/* The first send is never misread as idle, whatever the wall clock. */
+static int test_mb_ecn_first_send_warmup(void)
+{
+ struct mb_ecn_ctx * ctx;
+ uint64_t ftag = 0;
+
+ TEST_START();
+
+ ctx = mk_ctx();
+ if (ctx == NULL) {
+ printf("Failed to create context.\n");
+ goto fail;
+ }
+
+ /* started == false; a large first timestamp must not look idle. */
+ mb_ecn_snd(ctx, LEN, 500 * MS, &ftag);
+
+ if (!ctx->started) {
+ printf("first send did not set the warm-up sentinel.\n");
+ goto fail_ctx;
+ }
+
+ if (!ctx->backlogged) {
+ printf("first send misclassified as idle.\n");
+ goto fail_ctx;
+ }
+
+ mb_ecn_ctx_destroy(ctx);
+
+ TEST_SUCCESS();
+
+ return TEST_RC_SUCCESS;
+ fail_ctx:
+ mb_ecn_ctx_destroy(ctx);
+ fail:
+ TEST_FAIL();
+ return TEST_RC_FAIL;
+}
+
+int mb_ecn_test(int argc,
+ char ** argv)
+{
+ int ret = 0;
+
+ (void) argc;
+ (void) argv;
+
+ ret |= test_mb_ecn_ctx_create_destroy();
+ ret |= test_mb_ecn_calc_ecn();
+ ret |= test_mb_ecn_rcv_onset_immediate();
+ ret |= test_mb_ecn_rcv_window_mean();
+ ret |= test_mb_ecn_rcv_rate_independent();
+ ret |= test_mb_ecn_rcv_size_fair();
+ ret |= test_mb_ecn_rcv_release_exact_zero();
+ ret |= test_mb_ecn_rcv_gap_restart();
+ ret |= test_mb_ecn_rcv_accum_bounds();
+ ret |= test_mb_ecn_rcv_window_holds_target();
+ ret |= test_mb_ecn_rcv_window_clip_bounds();
+ ret |= test_mb_ecn_rcv_slow_window();
+ ret |= test_mb_ecn_rcv_no_overflow_highrate();
+ ret |= test_mb_ecn_ece_ttl_covers_cadence();
+ ret |= test_mb_ecn_slow_start();
+ ret |= test_mb_ecn_dt_scaling_invariant();
+ ret |= test_mb_ecn_probe_scale_invariant();
+ ret |= test_mb_ecn_multiplicative_decrease();
+ ret |= test_mb_ecn_fixed_point();
+ ret |= test_mb_ecn_lead_cut();
+ ret |= test_mb_ecn_slow_start_local_brake();
+ ret |= test_mb_ecn_slow_start_clean_ramp();
+ ret |= test_mb_ecn_starved_decrease_escape();
+ ret |= test_mb_ecn_starved_local_fallback();
+ ret |= test_mb_ecn_decrease_dt_invariant();
+ ret |= test_mb_ecn_idle_resume_bounded();
+ ret |= test_mb_ecn_rate_floor();
+ ret |= test_mb_ecn_ece_staleness();
+ ret |= test_mb_ecn_ece_ttl_tracks_rate();
+ ret |= test_mb_ecn_sfq_pace();
+ ret |= test_mb_ecn_probe_time_constant();
+ ret |= test_mb_ecn_rcv_cap_window_min();
+ ret |= test_mb_ecn_rcv_cap_onset_fresh();
+ ret |= test_mb_ecn_ece_cap_derives_rates();
+ ret |= test_mb_ecn_ece_cap_zero_keeps_rates();
+ ret |= test_mb_ecn_ece_cap_clamps();
+ ret |= test_mb_ecn_cap_ttl_reverts();
+ ret |= test_mb_ecn_ctrl_per_ctx_ai();
+ ret |= test_mb_ecn_ctrl_per_ctx_floor();
+ ret |= test_mb_ecn_snd_slow_rate_paced();
+ ret |= test_mb_ecn_snd_idle_burst_bound();
+ ret |= test_mb_ecn_snd_wait_no_overflow();
+ ret |= test_mb_ecn_backlogged_paced();
+ ret |= test_mb_ecn_source_limited_ceiling();
+ ret |= test_mb_ecn_max_filter();
+ ret |= test_mb_ecn_idle_clears_backlogged();
+ ret |= test_mb_ecn_first_send_warmup();
+
+ return ret;
+}