Browse Source

Add hierarchical region gating via TX duty cycle (simple_repeater)

Autonomously shed inter-region traffic when a repeater's own TX duty
cycle is high, protecting the local cluster during high-traffic events
(issue #2747). Opt-in, off by default.

When TX duty cycle exceeds a configurable threshold, regions are gated
from the outermost layer inward: wildcard '*' first, then the broadest
named regions, always keeping the innermost cluster. Recovery re-enables
regions inside-out once duty cycle drops below (threshold - hysteresis),
with per-step random jitter to desync recovery across repeaters.

Design notes:
- Gating is transient: a new non-persisted rt_flags overlay on
  RegionEntry is OR'd with config flags in the forward path, so a
  'region save' or reboot mid-event can never make a deny permanent.
- The gate level is re-asserted every check interval, so region edits
  or a bulk 'region load' commit (which rebuild entries with cleared
  rt_flags) cannot desync the gate state; disabling the feature while
  gated lifts the gate immediately.
- Dispatcher::getTxDutyCyclePercent() refreshes the airtime budget
  before reading, so recovery still fires when traffic (and TX) stops.

Config (persisted, appended for upgrade compat):
  set dc.gate <0|1>, set dc.gate.thresh <1-100>, set dc.gate.hyst <0-50>
  get dc.gate[.thresh|.hyst], get dc.gate.status (live duty% + level)

Adds a native gtest suite for the gating logic (test/test_region_gating)
and extends the native test mocks (Arduino/Stream/SHA256) so RegionMap
builds off-device.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
pull/2960/head
Kasper Hägele 2 weeks ago
parent
commit
30ebe2c3f7
  1. 48
      examples/simple_repeater/MyMesh.cpp
  2. 2
      examples/simple_repeater/MyMesh.h
  3. 3
      platformio.ini
  4. 11
      src/Dispatcher.cpp
  5. 1
      src/Dispatcher.h
  6. 43
      src/helpers/CommonCLI.cpp
  7. 3
      src/helpers/CommonCLI.h
  8. 57
      src/helpers/RegionMap.cpp
  9. 8
      src/helpers/RegionMap.h
  10. 58
      test/mocks/Arduino.h
  11. 16
      test/mocks/SHA256.h
  12. 41
      test/mocks/Stream.h
  13. 150
      test/test_region_gating/test_region_gating.cpp

48
examples/simple_repeater/MyMesh.cpp

@ -37,6 +37,13 @@
#define SERVER_RESPONSE_DELAY 300
#endif
#ifndef DC_GATE_INTERVAL_MS
#define DC_GATE_INTERVAL_MS 10000 // how often to re-evaluate duty-cycle gating
#endif
#ifndef DC_GATE_RECOVER_JITTER_MS
#define DC_GATE_RECOVER_JITTER_MS 30000 // extra random delay per recovery step (desync repeaters)
#endif
#ifndef TXT_ACK_DELAY
#define TXT_ACK_DELAY 200
#endif
@ -554,7 +561,7 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) {
if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD);
} else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) {
if (region_map.getWildcard().flags & REGION_DENY_FLOOD) {
if (region_map.getWildcard().effectiveFlags() & REGION_DENY_FLOOD) { // config or runtime gate
recv_pkt_region = NULL;
} else {
recv_pkt_region = &region_map.getWildcard();
@ -867,6 +874,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
set_radio_at = revert_radio_at = 0;
_logging = false;
region_load_active = false;
next_dc_gate_check = 0;
dc_gate_level = 0;
#if MAX_NEIGHBOURS
memset(neighbours, 0, sizeof(neighbours));
@ -894,6 +903,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.flood_max_advert = 8;
_prefs.interference_threshold = 0; // disabled
// duty-cycle region gating defaults (feature is opt-in, off by default)
_prefs.dc_gate_enabled = 0;
_prefs.dc_gate_threshold = 70; // start gating above 70% TX duty cycle
_prefs.dc_gate_hysteresis = 10; // recover below 60%
// bridge defaults
_prefs.bridge_enabled = 1; // enabled
_prefs.bridge_delay = 500; // milliseconds
@ -1257,6 +1271,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
sendNodeDiscoverReq();
strcpy(reply, "OK - Discover sent");
}
} else if (strcmp(command, "get dc.gate.status") == 0) {
sprintf(reply, "> duty %d%%, gate level %d/%d", (int)getTxDutyCyclePercent(),
(int)dc_gate_level, (int)region_map.getMaxGateLevel());
} else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
}
@ -1301,6 +1318,35 @@ void MyMesh::loop() {
dirty_contacts_expiry = 0;
}
// duty-cycle region gating: progressively shed outer regions when TX duty cycle is high,
// restoring them inside-out (with jitter) once it recovers. Purely transient (rt_flags);
// never persisted, so a reboot or 'region save' can't leave regions permanently gated.
if (_prefs.dc_gate_enabled) {
if (millisHasNowPassed(next_dc_gate_check)) {
uint8_t duty = getTxDutyCyclePercent();
uint8_t maxLevel = region_map.getMaxGateLevel();
uint8_t recover = _prefs.dc_gate_threshold > _prefs.dc_gate_hysteresis
? _prefs.dc_gate_threshold - _prefs.dc_gate_hysteresis : 0;
uint32_t next = DC_GATE_INTERVAL_MS;
if (duty > _prefs.dc_gate_threshold && dc_gate_level < maxLevel) {
dc_gate_level++; // gate one more (outer) layer
MESH_DEBUG_PRINTLN("duty gate: level up to %d (duty %d%%)", (int)dc_gate_level, (int)duty);
} else if (dc_gate_level > 0 && duty < recover) {
dc_gate_level--; // re-enable innermost gated layer
next += getRNG()->nextInt(0, DC_GATE_RECOVER_JITTER_MS); // desync recovery across repeaters
MESH_DEBUG_PRINTLN("duty gate: level down to %d (duty %d%%)", (int)dc_gate_level, (int)duty);
}
// re-assert every tick (not just on change): 'region load' commits and other region
// edits rebuild entries with cleared rt_flags, so the gate state must be reapplied
region_map.applyDutyGate(dc_gate_level);
next_dc_gate_check = futureMillis(next);
}
} else if (dc_gate_level > 0) { // feature was disabled while gated: lift the gate
dc_gate_level = 0;
region_map.applyDutyGate(0);
MESH_DEBUG_PRINTLN("duty gate: disabled, gating lifted");
}
// update uptime
uint32_t now = millis();
uptime_millis += now - last_millis;

