Files
firmware/test/test_nodeinfo_send_window
Tom 332c4d7c6f Narrow the ad-hoc NodeInfo greeting (#11897)
* feat(nodedb): greet only while the node store is under half full

The ad-hoc greeting in MeshService::handleFromRadio() was gated on
!isFull(), so a node kept sending unsolicited NodeInfo right up to the
last free slot - on a dense mesh that is the regime where the store is
already churning and the greeting is least likely to buy a lasting
entry.

Add NodeDB::isHalfEmpty(), true only when strictly more than half the
slots are free, and gate the greeting on it instead. The comparison is
written as 2 * numMeshNodes < cap so a half-full store reads false with
no integer rounding, and MAX_NUM_NODES is read into a local because
portduino resolves it through a runtime call.

The helper keeps the MINIMUM_SAFE_FREE_HEAP term that !isFull() used to
contribute: low heap disqualifies the store regardless of occupancy, so
a sparse database on a memory-starved device still does not transmit.

Admission is untouched - updateFrom() and getOrCreateMeshNode() still
fill to capacity. Only greeting stops early.

* fix(nodeinfo): raise the minimum greeting window to 30 minutes

The !shorterTimeout branch of NodeInfoModule::allocReply() used a
10-minute base, so a node that had just greeted one neighbour could
greet the next ten minutes later. Raise the base to 30 minutes.

This is the floor, not the window: getConfiguredOrDefaultMsScaled()
still multiplies by the congestion coefficient for the roles that scale,
so a busy mesh stretches it further. ROUTER/ROUTER_LATE and the
tracker/sensor roles bypass the scaling and get a flat 30 minutes.

The interactive paths are unaffected - they pass shorterTimeout and keep
their own 60-second gate. The periodic broadcast is unaffected too:
default_node_info_broadcast_secs is 3 hours with a 1-hour minimum, both
clear of the new floor, so the timer is not swallowed by the throttle.

* fix(nodeinfo): a send restarts the routine broadcast countdown

sendOurNodeInfo() left the OSThread schedule alone, so an ad-hoc send
had no effect on the periodic broadcast: run() anchors the next run at
runned() + interval, and nothing re-anchored it when the send came from
a greeting, a PKI decrypt failure or a completed key verification. The
routine copy could follow minutes behind an ad-hoc one, putting two
NodeInfos on the air for no gain.

Call setIntervalFromNow() with the configured broadcast interval once
the packet is queued, so the next periodic copy is a full interval from
the send rather than from the last tick.

It sits on the return-true path only: a send vetoed by allocReply() -
throttle, airtime ceiling, reply suppression - must not be able to
silence the routine broadcast. Calling it from inside runOnce() is
harmless, since run() then applies the same interval from a last_run of
effectively now.

* test(nodeinfo): cover the send window, the countdown reset and the greeting gate

Three behaviours from this branch had no coverage: isHalfEmpty()'s exclusive
boundary, the 30-minute send floor, and the countdown reset on a send.

isHalfEmpty() goes to test_nodedb_blocked, which already owns the full-store
cases and clears the hot store per test. Three tests sweep the cap over the
sizes real deployments have - portduino resolves MAX_NUM_NODES from
General.MaxNodes on every read, so a predicate that cached it would greet at the
wrong occupancy - and pin the band where admission outlives greeting. That suite
had no tearDown; it has one now, restoring the cap so an assertion firing
mid-sweep cannot leak a 2-node cap into the tests after it.

test_nodeinfo_send_window is new because nothing in the tree stands up
NodeInfoModule's send path. Six tests: the floor at 30 minutes with 10 refused,
the interactive 60-second gate staying separate, the countdown re-armed by a
broadcast and by an ad-hoc unicast, left alone by a refused send, and a preset
change consumed only by a send that goes out.

The scaling above 40 online nodes is deliberately not retested here -
getConfiguredOrDefaultMsScaled() is test_default's contract, per preset and per
role. These tests pin the base and leave the multiplier alone.

NodeInfoModule gains two PIO_UNIT_TESTING accessors for the countdown:
concurrency::OSThread is a private base, so a test shim cannot reach it and only
the class itself can. They compile out of a shipping build.

The heap term in isHalfEmpty()/isFull() stays uncovered: memGet.getFreeHeap()
returns UINT32_MAX on portduino, so a native test could only pin a stub.

* chore(trunk): exempt test_nodedb_blocked from the trufflehog Lob detector

test_removeNodeByNum_presentNodeOnFullDb is exactly 35 characters after the
test_ prefix, which is the length of a Lob API key, and trufflehog's detector
matches the bare identifier. The name is years old; it surfaces now only because
this branch touches the file, and the pre-push gate reports a finding in a
changed file as new.

Added to the ignore block that already carries the same detector's hex-literal
false positives, with the reason stated alongside them. Nothing in that file is
a credential.

* fix(nodeinfo): exempt a licensed station from the floor, delay only on a real send

Two review findings on the 30-minute window.

Ham mode sets node_info_broadcast_secs to 600 s for the FCC minimum call-sign
announcement (AdminModule.cpp). The new floor refused every one of those sends
until 30 minutes had passed, so a licensed station's call sign went out three
times less often than the regulation asks - a regression the old 10-minute base
did not have. A licensed station now keeps its own interval whenever that is
shorter than the floor. The exemption is exactly the licensed case because
nothing else can get under the floor: a set-config clamps the field to an hour,
and the userprefs path clamps identically.

sendOurNodeInfo() ignored what sendToMesh() returned, so a packet the router
declined - no interface, queue full - still re-armed the routine broadcast and
still reported success, which let runOnce() consume a pending channel change
for a send that never reached the air. Only ERRNO_OK and ERRNO_SHOULD_RELEASE
now count; sendToMesh() has already released the packet in both cases.

Both are pinned by tests that fail without them, measured: the licensed case
fails at "11 min is past it, and the floor must not override it", the declined
send at "a declined send is not a send". The licensed test carries an unlicensed
control on the same configuration, so deleting the floor outright would not
satisfy it.

* test(nodeinfo): assert the deadline the scheduler reads, from an aged last_run

The countdown cases asserted Thread::interval, which is not what schedules the
next run: shouldRun() keys off _cached_next_run, and the two ways of writing it
differ. setIntervalFromNow() recomputes it from now; Thread::setInterval()
recomputes it from last_run. Swap the call in sendOurNodeInfo() for the latter
and the period still reads three hours while the deadline lands wherever the
last tick was - firing the routine copy right behind an ad-hoc send, the exact
thing the reset exists to prevent. Every test passed.

Assert the deadline instead, from a fixture where the two answers are
distinguishable: ageLastRunForTests() calls Thread::runned() with an hour-old
timestamp, the state a periodic thread is genuinely in between runs, so a
deadline off last_run lands an hour early against a five second tolerance.

Measured: with setInterval() in place of setIntervalFromNow(), the new case
fails by 3600004 ms and the eight others pass, including the one asserting the
period - which is what says the old assertion could not see this.

runned() and _cached_next_run are protected in Thread and OSThread is a private
base, so the hooks live on NodeInfoModule, with the two already there.

Raised by Copilot on #11897.

* fix(nodeinfo): a declined send must not start the throttle window either

allocReply() stamped TransmitHistory when it built the packet, before anything
had been sent. The previous commit made sendOurNodeInfo() report a router
rejection instead of swallowing it, but the stamp was already written by then,
so a packet that never reached the air still started the window - and with the
floor now at 30 minutes, that silences the node for half an hour over a send
that failed.

allocReply() has two callers and only one of them can see the outcome: the
module framework sends its own reply through currentReply, with no post-send
hook a module can reach (MeshModule::sendResponse is not virtual). So the stamp
stays there for that path, and sendOurNodeInfo() defers it across its own
allocReply() call and stamps once the router has accepted the packet.
deferHistoryStamp mirrors the shorterTimeout member alongside it - same
call-scoped signal, same lifetime.

test_sendWindow_aRejectedSendDoesNotStartTheWindow asserts both halves: no stamp
after the rejection, and the retry immediately after goes out. The existing
rejected-send case checked the first failure and the countdown only, which is
how this survived it.

248/248 across every suite that touches NodeInfoModule (admin_session_repro,
admin_radio, nodeinfo_send_window, traffic_management, fuzz_packets) plus
transmit_history, whose subject this is.

Raised by CodeRabbit on #11897.
2026-09-20 10:32:41 +00:00
..