MeshBenchScotMesh, 154 nodes MeshCore 1.16.0 vs 1.17.014 runs

Testing 1.17's listen-before-talk on a radio that lies

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.

What 1.17 claims

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.

The two implementations

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.

The harness, and where the simulation starts

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.

the stack a packet passes through, top to bottom MeshCore application examples/simple_repeater, unmodified REAL MeshCore radio wrapper CustomSX1262 / CustomSX1262Wrapper REAL RadioLib 7.6.0 the actual SX126x driver, unmodified REAL the firmware believes everything below this line RadioLibHal SimHal: pins, SPI, timing SIMULATED SX1262 silicon VirtualSX1262: opcodes, registers, IRQs SIMULATED The air MeshBench engine: path loss, terrain, noise SIMULATED
Figure 1: where the real firmware ends and the model begins Above the line is MeshCore's and RadioLib's own code, unmodified, making its own decisions. Below it is a model: a virtual SX1262 that answers SPI opcodes and raises interrupts, a HAL that supplies pins and simulated time, and a channel that sums waveforms, applies terrain and noise, and lets the demodulator decide what survives. The firmware cannot tell the difference, which is the point and also the caveat.

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.

The faulty variant

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.

The scenario

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.

The result

share of the firmware's own channel checks that found the air busy 1.16.0 radio behaves 46.2% 1.17.0 radio behaves 46.9% 1.16.0-faultyirq radio latches 95.9% 1.17.0-faultyirq radio latches 35.1%
Figure 2: how often each build's own channel checks found the air busy On a radio that behaves, 1.16 and 1.17 are indistinguishable at 46.2% against 46.9%. That is the null result every earlier sweep produced. Give both the fault and they separate completely: 1.16 finds the channel busy on 96% of checks, while 1.17 sits at 35%, lower than it manages on a working radio.
node-time spent believing the channel was busy (log scale) 100 k ms 1 M ms 10 M ms 1.16.0 483,026 ms 1.17.0 481,009 ms 1.16.0-faultyirq 13,140,556 ms 1.17.0-faultyirq 300,685 ms 43x
Figure 3: node-time spent believing the channel was occupied Summed across all 154 nodes, log scale. Both builds received an identical 9,209 injected spurious interrupts. 1.16 turned them into 13,140,556 ms of false busy, 1.17 into 300,685 ms. That factor of 43 is the listen-before-talk rewrite doing what the release notes claim.

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.

And yet the mesh delivered the same

buildtransmissionsto repeatersto companions collisionsairtime
1.16.021431.13%43.14%2,621133 s
1.17.021431.13% 43.14%2,621133 s
1.16.0-faultyirq14425.37%26.47%1,169100 s
1.17.0-faultyirq14425.37% 26.47%1,169100 s
Table 1: delivery outcomes Within each radio condition the two versions are identical to the decimal. The fault costs the mesh about a third of its traffic, but it costs 1.16 and 1.17 the same amount, despite one of them spending 43 times longer paralysed.

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.

two nodes, empty channel: node-time believing busy 1.16.0-faultyirq 17,463 ms 1.17.0-faultyirq 55 ms 317x. With no other traffic the backstop rarely fires and the fault is not masked
Figure 4: two nodes, nothing else on the channel With no competing traffic the backstop rarely fires and the difference is stark, 17,463 ms against 55 ms. This is the number that would matter to a quiet real-world link, and it is the strongest evidence in the study because it has no contention to confound it.

What to trust here, and what not to

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.

What made this measurable

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.