variants/linux: block on the LoRa IRQ in poll() instead of spinning; non-blocking hardware CAD - #4
Open
mmmorks wants to merge 1 commit into
Open
variants/linux: block on the LoRa IRQ in poll() instead of spinning; non-blocking hardware CAD#4mmmorks wants to merge 1 commit into
mmmorks wants to merge 1 commit into
Conversation
…ng CAD ardulinux skips its loop sleep whenever real hardware is bound, so the main loop ran flat out; l5yth#24 turned that into a 1 ms sleep per iteration, which is a fixed-rate poll of the radio rather than an idle. Block on the radio instead: bind DIO1 as an EventGPIOPin -- an ardulinux GPIOPin that also exposes a libgpiod rising-edge event descriptor (v1 and v2) -- and have loop() end in poll() on that descriptor until the IRQ fires or a bounded ceiling elapses. Idle CPU drops from a busy core to well under 1% with edge detection (1-3% in the polling fallback), with no added packet latency: DIO1 carries both RX-done and TX-done, so packet events wake the loop at once. l5yth#24's sleep() and delay(1) stay; on Linux the 1 ms delay is now redundant, since the poll() wait follows it. The seam is a new MainBoard::idleUntilEvent(max_wait_ms), default no-op, with the implementer's contract written down: never lose an IRQ that is already asserted on entry (a level-latched DIO1 that went high beforehand may produce no further edge -- ESP32Board::sleep() already checks gpio_get_level() for the same reason), and never wait on a descriptor the caller will not drain (POLLIN is level-triggered, so an undrained one turns the wait back into a busy loop). LinuxBoard's implementation re-reads the IRQ level via digitalRead() before blocking -- in ardulinux that refreshes the cached level gpioIdle() fires the ISR against, and it is what makes a ceiling longer than a packet's airtime safe. The ceiling is 50 ms: the floor of Dispatcher's delayed-inbound queue, and everything faster arrives on the IRQ. LinuxEventLoop backs off 1 ms on POLLNVAL/POLLHUP/poll() failure, because poll() returns a positive count for those and a single closed descriptor would otherwise reinstate the spin. The same descriptor fixes hardware CAD. RadioLib's scanChannel(), which performChannelScan() calls, spins on digitalRead(DIO1) with no deadline: on Linux that burns a core for the length of every scan, and would turn EventGPIOPin's deliberate read-fails-as-LOW degradation into an unbreakable hang. The override splits the scan into startChannelScan(), a sleep on the edge with a deadline derived from the active SF/BW (8 symbol times plus 20 ms), and getChannelScanResult() -- read over SPI regardless of whether the line reported, so a dead line costs latency and a log line, never a wrong answer. It stays synchronous on purpose: isChannelActive() has to answer "is the channel clear right now", and CAD puts the modem in standby, so there is nothing to overlap with. This depends on CAD_DONE being in the DIO1 routing mask alongside CAD_DETECTED (a free channel arrives as an edge, not a timeout); verified against the pinned RadioLib. symbolMicros() moves out of calcMaxPacketMillis() so the two share one formula. Both LinuxEventLoop and the wait (LinuxRadioWait) depend only on POSIX -- no RadioLib, no libgpiod, no Arduino -- so they compile into the native gtest env. The tests cover the anti-spin properties directly (a removed backoff or an unfiltered poll() count fails them), drive real signals through the wait for EINTR, and check the INT_MAX clamp on the timeout. Run on a Pi with the Waveshare SX1262 HAT on bookworm (libgpiod 1.6.3). The libgpiod v2 path builds in the trixie container but has not been run on hardware; the README says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EXSCjgNEbJfHwLjD2WSHW4
mmmorks
force-pushed
the
pr/04-event-loop-cad
branch
from
September 8, 2026 04:11
c22de21 to
b3ea334
Compare
mmmorks
force-pushed
the
pr/03-sx1262-parity
branch
from
September 8, 2026 04:11
d1082d7 to
294f8c5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Builds on l5yth#24.
LinuxBoard::sleep()from l5yth#24 stays, and so does thedelay(1)in the non-powersaving branch; this adds the piece that letsmeshcoredidle on the radio: the main loop blocks inpoll()on a libgpiod edge-event descriptor for DIO1 (plus anything else it will drain that iteration) until the IRQ fires or a bounded timeout elapses. Idle CPU goes from a busy core to well under 1% with edge detection, and roughly 1–3% in the polling fallback, with no added packet latency — DIO1 carries both RX-done and TX-done, so packet events wake the loop immediately. On Linux the 1 msdelay(1)is now redundant (thepoll()wait follows it); it is left in place because the other targets in this tree rely on it.The same edge descriptor also fixes hardware CAD (
set cad on): RadioLib'sscanChannel()busy-spins ondigitalRead(DIO1)with no deadline, which burned a core for every scan and would turn a GPIO read that starts failing into an unbreakable hang.performChannelScan()is overridden to start the scan, sleep on the edge with a deadline derived from the active SF/BW, and read the verdict over SPI regardless.What changed
src/MeshCore.h:virtual void MainBoard::idleUntilEvent(uint32_t max_wait_ms), default no-op, with the implementer's contract written down (do not lose an IRQ already asserted on entry; do not wait on a descriptor the caller will not drain). ESP32Board/NRF52Board already implement the same idea inside theirsleep(), gated on the powersaving pref; this is the unconditional, always-safe variant. Boards that do not implement it keep the historical busy loop.examples/simple_repeater/main.cpp: callsboard.idleUntilEvent(IDLE_MAX_WAIT_MS)(50 ms, overridable) at the end ofloop(), after the existing powersaving block. The comment records why 50 ms: it is the floor of the delayed-inbound queue, and everything faster arrives on the IRQ.variants/linux/LinuxEventLoop.{h,cpp}: pure-POSIXpoll()wrapper over an abstractLinuxEventSource, so it compiles and is unit-tested on a host with no libgpiod.wait()sleeps 1 ms and reports 0 onPOLLNVAL/POLLHUP/non-EINTR failure, becausepoll()returns a positive count for those and a single closed descriptor would otherwise reinstate the busy loop.variants/linux/EventGPIOPin.{h,cpp}: an ardulinuxGPIOPinthat is also aLinuxEventSource, exposing a pollable rising-edge descriptor. Supports libgpiod v1 and v2 (selected by preprocessor check).setPinMode()re-applies edge detection, because RadioLib'spinMode(irq, INPUT)inSX126x::begin()would otherwise drop it. If edge detection is unavailable it degrades to a plain pin and the loop falls back to a 1 ms poll timeout; startup logs which mode the IRQ pin ended up in.variants/linux/LinuxBoard.{h,cpp}: the IRQ pin is bound throughEventGPIOPin;idleUntilEvent()andwaitForRadioIrq(). Both re-read the IRQ level viadigitalRead()before blocking — in ardulinux that refreshes the cached levelgpioIdle()fires the ISR against, and it is what lets the ceiling be longer than a packet's airtime without silently stopping RX on a latched-high DIO1.variants/linux/LinuxRadioWait.{h,cpp}:waitForIrqAsserted()(depends only on the event loop andCLOCK_MONOTONIC, so it is native-testable) andcadTimeoutMillis()(8 symbol times, twice RadioLib's 4-symbol scan, plus 20 ms).src/helpers/radiolib/LinuxSX1262Wrapper.h:performChannelScan()override =startChannelScan()→ wait on the edge with the deadline →getChannelScanResult(). Relies onCAD_DONEbeing in the DIO1 routing mask alongsideCAD_DETECTED, so a free channel arrives as an edge rather than a timeout (checked against the pinned RadioLib).symbolMicros()moves toRadioLibWrapperso the CAD deadline andcalcMaxPacketMillis()share one formula.test/test_linux_event_loop(anti-spin properties: stale-descriptor backoff, filtered POLLIN count, POLLHUP handling) andtest/test_linux_radio_wait(deadline governs under EINTR, each wake re-reads the line, INT_MAX clamp).platformio.ini's native env adds-I variants/linuxand the two sources.## Operationwith "Idle CPU usage" (what the startup and degradation log lines mean) and "Channel Activity Detection" (how it composes withint.thresh, and the scan-duty cost at high SF); a Known Gaps entry that the libgpiod v2 path is compile-verified only.Why
ardulinux skips its loop sleep whenever real hardware is bound, so before l5yth#24
meshcoredburned 100% of a core; l5yth#24 made that a 1 ms sleep per iteration, which is a fixed-rate poll of the radio rather than an idle. Blocking on the IRQ line is what an event-driven daemon should do, and it is also the only way to make CAD non-blocking without giving up the synchronous "is the channel clear right now" answerisChannelActive()has to give (there is nothing to overlap with anyway — CAD puts the modem in standby).How it was tested
linux_repeaterbuild for arm64 in the container.Dependencies
Stacked on
pr/03-sx1262-parity(the CAD override lives in the reworked wrapper). Review the last commit only until that merges.pr/06-gpsadds lines next to this PR'splatformio.ininative-env lines; whichever lands second will need a trivial rebase.Shared code touched
src/MeshCore.h(new virtual with a no-op default)examples/simple_repeater/main.cpp(one call at the end ofloop(); a no-op on every board that does not implement it)src/helpers/radiolib/RadioLibWrappers.{h,cpp}(symbolMicros()extracted fromcalcMaxPacketMillis(); no behaviour change)platformio.ini(native test env only)