Skip to content

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
pr/03-sx1262-parityfrom
pr/04-event-loop-cad
Open

variants/linux: block on the LoRa IRQ in poll() instead of spinning; non-blocking hardware CAD#4
mmmorks wants to merge 1 commit into
pr/03-sx1262-parityfrom
pr/04-event-loop-cad

Conversation

@mmmorks

@mmmorks mmmorks commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Staged on the fork for review. Final destination: l5yth/meshcore-linux linux. Base here is pr/03-sx1262-parity so the diff shows only this change.

Summary

Builds on l5yth#24. LinuxBoard::sleep() from l5yth#24 stays, and so does the delay(1) in the non-powersaving branch; this adds the piece that lets meshcored idle on the radio: the main loop blocks in poll() 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 ms delay(1) is now redundant (the poll() 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's scanChannel() busy-spins on digitalRead(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 their sleep(), 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: calls board.idleUntilEvent(IDLE_MAX_WAIT_MS) (50 ms, overridable) at the end of loop(), 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-POSIX poll() wrapper over an abstract LinuxEventSource, so it compiles and is unit-tested on a host with no libgpiod. wait() sleeps 1 ms and reports 0 on POLLNVAL/POLLHUP/non-EINTR failure, because poll() returns a positive count for those and a single closed descriptor would otherwise reinstate the busy loop.
  • variants/linux/EventGPIOPin.{h,cpp}: an ardulinux GPIOPin that is also a LinuxEventSource, exposing a pollable rising-edge descriptor. Supports libgpiod v1 and v2 (selected by preprocessor check). setPinMode() re-applies edge detection, because RadioLib's pinMode(irq, INPUT) in SX126x::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 through EventGPIOPin; idleUntilEvent() and waitForRadioIrq(). Both re-read the IRQ level via digitalRead() before blocking — in ardulinux that refreshes the cached level gpioIdle() 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 and CLOCK_MONOTONIC, so it is native-testable) and cadTimeoutMillis() (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 on CAD_DONE being in the DIO1 routing mask alongside CAD_DETECTED, so a free channel arrives as an edge rather than a timeout (checked against the pinned RadioLib). symbolMicros() moves to RadioLibWrapper so the CAD deadline and calcMaxPacketMillis() share one formula.
  • Tests: test/test_linux_event_loop (anti-spin properties: stale-descriptor backoff, filtered POLLIN count, POLLHUP handling) and test/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/linux and the two sources.
  • README: ## Operation with "Idle CPU usage" (what the startup and degradation log lines mean) and "Channel Activity Detection" (how it composes with int.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 meshcored burned 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" answer isChannelActive() has to give (there is nothing to overlap with anyway — CAD puts the modem in standby).

How it was tested

  • Run on a Pi with the Waveshare SX1262 HAT (libgpiod 1.6.3, bookworm): idle well under 1% CPU with edge detection, roughly 1–3% in the polling fallback (the figures the README quotes). Expected loop-wakeup rate at the 50 ms ceiling is ~20 Hz, down from ~88 Hz with the previous 10 ms ceiling. The libgpiod v2 path (trixie) is compile-verified in the container only.
  • Native suite passes with the two new test groups (real signals are driven through the wait to cover EINTR).
  • Clean linux_repeater build for arm64 in the container.
  • The CAD path is covered by the native tests and a clean build; its on-air behaviour has not been measured against a second node.

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-gps adds lines next to this PR's platformio.ini native-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 of loop(); a no-op on every board that does not implement it)
  • src/helpers/radiolib/RadioLibWrappers.{h,cpp} (symbolMicros() extracted from calcMaxPacketMillis(); no behaviour change)
  • platformio.ini (native test env only)

…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
mmmorks force-pushed the pr/04-event-loop-cad branch from c22de21 to b3ea334 Compare September 8, 2026 04:11
@mmmorks
mmmorks force-pushed the pr/03-sx1262-parity branch from d1082d7 to 294f8c5 Compare September 8, 2026 04:11
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