2
examples/simple_repeater/MyMesh.h

@ -103,6 +103,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
unsigned long pending_discover_until;
bool region_load_active;
unsigned long dirty_contacts_expiry;
unsigned long next_dc_gate_check; // duty-cycle region gating: next evaluation time
uint8_t dc_gate_level; // current number of outer layers gated (0 = none)
#if MAX_NEIGHBOURS
NeighbourInfo neighbours[MAX_NEIGHBOURS];
#endif

3
platformio.ini

@ -164,5 +164,8 @@ test_build_src = yes
build_src_filter =
-<*>
+<../src/Utils.cpp>
+<../src/helpers/TxtDataHelpers.cpp>
+<../src/helpers/TransportKeyStore.cpp>
+<../src/helpers/RegionMap.cpp>
lib_deps =
google/googletest @ 1.17.0

11
src/Dispatcher.cpp

@ -52,6 +52,17 @@ void Dispatcher::updateTxBudget() {
}
}
uint8_t Dispatcher::getTxDutyCyclePercent() {
updateTxBudget(); // refill first, so the reading is current even when the mesh is idle
float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor());
unsigned long max_budget = (unsigned long)(getDutyCycleWindowMs() * duty_cycle);
if (max_budget == 0) return 0;
float used = 1.0f - (float)tx_budget_ms / (float)max_budget;
if (used <= 0.0f) return 0;
if (used >= 1.0f) return 100;
return (uint8_t)(used * 100.0f + 0.5f);
}
int Dispatcher::calcRxDelay(float score, uint32_t air_time) const {
return (int) ((pow(10, 0.85f - score) - 1.0) * air_time);
}

1
src/Dispatcher.h

