Skip to content

fix(ReliableRouter): keep the retry across a duty-cycle rejection - #11477

Draft
h3lix1 wants to merge 3 commits into
meshtastic:developfrom
h3lix1:fix/duty-cycle-retry-carveout
Draft

fix(ReliableRouter): keep the retry across a duty-cycle rejection#11477
h3lix1 wants to merge 3 commits into
meshtastic:developfrom
h3lix1:fix/duty-cycle-retry-carveout

Conversation

@h3lix1

@h3lix1 h3lix1 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

ReliableRouter::send() deliberately leaves the pending record in place when the send comes back
DUTY_CYCLE_LIMIT, on the reasoning in its own comment that the rejection may clear before the
scheduled retry:

    if (retransmitting && result != ERRNO_OK && result != meshtastic_Routing_Error_DUTY_CYCLE_LIMIT)
        stopRetransmission(key);

That never worked. Router::send() aborts the packet on a duty-cycle rejection and NAKs it back to us
through abortSendAndNak() -> sendLocal() -> deliverLocal(). deliverLocal() only defers when
handleDepth > 0, and ReliableRouter::send() runs with handleDepth 0, so the NAK is delivered
inline. sniffReceived() sees an error NAK carrying the packet's id and calls
stopRetransmission(), erasing the record before send() ever reaches the line above.

So the retry the carve-out exists to preserve is gone by the time the check runs, and a duty-cycle
rejection is final in practice rather than transient.

The fix skips that stopRetransmission() for a self-addressed DUTY_CYCLE_LIMIT NAK. It is
locally generated and describes a condition that clears on its own, so it is the one NAK that should
not be the last word. The client still receives it.

Duty cycle applies where dutyCycle < 100: EU_433, EU_868, EU_866, EU_N_868, TH and UA_433.

The test was green for the wrong reason

test_C14_duty_cycle_limited_reliable_send_remains_pending asserts exactly the behaviour that was
broken, and passed anyway. AuthPipelineRoutingModule::sendAckNak() counted the call and returned,
so nothing was ever delivered, so nothing erased the record, and pendingCount() == 1 held for a
reason unrelated to what the test claims to check.

The second commit makes that stub deliver its NAKs the way sendLocal() does. With the stub fixed
and the source change reverted, C14 fails:

test_C14_duty_cycle_limited_reliable_send_remains_pending:
  Expected 1 Was 0. duty-cycle rejection must retain the retry for when airtime is available

which is the assertion it was written to make.

Worth flagging separately: the same synchronous loopback is why doRetransmissions() can use a
freed pending record, which I have raised as its own PR. This one is the narrower behavioural half.

Testing: builds clean on heltec-v4 and seeed-xiao-s3, 169/169 across test_packet_signing,
test_nexthop_routing and test_mqtt. Not exercised on hardware, since it needs an EU-region node
driven past its duty-cycle budget and my bench is US-region.

🤖 Generated with Claude Code

🤝 Attestations

  • I have tested that my proposed changes behave as described.
  • I have tested that my proposed changes do not cause any obvious regressions on the following devices:
    • Heltec (Lora32) V3
    • LilyGo T-Deck
    • LilyGo T-Beam
    • RAK WisBlock 4631
    • Seeed Studio T-1000E tracker card
    • Other: compile-only on Heltec V4 and Seeed XIAO ESP32-S3

h3lix1 added 2 commits August 13, 2026 01:17
ReliableRouter::send() deliberately leaves the pending record in place when the
send comes back DUTY_CYCLE_LIMIT, on the reasoning in its comment that the
rejection may clear before the scheduled retry. That never worked.

Router::send() aborts the packet on a duty-cycle rejection and NAKs it back to
us via abortSendAndNak() -> sendLocal() -> deliverLocal(). deliverLocal() only
defers when handleDepth > 0, and ReliableRouter::send() runs with handleDepth 0,
so the NAK is delivered inline. sniffReceived() sees an error NAK carrying the
packet's id and calls stopRetransmission(), erasing the record before send()
ever reaches the line that was supposed to protect it.

Skip that stopRetransmission() for a self-addressed DUTY_CYCLE_LIMIT NAK. The
error is transient and locally generated, so it is the one NAK that should not
be the final word. The client still receives it.

Duty cycle applies where dutyCycle < 100: EU_433, EU_868, EU_866, EU_N_868, TH
and UA_433.
AuthPipelineRoutingModule::sendAckNak() counted the call and returned. In the
firmware the call continues into router->sendLocal(), and a NAK addressed to
ourselves reaches deliverLocal(), which delivers inline whenever handleDepth is
0, so ReliableRouter::sniffReceived() acts on it while the original caller is
still mid-send.

That gap is why test_C14_duty_cycle_limited_reliable_send_remains_pending
passed against a carve-out that did not work: with no delivery there was nothing
to erase the record, so pendingCount() was 1 for the wrong reason.

Drive the loopback for error NAKs. C14 now fails without the accompanying fix
with "Expected 1 Was 0", which is the behaviour it was written to assert.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 70d864ce-0a9d-4635-a927-5666c863c4cc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Note

Building this pull request… the flash button, badges and supported-board
list will appear here automatically once CI finishes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant