MeshCore 1.17 rewrote listen-before-talk to survive LoRa interrupt flags that get stuck. On a well-behaved radio the change is invisible, and twelve runs found nothing at all. So we built a radio that sticks. The two versions then separated by a factor of 43.
The release notes are specific about the problem:
"The 'listen-before-talk' code has had some pretty big changes, thanks to Taco. He has spent weeks doing diagnostics, and even simulations, testing an improved scheme which involves handling cases where the LoRa radio's IRQ flags get 'stuck', and also the big one being proper preamble detection."
"He found that the new scheme performs on par with hardware CAD, but without the 4 second lock-up glitches that CAD still suffers from."
MeshCore v1.17.0 release notes, 9 August 2026. Changelog entry#2977 preamble and header IRQ rx timeout logic.
That is a testable claim about a specific hardware misbehaviour, so we tested it.
1.16 decides the channel is busy in two lines. It asks the radio and believes the answer:
uint16_t irq = getIrqFlags();
return (irq & HEADER_VALID) || (irq & PREAMBLE_DETECTED);
1.17 replaces that with a state machine which tracks how long each flag has been raised, and clears one that has been up implausibly long: 66 ms for a preamble, 3,934 ms for a payload, both derived from the airtime a real frame would take at the configured modulation.
Those two agree in every case but one, an interrupt that fires with nothing on the air and then stays up. On a chip that never lies they are the same function. That is why earlier sweeps comparing 1.16 and 1.17 produced byte-identical results twelve runs running, first on a stand-in radio and then again on the real driver. The change was not doing nothing. There was nothing for it to do.
MeshBench runs real MeshCore firmware. The application from examples/ is compiled
natively for the host rather than for an nRF52, with no changes to the application or to its radio
driver. Each node is a separate operating-system process with its own storage, identity and clock,
talking to the harness over a bridge.
What is simulated is everything below the driver.
So when this report says 1.16 believed the channel was busy, that is a real decision taken by real firmware. What it was reacting to is a model of a chip and a model of the air. A virtual SX1262 is only as good as the errata we chose to give it.
Real SX1262s sometimes latch a detection interrupt and hold it. The important detail is that clearing the flag still works. The flag is spurious, not stuck on write. A driver that clears a flag which has been up too long recovers; one that trusts it believes the channel is busy for ever.
So the chip was given that fault, compiled in:
// The fault, on builds compiled to have it: a detection interrupt that fires
// with nothing on the air and then stays up.
//
// Clearing it works perfectly well - which is the whole point.
if (stuckIrqMs_ > 0 && mode_ == 1 && nowMs_ >= nextSpuriousMs_) {
irq_ |= kIrqPreambleDetected;
spuriousRaises_++;
nextSpuriousMs_ = nowMs_ + stuckIrqMs_;
}
Built with STUCK_IRQ_MS=1500, so a receiving node has a false preamble-detected
interrupt raised every 1.5 seconds. It is a build rather than a runtime switch, so an experiment
selects it exactly like any other firmware version:
repeater-v1.17.0 a chip that behaves
repeater-v1.17.0-faultyirq a chip that latches its detection flags
The chip also counts what the firmware asked it: interrupt register reads, how many of those found a busy flag set, how long the flags were up, and how many spurious preambles it raised. That instrumentation is what separates a genuinely busy mesh from a chip that cries busy too readily. Without it, the second would look exactly like a finding about the firmware.
154 nodes imported from ScotMesh's CoreScope deployment at their real positions and antenna heights: 136 repeaters and 18 companions. They run EU/UK (Narrow), which is 869.618 MHz, 62.5 kHz bandwidth, SF8, CR4/8, the preset ScotMesh actually uses.
Six companions each send one message into #sco, fired together at 45 seconds, with 40
seconds measured afterwards. Node storage is wiped between runs. Seeds 4417 and 9001, 14 runs in
total, on MeshCore v1.16.0 and v1.17.0, each in a well-behaved and a -faultyirq build,
over RadioLib 7.6.0.
1.17 on a lying radio behaves better than 1.16 does on a working one. The recovery path is not a marginal improvement. It removes the failure mode.
| build | transmissions | to repeaters | to companions | collisions | airtime |
|---|---|---|---|---|---|
| 1.16.0 | 214 | 31.13% | 43.14% | 2,621 | 133 s |
| 1.17.0 | 214 | 31.13% | 43.14% | 2,621 | 133 s |
| 1.16.0-faultyirq | 144 | 25.37% | 26.47% | 1,169 | 100 s |
| 1.17.0-faultyirq | 144 | 25.37% | 26.47% | 1,169 | 100 s |
1.16 is not silenced, because MeshCore's dispatcher has a backstop. When channel-activity
detection has been busy for longer than getCADFailMaxDuration(), it raises
ERR_EVENT_CAD_TIMEOUT and transmits regardless. 1.16 is rescued by that emergency
override on almost every packet. 1.17 never needs it.
That is a more useful finding than a throughput difference would have been. The fix is not about getting more packets out, because the mesh was going to get them out either way. It is about whether a node spends its life in a false busy state and escapes only through an error path. On a congested mesh the backstop hides that completely.
Take the congestion away and it stops hiding.
Later work showed that firing six senders simultaneously makes this scenario chaotic. Configurations that ought to be equivalent can land up to 20% apart, deterministically.
That does not touch the busy-time findings, which are per-node instrumentation counters rather than between-arm aggregates. 96% against 35%, and 43 times, are far outside any such floor, and the two-node case has no contention at all. It does mean the delivery figures in Table 1 should be read as approximate. The drop from 214 to 144 is real; its precise size is not.
Other limits worth stating plainly. Two seeds is enough to see a 43-fold effect and not enough to characterise a small one. A virtual chip is a model of a chip: the fault injected here is our reading of a real erratum, a spurious latching preamble every 1.5 seconds, and real hardware may stick differently, more rarely, or in bursts. What this shows is that given that fault, 1.17 recovers and 1.16 does not. It does not establish how often real radios do it.
The model is also optimistic throughout. There is no multipath, the terrain is bare earth, and there is no body loss, oscillator error, or interference beyond the mesh's own traffic. Every one of those omissions makes real links worse, so absolute delivery figures are an upper bound.
Worth admitting too: the fault model was wrong on the first attempt. The initial version made the chip refuse to clear its flags, but then no driver can recover, and both versions were equally stuck, which looked like another null result. The real erratum is that the flag latches spuriously and clearing still works. Getting that distinction right was the whole experiment.
The finding is a good one, but the more interesting part is that it could be found at all. A 43-fold difference in how long a node believes the air is occupied is invisible to every ordinary method. It does not show up in delivery statistics, it does not show up in a packet capture, and on working hardware it does not show up at all.
Five things had to be true at once.
The firmware is real. Not a model of MeshCore and not a reimplementation, but the application from
examples/ compiled for the host, one process per node, each with its own storage,
identity and clock. Every decision in this report was taken by the same code that runs on hardware.
So is the driver. MeshCore's CustomSX1262 wrapper on RadioLib 7.6.0, unmodified, all
the way down to the SPI transactions. That is what makes the question meaningful, because 1.16 and
1.17 differ inside the driver, so anything that stubs the driver out cannot see the difference by
construction.
The hardware is a model we control, which means a hardware erratum becomes something you can specify. You cannot buy a radio that latches its preamble flag exactly every 1.5 seconds. Here you can compile one.
The fault is a build, not a switch. repeater-v1.17.0-faultyirq sits beside
repeater-v1.17.0 and is selected the same way any version is, so "does this release
survive a radio that sticks?" becomes a row in a matrix rather than a special project.
And the chip reports what it was asked: interrupt reads, how many found a flag set, how long the flags were up, how many spurious ones were injected. Without those counters, 1.16's paralysis and a genuinely busy mesh look identical, and the wrong one of those is a finding about the firmware.
Together that is the ability to ask a question no fleet of real radios can answer on demand. What happens to this firmware when the hardware misbehaves in precisely this way? And to get a number back, reproducibly, in fourteen runs.
MeshCore 1.17's listen-before-talk rewrite does what its release notes say. On a radio that latches its detection interrupts, 1.17 performs better than 1.16 does on a radio that works.
On a busy mesh you would never know, because the CAD-timeout backstop hides it. That is itself worth knowing, because it means the fault has been costing quiet links far more than anyone's delivery statistics would ever have shown.