@ -180,6 +180,7 @@ public:
unsigned long getTotalAirTime() const { return total_air_time; }
unsigned long getReceiveAirTime() const {return rx_air_time; }
unsigned long getRemainingTxBudget() const { return tx_budget_ms; }
uint8_t getTxDutyCyclePercent(); // live TX airtime usage, 0..100 (% of permitted budget)
uint32_t getNumSentFlood() const { return n_sent_flood; }
uint32_t getNumSentDirect() const { return n_sent_direct; }
uint32_t getNumRecvFlood() const { return n_recv_flood; }

43
src/helpers/CommonCLI.cpp

@ -91,7 +91,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290
file.read((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291
file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292
// next: 293
file.read((uint8_t *)&_prefs->dc_gate_enabled, sizeof(_prefs->dc_gate_enabled)); // 293
file.read((uint8_t *)&_prefs->dc_gate_threshold, sizeof(_prefs->dc_gate_threshold)); // 294
file.read((uint8_t *)&_prefs->dc_gate_hysteresis, sizeof(_prefs->dc_gate_hysteresis)); // 295
// next: 296
// sanitise bad pref values
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f);
@ -122,6 +125,11 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
// sanitise settings
_prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean
// duty-cycle region gating
_prefs->dc_gate_enabled = constrain(_prefs->dc_gate_enabled, 0, 1); // boolean
_prefs->dc_gate_threshold = constrain(_prefs->dc_gate_threshold, 1, 100); // percent
_prefs->dc_gate_hysteresis = constrain(_prefs->dc_gate_hysteresis, 0, 50);// percent margin
file.close();
}
}
@ -184,7 +192,10 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290
file.write((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291
file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292
// next: 293
file.write((uint8_t *)&_prefs->dc_gate_enabled, sizeof(_prefs->dc_gate_enabled)); // 293
file.write((uint8_t *)&_prefs->dc_gate_threshold, sizeof(_prefs->dc_gate_threshold)); // 294
file.write((uint8_t *)&_prefs->dc_gate_hysteresis, sizeof(_prefs->dc_gate_hysteresis)); // 295
// next: 296
file.close();
}
@ -496,6 +507,28 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
_prefs->airtime_factor = atof(&config[3]);
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "dc.gate.thresh ", 15) == 0) {
int t = atoi(&config[15]);
if (t < 1 || t > 100) {
strcpy(reply, "ERROR: dc.gate.thresh must be 1-100");
} else {
_prefs->dc_gate_threshold = t;
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "dc.gate.hyst ", 13) == 0) {
int h = atoi(&config[13]);
if (h < 0 || h > 50) {
strcpy(reply, "ERROR: dc.gate.hyst must be 0-50");
} else {
_prefs->dc_gate_hysteresis = h;
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "dc.gate ", 8) == 0) {
_prefs->dc_gate_enabled = (atoi(&config[8]) != 0) ? 1 : 0;
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "int.thresh ", 11) == 0) {
_prefs->interference_threshold = atoi(&config[11]);
savePrefs();
@ -774,6 +807,12 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %d.%d%%", dc_int, dc_frac);
} else if (memcmp(config, "af", 2) == 0) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->airtime_factor));
} else if (memcmp(config, "dc.gate.thresh", 14) == 0) {
sprintf(reply, "> %d", (uint32_t) _prefs->dc_gate_threshold);
} else if (memcmp(config, "dc.gate.hyst", 12) == 0) {
sprintf(reply, "> %d", (uint32_t) _prefs->dc_gate_hysteresis);
} else if (memcmp(config, "dc.gate", 7) == 0) {
sprintf(reply, "> %s", _prefs->dc_gate_enabled ? "on" : "off");
} else if (memcmp(config, "int.thresh", 10) == 0) {
sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold);
} else if (memcmp(config, "agc.reset.interval", 18) == 0) {

3
src/helpers/CommonCLI.h

@ -63,6 +63,9 @@ struct NodePrefs { // persisted to file
uint8_t rx_boosted_gain; // power settings
uint8_t path_hash_mode; // which path mode to use when sending
uint8_t loop_detect;
uint8_t dc_gate_enabled; // duty-cycle region gating: 0 = off (opt-in)
uint8_t dc_gate_threshold; // TX duty-cycle % above which outer regions start being gated
uint8_t dc_gate_hysteresis; // recover margin in %: re-enable regions below (threshold - hysteresis)
};
class CommonCLICallbacks {

57
src/helpers/RegionMap.cpp

@ -46,6 +46,7 @@ RegionMap::RegionMap(TransportKeyStore& store) : _store(&store) {
default_id = home_id = 0;
wildcard.id = wildcard.parent = 0;
wildcard.flags = 0; // default behaviour, allow flood and direct
wildcard.rt_flags = 0;
strcpy(wildcard.name, "*");
}
@ -101,6 +102,8 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) {
if (!success) break; // EOF
r->rt_flags = 0; // runtime overlay is never persisted; start clean
if (r->id >= next_id) { // make sure next_id is valid
next_id = r->id + 1;
}
@ -161,6 +164,7 @@ RegionEntry* RegionMap::putRegion(const char* name, uint16_t parent_id, uint16_t
region = &regions[num_regions++]; // alloc new RegionEntry
region->flags = REGION_DENY_FLOOD; // DENY by default
region->rt_flags = 0;
region->id = id == 0 ? next_id++ : id;
StrHelper::strncpy(region->name, name, sizeof(region->name));
region->parent = parent_id;
@ -188,7 +192,7 @@ int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[],
RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) {
for (int i = 0; i < num_regions; i++) {
auto region = &regions[i];
if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param)
if ((region->effectiveFlags() & mask) == 0) { // does region allow this? (config + runtime gate)
TransportKey keys[4];
int num = getTransportKeysFor(*region, keys, 4);
for (int j = 0; j < num; j++) {
@ -305,6 +309,57 @@ void RegionMap::exportTo(Stream& out) const {
printChildRegions(0, &wildcard, out); // recursive
}
int RegionMap::depthOf(const RegionEntry* region) const {
int depth = 0;
uint16_t pid = region->parent;
// walk up parent links to the wildcard root (id 0); guard against cycles / missing parents
while (pid != 0 && depth < MAX_REGION_ENTRIES) {
const RegionEntry* p = NULL;
for (int i = 0; i < num_regions; i++) {
if (regions[i].id == pid) { p = &regions[i]; break; }
}
if (!p) break; // dangling parent -> treat current chain as the root
depth++;
pid = p->parent;
}
return depth + 1; // wildcard's direct children are depth 1
}
int RegionMap::getMaxDepth() const {
int maxDepth = 0;
for (int i = 0; i < num_regions; i++) {
int d = depthOf(&regions[i]);
if (d > maxDepth) maxDepth = d;
}
return maxDepth; // 0 when there are no named regions (wildcard only)
}
uint8_t RegionMap::getMaxGateLevel() const {
int maxDepth = getMaxDepth();
// No named regions -> the wildcard IS the local cluster, so nothing may be gated.
// Otherwise the highest level gates '*' + all depths except the innermost (deepest) one.
return maxDepth <= 0 ? 0 : (uint8_t)maxDepth;
}
void RegionMap::applyDutyGate(uint8_t level) {
int maxDepth = getMaxDepth();
// Wildcard is the outermost layer: gated from level 1 onwards (but only if named
// regions exist to keep serving the local cluster).
if (level >= 1 && maxDepth > 0) wildcard.rt_flags |= REGION_DENY_FLOOD;
else wildcard.rt_flags &= (uint8_t)~REGION_DENY_FLOOD;
// Named regions: level 2 gates depth 1, level 3 gates depth 2, ... The innermost
// layer (depth == maxDepth) is always protected.
for (int i = 0; i < num_regions; i++) {
RegionEntry* r = &regions[i];
int d = depthOf(r);
bool gate = (level >= 2) && (d <= (int)level - 1) && (d < maxDepth);
if (gate) r->rt_flags |= REGION_DENY_FLOOD;
else r->rt_flags &= (uint8_t)~REGION_DENY_FLOOD;
}
}
size_t RegionMap::exportTo(char *dest, size_t max_len) const {
if (!dest || max_len == 0) return 0;

8
src/helpers/RegionMap.h

@ -16,8 +16,10 @@ struct RegionEntry {
uint16_t parent;
uint8_t flags;
char name[31];
uint8_t rt_flags; // transient runtime overlay (eg. duty-cycle gating); NEVER persisted
bool isWildcard() const { return id == 0; }
uint8_t effectiveFlags() const { return flags | rt_flags; }
};
class RegionMap {
@ -28,6 +30,7 @@ class RegionMap {
RegionEntry wildcard;
void printChildRegions(int indent, const RegionEntry* parent, Stream& out) const;
int depthOf(const RegionEntry* region) const;
public:
RegionMap(TransportKeyStore& store);
@ -53,6 +56,11 @@ public:
int getCount() const { return num_regions; }
const RegionEntry* getByIdx(int i) const { return &regions[i]; }
const RegionEntry* getRoot() const { return &wildcard; }
// --- duty-cycle region gating (transient, via rt_flags; see applyDutyGate) ---
int getMaxDepth() const; // deepest named-region depth (wildcard=0, its children=1, ...)
uint8_t getMaxGateLevel() const; // highest gate level that still protects the innermost cluster
void applyDutyGate(uint8_t level); // gate '*' first (level>=1), then broadest depth inward
int exportNamesTo(char *dest, int max_len, uint8_t mask, bool invert = false);
int getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num);

58
test/mocks/Arduino.h

@ -0,0 +1,58 @@
#pragma once
// Minimal Arduino.h shim for native (host) unit tests.
// Only provides what the headers pulled in by RegionMap / TransportKeyStore /
// IdentityStore need to compile off-device. File / FILESYSTEM are inert stubs:
// the region-gating tests exercise in-memory logic and never touch the filesystem.
#include <cstdint>
#include <cstddef>
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <string>
#include "Stream.h"
// ---- Arduino string helpers not present in the host libc --------------------
static inline char* ltoa(long value, char* result, int base) {
if (base < 2 || base > 36) { *result = '\0'; return result; }
char* ptr = result;
char* low = result;
if (value < 0 && base == 10) { *ptr++ = '-'; low = ptr; }
unsigned long uval = (value < 0 && base == 10) ? (unsigned long)(-value) : (unsigned long)value;
do {
int digit = (int)(uval % (unsigned long)base);
*ptr++ = (char)(digit < 10 ? '0' + digit : 'a' + digit - 10);
uval /= (unsigned long)base;
} while (uval);
*ptr-- = '\0';
while (low < ptr) { char t = *low; *low++ = *ptr; *ptr-- = t; }
return result;
}
// ---- filesystem stubs -------------------------------------------------------
class File {
public:
operator bool() const { return false; }
int read(uint8_t* buf, size_t len) { (void)buf; (void)len; return 0; }
size_t write(const uint8_t* buf, size_t len) { (void)buf; (void)len; return 0; }
void close() {}
};
class FsMock {
public:
bool exists(const char* path) { (void)path; return false; }
bool remove(const char* path) { (void)path; return false; }
bool mkdir(const char* path) { (void)path; return false; }
File open(const char* path) { (void)path; return File(); }
File open(const char* path, const char* mode) { (void)path; (void)mode; return File(); }
File open(const char* path, const char* mode, bool create) {
(void)path; (void)mode; (void)create; return File();
}
};
#ifndef FILESYSTEM
#define FILESYSTEM FsMock
#endif

16
test/mocks/SHA256.h

@ -3,12 +3,16 @@
#include <stdint.h>
#include <stddef.h>
// Mock SHA256 class for testing
// Provides minimal interface to allow Utils.cpp to compile
// Mock SHA256 class for testing.
// Uses void* params to match the real Crypto library signatures, so it can back
// both Utils.cpp and TransportKeyStore.cpp in native builds (the digest output is
// not meaningful under the mock; tests must not depend on actual hash values).
class SHA256 {
public:
void update(const uint8_t* data, size_t len) {}
void finalize(uint8_t* hash, size_t hashLen) {}
void resetHMAC(const uint8_t* key, size_t keyLen) {}
void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* hash, size_t hashLen) {}
void update(const void* data, size_t len) { (void)data; (void)len; }
void finalize(void* hash, size_t hashLen) { (void)hash; (void)hashLen; }
void resetHMAC(const void* key, size_t keyLen) { (void)key; (void)keyLen; }
void finalizeHMAC(const void* key, size_t keyLen, void* hash, size_t hashLen) {
(void)key; (void)keyLen; (void)hash; (void)hashLen;
}
};

41
test/mocks/Stream.h

@ -1,10 +1,43 @@
#pragma once
// Mock Stream class for native testing
// Provides minimal interface needed by Utils.h
// Mock Stream class for native testing.
// Provides the Print/Stream surface used by Utils.cpp and RegionMap.cpp (BufStream).
#include <cstdint>
#include <cstddef>
#include <cstdio>
#include <cstdarg>
#include <cstring>
class Stream {
public:
virtual void print(char c) {}
virtual void print(const char* str) {}
virtual ~Stream() {}
virtual size_t write(uint8_t c) { (void)c; return 1; }
virtual size_t write(const uint8_t* buffer, size_t size) {
size_t written = 0;
while (written < size) {
if (!write(buffer[written])) break;
written++;
}
return written;
}
virtual int available() { return 0; }
virtual int read() { return -1; }
virtual int peek() { return -1; }
virtual void flush() {}
size_t print(char c) { return write((uint8_t)c); }
size_t print(const char* str) { return write((const uint8_t*)str, strlen(str)); }
size_t printf(const char* fmt, ...) {
char buf[256];
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
if (n < 0) return 0;
if (n > (int)sizeof(buf) - 1) n = sizeof(buf) - 1;
return write((const uint8_t*)buf, (size_t)n);
}
};

150
test/test_region_gating/test_region_gating.cpp

@ -0,0 +1,150 @@
#include <gtest/gtest.h>
#include "helpers/RegionMap.h"
// Unit tests for duty-cycle region gating (issue #2747).
// These exercise the pure hierarchy/level logic; no crypto or filesystem is touched.
static bool gated(RegionMap& rm, const char* name) {
RegionEntry* r = rm.findByName(name); // findByName("*") returns the wildcard
return r && (r->rt_flags & REGION_DENY_FLOOD);
}
// Build: * > eu > nl > nl-ge > nl-ge-nij (a single deep chain)
static void buildNlChain(RegionMap& rm) {
RegionEntry* eu = rm.putRegion("eu", 0);
RegionEntry* nl = rm.putRegion("nl", eu->id);
RegionEntry* nlge = rm.putRegion("nl-ge", nl->id);
rm.putRegion("nl-ge-nij", nlge->id);
}
// Build: * > cz > { cz-ulk, cz-stc, cz-lbk } (three regions on the same level)
static void buildCzFlat(RegionMap& rm) {
RegionEntry* cz = rm.putRegion("cz", 0);
rm.putRegion("cz-ulk", cz->id);
rm.putRegion("cz-stc", cz->id);
rm.putRegion("cz-lbk", cz->id);
}
TEST(RegionGating, DepthAndMaxGateLevel) {
TransportKeyStore ks;
RegionMap rm(ks);
buildNlChain(rm);
EXPECT_EQ(rm.getMaxDepth(), 4);
EXPECT_EQ(rm.getMaxGateLevel(), 4);
}
TEST(RegionGating, GatesWildcardFirstThenOutermostInward) {
TransportKeyStore ks;
RegionMap rm(ks);
buildNlChain(rm);
rm.applyDutyGate(0);
EXPECT_FALSE(gated(rm, "*"));
EXPECT_FALSE(gated(rm, "eu"));
rm.applyDutyGate(1); // wildcard first
EXPECT_TRUE(gated(rm, "*"));
EXPECT_FALSE(gated(rm, "eu"));
rm.applyDutyGate(2); // + broadest named region
EXPECT_TRUE(gated(rm, "*"));
EXPECT_TRUE(gated(rm, "eu"));
EXPECT_FALSE(gated(rm, "nl"));
rm.applyDutyGate(3);
EXPECT_TRUE(gated(rm, "nl"));
EXPECT_FALSE(gated(rm, "nl-ge"));
rm.applyDutyGate(4); // max: everything except the innermost cluster
EXPECT_TRUE(gated(rm, "nl-ge"));
EXPECT_FALSE(gated(rm, "nl-ge-nij")); // innermost local cluster is always protected
}
TEST(RegionGating, InnermostClusterNeverGatedEvenAboveMax) {
TransportKeyStore ks;
RegionMap rm(ks);
buildNlChain(rm);
rm.applyDutyGate(99); // clamped behaviour
EXPECT_TRUE(gated(rm, "nl-ge"));
EXPECT_FALSE(gated(rm, "nl-ge-nij"));
}
TEST(RegionGating, RecoveryReenablesInsideOut) {
TransportKeyStore ks;
RegionMap rm(ks);
buildNlChain(rm);
rm.applyDutyGate(4);
EXPECT_TRUE(gated(rm, "eu"));
EXPECT_TRUE(gated(rm, "nl-ge"));
rm.applyDutyGate(2); // step back down: nl and nl-ge come back first
EXPECT_TRUE(gated(rm, "*"));
EXPECT_TRUE(gated(rm, "eu"));
EXPECT_FALSE(gated(rm, "nl"));
EXPECT_FALSE(gated(rm, "nl-ge"));
rm.applyDutyGate(0); // fully recovered
EXPECT_FALSE(gated(rm, "*"));
EXPECT_FALSE(gated(rm, "eu"));
}
TEST(RegionGating, MultipleRegionsOnSameLevelGateTogetherAndLeavesProtected) {
TransportKeyStore ks;
RegionMap rm(ks);
buildCzFlat(rm);
EXPECT_EQ(rm.getMaxDepth(), 2);
EXPECT_EQ(rm.getMaxGateLevel(), 2);
rm.applyDutyGate(1); // wildcard only
EXPECT_TRUE(gated(rm, "*"));
EXPECT_FALSE(gated(rm, "cz"));
rm.applyDutyGate(2); // wildcard + cz; the three leaf regions stay up
EXPECT_TRUE(gated(rm, "cz"));
EXPECT_FALSE(gated(rm, "cz-ulk"));
EXPECT_FALSE(gated(rm, "cz-stc"));
EXPECT_FALSE(gated(rm, "cz-lbk"));
}
TEST(RegionGating, LoneWildcardIsNeverGated) {
TransportKeyStore ks;
RegionMap rm(ks); // no named regions
EXPECT_EQ(rm.getMaxDepth(), 0);
EXPECT_EQ(rm.getMaxGateLevel(), 0);
rm.applyDutyGate(1);
EXPECT_FALSE(gated(rm, "*")); // wildcard is the local cluster here, keep serving
}
TEST(RegionGating, SingleNamedRegionGatesWildcardButKeepsRegion) {
TransportKeyStore ks;
RegionMap rm(ks);
rm.putRegion("cz", 0);
EXPECT_EQ(rm.getMaxGateLevel(), 1);
rm.applyDutyGate(1);
EXPECT_TRUE(gated(rm, "*"));
EXPECT_FALSE(gated(rm, "cz"));
}
TEST(RegionGating, RuntimeGateIsSeparateFromConfigFlags) {
TransportKeyStore ks;
RegionMap rm(ks);
buildNlChain(rm);
RegionEntry* eu = rm.findByName("eu");
eu->flags &= (uint8_t)~REGION_DENY_FLOOD; // admin: this region allows flood
rm.applyDutyGate(2); // transiently gate 'eu'
EXPECT_TRUE(eu->rt_flags & REGION_DENY_FLOOD);
EXPECT_FALSE(eu->flags & REGION_DENY_FLOOD); // config untouched
EXPECT_TRUE(eu->effectiveFlags() & REGION_DENY_FLOOD);
rm.applyDutyGate(0); // recover
EXPECT_FALSE(eu->rt_flags & REGION_DENY_FLOOD);
EXPECT_FALSE(eu->effectiveFlags() & REGION_DENY_FLOOD); // flood allowed again
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
Loading…
Cancel
Save