From 77ffee72149d726ce7a08d58dbf0effd5f0a5e24 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:32:23 +0200 Subject: [PATCH 1/8] radio: return real status from reconfigure() and stop panicking on SPI errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconfigure() in all five chip drivers (SX126x, SX128x, LR11x0, LR20x0, RF95) was returning true unconditionally, even when individual SPI setters failed. The caller in RadioInterface::cppInit() reboots the device when reconfigure returns false, so the failure-recovery path was silently disabled. Track failures in a local bool and return its accumulated state. Same drivers also asserted on remote SPI calls in reconfigure() and setStandby() — a momentary glitch on the SPI bus would panic-reset the firmware. Replaced with log-and-continue; the new return value lets the caller make the reboot decision instead of crashing inline. --- src/mesh/LR11x0Interface.cpp | 40 +++++++++++++++++++++++--------- src/mesh/LR20x0Interface.cpp | 40 +++++++++++++++++++++++--------- src/mesh/RF95Interface.cpp | 42 ++++++++++++++++++++++----------- src/mesh/SX126xInterface.cpp | 45 +++++++++++++++++++++++------------- src/mesh/SX128xInterface.cpp | 39 ++++++++++++++++++++----------- 5 files changed, 142 insertions(+), 64 deletions(-) diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 1d1616ed673..8f8521758d8 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -173,21 +173,33 @@ template bool LR11x0Interface::reconfigure() // set mode to standby setStandby(); - // configure publicly accessible settings + // The caller reboots the device when reconfigure returns false; track + // every SPI setter so we don't silently report success after a glitch. + bool ok = true; + int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setBandwidth(bw, wideLora() && (getFreq() > 1000.0f)); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setSyncWord(syncWord); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setSyncWord %s%d", radioLibErr, err); + ok = false; + } if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range limitPower(LR1120_MAX_POWER); @@ -196,14 +208,22 @@ template bool LR11x0Interface::reconfigure() } err = lora.setPreambleLength(preambleLength); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setPreambleLength %s%d", radioLibErr, err); + ok = false; + } err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setOutputPower(power); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 setOutputPower %s%d", radioLibErr, err); + ok = false; + } // Apply RX gain mode — valid in STDBY, matches resetAGC() pattern err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); @@ -212,7 +232,7 @@ template bool LR11x0Interface::reconfigure() startReceive(); // restart receiving - return true; + return ok; } template void LR11x0Interface::disableInterrupt() @@ -230,8 +250,6 @@ template void LR11x0Interface::setStandby() LOG_DEBUG("LR11x0 standby failed with error %d", err); } - assert(err == RADIOLIB_ERR_NONE); - isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index 699663cf032..ed2fe5628c8 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -201,21 +201,33 @@ template bool LR20x0Interface::reconfigure() // set mode to standby setStandby(); - // configure publicly accessible settings + // The caller reboots the device when reconfigure returns false; track + // every SPI setter so we don't silently report success after a glitch. + bool ok = true; + int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setBandwidth(bw); // different form than LR11xx - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setSyncWord(syncWord); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setSyncWord %s%d", radioLibErr, err); + ok = false; + } if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range limitPower(LR2021_MAX_POWER_HF); @@ -224,14 +236,22 @@ template bool LR20x0Interface::reconfigure() } err = lora.setPreambleLength(preambleLength); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setPreambleLength %s%d", radioLibErr, err); + ok = false; + } err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setOutputPower(power); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 setOutputPower %s%d", radioLibErr, err); + ok = false; + } // Apply RX gain mode — valid in STDBY, matches resetAGC() pattern err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); @@ -240,7 +260,7 @@ template bool LR20x0Interface::reconfigure() startReceive(); // restart receiving - return true; + return ok; } template void LR20x0Interface::disableInterrupt() @@ -258,8 +278,6 @@ template void LR20x0Interface::setStandby() LOG_DEBUG("LR20x0 standby failed with error %d", err); } - assert(err == RADIOLIB_ERR_NONE); - isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 43149ef8b40..561f0a7baae 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -208,37 +208,51 @@ bool RF95Interface::reconfigure() // set mode to standby setStandby(); - // configure publicly accessible settings + // The caller reboots the device when reconfigure returns false; track + // every SPI setter so we don't silently report success after a glitch. + bool ok = true; + int err = lora->setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora->setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora->setCodingRate(cr); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora->setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("RF95 setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } err = lora->setCurrentLimit(currentLimit); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("RF95 setCurrentLimit %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } err = lora->setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("RF95 setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } err = lora->setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } limitPower(RF95_MAX_POWER); @@ -247,12 +261,14 @@ bool RF95Interface::reconfigure() #else err = lora->setOutputPower(power); #endif - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } startReceive(); // restart receiving - return true; + return ok; } /** diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 3513bbba3f3..703a11421fc 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -213,37 +213,51 @@ template bool SX126xInterface::reconfigure() // set mode to standby setStandby(); - // configure publicly accessible settings + // The caller reboots the device when reconfigure returns false; track + // every SPI setter so we don't silently report success after a glitch. + bool ok = true; + int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setCodingRate(cr); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } err = lora.setCurrentLimit(currentLimit); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X setCurrentLimit %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } limitPower(SX126X_MAX_POWER); // Make sure we reach the minimum power supported to turn the chip on (-9dBm) @@ -251,9 +265,10 @@ template bool SX126xInterface::reconfigure() power = -9; err = lora.setOutputPower(power); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX126X setOutputPower %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } // Apply RX gain mode — valid in STDBY (datasheet §9.6), matches resetAGC() pattern err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); @@ -262,7 +277,7 @@ template bool SX126xInterface::reconfigure() startReceive(); // restart receiving - return true; + return ok; } template void SX126xInterface::disableInterrupt() @@ -281,8 +296,6 @@ template void SX126xInterface::setStandby() #ifdef ARCH_PORTDUINO if (err != RADIOLIB_ERR_NONE) portduino_status.LoRa_in_error = true; -#else - assert(err == RADIOLIB_ERR_NONE); #endif isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 64d71921a58..60dc3457247 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -117,43 +117,57 @@ template bool SX128xInterface::reconfigure() // set mode to standby setStandby(); - // configure publicly accessible settings + // The caller reboots the device when reconfigure returns false; track + // every SPI setter so we don't silently report success after a glitch. + bool ok = true; + int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX128X setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX128X setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + ok = false; + } limitPower(SX128X_MAX_POWER); err = lora.setOutputPower(power); - if (err != RADIOLIB_ERR_NONE) + if (err != RADIOLIB_ERR_NONE) { LOG_ERROR("SX128X setOutputPower %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + ok = false; + } startReceive(); // restart receiving - return true; + return ok; } template void SX128xInterface::disableInterrupt() @@ -174,7 +188,6 @@ template void SX128xInterface::setStandby() if (err != RADIOLIB_ERR_NONE) LOG_ERROR("SX128x standby %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); #if ARCH_PORTDUINO if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { digitalWrite(portduino_config.lora_rxen_pin.pin, LOW); From 080ab71c358e9fb5e8dc7d6d124039e426e96526 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:32:47 +0200 Subject: [PATCH 2/8] mesh: null-check getMeshNode in handleFromRadio NodeDB::getMeshNode returns nullptr legitimately under heap pressure (see NodeDB::isFull and updateFrom early-out paths). The condition at MeshService.cpp:97 dereferenced the result directly to read has_user, so every received decoded packet from a new node could crash on a device near pool exhaustion. Restructure with an init-statement so the node is fetched once and the rest of the predicate short-circuits on null. --- src/mesh/MeshService.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 952a6d2be37..f02257231c9 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -94,8 +94,9 @@ int MeshService::handleFromRadio(const meshtastic_MeshPacket *mp) mp->decoded.portnum == meshtastic_PortNum_TELEMETRY_APP && mp->decoded.request_id > 0) { LOG_DEBUG("Received telemetry response. Skip sending our NodeInfo"); // ignore our request for its NodeInfo - } else if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag && !nodeDB->getMeshNode(mp->from)->has_user && - nodeInfoModule && !isPreferredRebroadcaster && !nodeDB->isFull()) { + } else if (auto *senderNode = (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag) ? nodeDB->getMeshNode(mp->from) + : nullptr; + senderNode && !senderNode->has_user && nodeInfoModule && !isPreferredRebroadcaster && !nodeDB->isFull()) { if (airTime->isTxAllowedChannelUtil(true)) { const int8_t hopsUsed = getHopsAway(*mp, config.lora.hop_limit); if (hopsUsed > (int32_t)(config.lora.hop_limit + 2)) { From 9c07733bbb984741595cda2a4f9322ca9a67824c Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:33:08 +0200 Subject: [PATCH 3/8] radio: drop oversized RX packets instead of asserting handleReceiveInterrupt bounded the encrypted-payload memcpy with an assert, which a future modem with a longer max payload would turn into a panic-reset on a malformed (or simply larger) air packet. Replace with a runtime length check that releases the pool entry, bumps rxBad, and returns. assert() also vanishes in NDEBUG builds, so there was no actual bound check in release. --- src/mesh/RadioLibInterface.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index de468cf9793..20889dd3c22 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -530,7 +530,13 @@ void RadioLibInterface::handleReceiveInterrupt() mp->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; // Mark that the payload is still encrypted at this point - assert(((uint32_t)payloadLen) <= sizeof(mp->encrypted.bytes)); + if ((uint32_t)payloadLen > sizeof(mp->encrypted.bytes)) { + LOG_WARN("Drop oversized RX packet (%d > %u)", payloadLen, (unsigned)sizeof(mp->encrypted.bytes)); + packetPool.release(mp); + rxBad++; + airTime->logAirtime(RX_ALL_LOG, rxMsec); + return; + } memcpy(mp->encrypted.bytes, radioBuffer.payload, payloadLen); mp->encrypted.size = payloadLen; From c5350a72800d2d33bfc9065b332e0fe4c80130a8 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:33:47 +0200 Subject: [PATCH 4/8] mesh: add exponential backoff and jitter to retransmits setNextTx scheduled every retry at the same base offset returned by getRetransmissionMsec, which is driven by channel utilization rather than per-attempt history. Two nodes that simultaneously NAK each other re-collide on near-identical schedules and waste airtime. Use a 1x/2x/4x backoff (with the multiplier capped at 8x for safety) plus random half-base jitter so concurrent attempts spread out. --- src/mesh/NextHopRouter.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index e8613d45729..48a5175813b 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -349,9 +349,16 @@ int32_t NextHopRouter::doRetransmissions() void NextHopRouter::setNextTx(PendingPacket *pending) { assert(iface); - auto d = iface->getRetransmissionMsec(pending->packet); + // Two nodes that NAK each other will otherwise re-collide on near-identical + // schedules; spread successive attempts with exponential backoff plus jitter. + uint32_t base = iface->getRetransmissionMsec(pending->packet); + uint8_t attempt = (NUM_RELIABLE_RETX - 1) - pending->numRetransmissions; + uint8_t shift = attempt < 3 ? attempt : 3; // cap multiplier at 8x + uint32_t backoff = base << shift; + uint32_t jitter = base ? random(base / 2 + 1) : 0; + uint32_t d = backoff + jitter; pending->nextTxMsec = millis() + d; - LOG_DEBUG("Setting next retransmission in %u msecs: ", d); + LOG_DEBUG("Setting next retransmission in %u msecs (attempt %u): ", d, attempt); printPacket("", pending->packet); setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time } From c15007125c3c046f5844cc2bc42fd48356073098 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:35:01 +0200 Subject: [PATCH 5/8] router: skip pre-encryption packet copy when MQTT does not need it Router::send unconditionally allocCopy/release'd a pre-encryption clone of every relayed packet, even on builds where MQTT was excluded (MESHTASTIC_EXCLUDE_MQTT) or runtime-disabled (mqtt module nullptr or moduleConfig.mqtt.enabled=false) or when the packet was a relay rather than from us. The copy is only fed to MQTT::onSend, so guard the alloc on the same predicate the call site already used. On nodes near pool exhaustion this halves the alloc rate on the hot path. --- src/mesh/Router.cpp | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index ffeb7c5393d..058be504c07 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -372,24 +372,32 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { ChannelIndex chIndex = p->channel; // keep as a local because we are about to change it - DEBUG_HEAP_BEFORE; - meshtastic_MeshPacket *p_decoded = packetPool.allocCopy(*p); - DEBUG_HEAP_AFTER("Router::send", p_decoded); + // The pre-encryption copy is only needed to feed MQTT::onSend; skip it + // entirely on builds/devices where MQTT is excluded or disabled. + meshtastic_MeshPacket *p_decoded = nullptr; +#if !MESHTASTIC_EXCLUDE_MQTT + const bool needDecodedForMqtt = moduleConfig.mqtt.enabled && isFromUs(p) && mqtt; + if (needDecodedForMqtt) { + DEBUG_HEAP_BEFORE; + p_decoded = packetPool.allocCopy(*p); + DEBUG_HEAP_AFTER("Router::send", p_decoded); + } +#endif auto encodeResult = perhapsEncode(p); if (encodeResult != meshtastic_Routing_Error_NONE) { - packetPool.release(p_decoded); + if (p_decoded) + packetPool.release(p_decoded); p->channel = 0; // Reset the channel to 0, so we don't use the failing hash again abortSendAndNak(encodeResult, p); return encodeResult; // FIXME - this isn't a valid ErrorCode } #if !MESHTASTIC_EXCLUDE_MQTT - // Only publish to MQTT if we're the original transmitter of the packet - if (moduleConfig.mqtt.enabled && isFromUs(p) && mqtt) { + if (p_decoded) { mqtt->onSend(*p, *p_decoded, chIndex); + packetPool.release(p_decoded); } #endif - packetPool.release(p_decoded); } #if HAS_UDP_MULTICAST From 00ceb61f2b9a3dc775cb1be6b039dd85b4d16c12 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:36:03 +0200 Subject: [PATCH 6/8] router: don't evict ACKs when a background-priority packet arrives full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fromRadioQueue is a plain FIFO of size 4. When it fills, the prior behaviour was to drop the oldest packet — usually a routing ACK or reply — to make room for whatever just arrived, even if the new packet was BACKGROUND priority. Reverse that for the low-priority case: if the incoming packet's priority is at or below BACKGROUND, drop it instead. Higher-priority arrivals still evict to fit. PointerQueue doesn't expose iteration, so a full priority-queue refactor would be a larger change; this addresses the worst case. --- src/mesh/Router.cpp | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 058be504c07..99a35a7fbbe 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -168,13 +168,22 @@ int32_t Router::runOnce() */ void Router::enqueueReceivedMessage(meshtastic_MeshPacket *p) { - // Try enqueue until successful - while (!fromRadioQueue.enqueue(p, 0)) { - meshtastic_MeshPacket *old_p; - old_p = fromRadioQueue.dequeuePtr(0); // Dequeue and discard the oldest packet - if (old_p) { - printPacket("fromRadioQ full, drop oldest!", old_p); - packetPool.release(old_p); + // Try enqueue, but if the queue is full and the incoming packet is itself + // low priority, drop *it* rather than evicting an older (likely higher- + // priority) packet such as an ACK or routing reply. + if (!fromRadioQueue.enqueue(p, 0)) { + if (p->priority != meshtastic_MeshPacket_Priority_UNSET && p->priority <= meshtastic_MeshPacket_Priority_BACKGROUND) { + printPacket("fromRadioQ full, drop incoming low-prio!", p); + packetPool.release(p); + return; + } + // Higher-priority packet — fall back to evicting until it fits. + while (!fromRadioQueue.enqueue(p, 0)) { + meshtastic_MeshPacket *old_p = fromRadioQueue.dequeuePtr(0); + if (old_p) { + printPacket("fromRadioQ full, drop oldest!", old_p); + packetPool.release(old_p); + } } } // Nasty hack because our threading is primitive. interfaces shouldn't need to know about routers FIXME From eb4e1f2b079c40cf5e3365cbc4cef4f3a57c6605 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 21:37:04 +0200 Subject: [PATCH 7/8] radio: poll TX_DONE and stuck-TX timeout from main loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 60s stuck-TX detector in canSendImmediately only fires when more packets enter the TX queue, because that's the only path that calls it. If the queue empties after the radio wedges, no IRQ ever fires, no poll runs, and the device sits in busyTx forever. pollMissedIrqs is already invoked unconditionally from the main loop — extend it to poll TX_DONE when sendingPacket is set, and trip the same reboot path independently of queue activity. --- src/mesh/RadioLibInterface.cpp | 19 +++++++++++++++++++ src/mesh/RadioLibInterface.h | 1 + 2 files changed, 20 insertions(+) diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 20889dd3c22..99183d6601c 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -561,6 +561,17 @@ void RadioLibInterface::pollMissedIrqs() if (isReceiving) { checkRxDoneIrqFlag(); } + if (sendingPacket != NULL) { + checkTxDoneIrqFlag(); + // The stuck-TX guard in canSendImmediately only runs when the queue + // gets new packets. If the queue is empty, a wedged radio sits + // forever — fire the same reboot path here so it always trips. + if (!Throttle::isWithinTimespanMs(lastTxStart, 60000) && rebootAtMsec == 0) { + LOG_ERROR("Hardware Failure! TX stuck for more than 60s (poll)"); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_TRANSMIT_FAILED); + rebootAtMsec = lastTxStart + 65000; + } + } } void RadioLibInterface::resetAGC() @@ -576,6 +587,14 @@ void RadioLibInterface::checkRxDoneIrqFlag() } } +void RadioLibInterface::checkTxDoneIrqFlag() +{ + if (iface->checkIrq(RADIOLIB_IRQ_TX_DONE)) { + LOG_WARN("caught missed TX_DONE"); + notify(ISR_TX, true); + } +} + void RadioLibInterface::configHardwareForSend() { powerMon->setState(meshtastic_PowerMon_State_Lora_TXOn); diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 0740561f9b2..f024457c211 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -293,4 +293,5 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified bool removePendingTXPacket(NodeNum from, PacketId id, uint32_t hop_limit_lt) override; void checkRxDoneIrqFlag(); + void checkTxDoneIrqFlag(); }; From f4ded6584d0e97bc1ced26bd3cf23cee37ad0c89 Mon Sep 17 00:00:00 2001 From: DatanoiseTV <6614616+DatanoiseTV@users.noreply.github.com> Date: Fri, 8 May 2026 22:10:15 +0200 Subject: [PATCH 8/8] mesh: bound the user-facing notification sprintf calls Two sites built ClientNotification messages with sprintf into a fixed-size proto buffer with no length cap. The current format strings fit comfortably, but a future caller editing either format string without rechecking the buffer size would get a silent stack/heap overrun. Switch to snprintf with sizeof so the bound is enforced at the call site. --- src/mesh/NodeDB.cpp | 2 +- src/mesh/Router.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index ac6880adea4..ef07f68fd64 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1881,7 +1881,7 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); cn->level = meshtastic_LogRecord_Level_WARNING; cn->time = getValidTime(RTCQualityFromNet); - sprintf(cn->message, warning, p.long_name); + snprintf(cn->message, sizeof(cn->message), warning, p.long_name); service->sendClientNotification(cn); } return false; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 99a35a7fbbe..528cd011445 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -338,7 +338,7 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) cn->reply_id = p->id; cn->level = meshtastic_LogRecord_Level_WARNING; cn->time = getValidTime(RTCQualityFromNet); - sprintf(cn->message, "Duty cycle limit exceeded. You can send again in %d mins", silentMinutes); + snprintf(cn->message, sizeof(cn->message), "Duty cycle limit exceeded. You can send again in %d mins", silentMinutes); service->sendClientNotification(cn); meshtastic_Routing_Error err = meshtastic_Routing_Error_DUTY_CYCLE_LIMIT;