Implement LinuxBoard::sleep() to stop the main loop busy-spinning - #24
Merged
Conversation
`mesh::MainBoard::sleep()` is a no-op by default and `LinuxBoard` never overrode it, so `board.sleep(0)`/`sleep(30)` returned immediately and the repeater loop spun a core at 100%. Only `NRF52Board` and `ESP32Board` implement it. Implement it with `sleep()`/`usleep()`, and add a `delay(1)` in the main loop's non-powersaving branch so platforms without power management do not spin either. Reported and fixed by brianhealey, measured at 99% -> 0-9% CPU on a Pi Compute Module 5 with an SX1262. Cherry-picked from #21, which could not be merged. (cherry picked from commit 11d6ddf)
l5yth
added a commit
that referenced
this pull request
Sep 9, 2026
`mesh::MainBoard::sleep()` is a no-op by default and `LinuxBoard` never overrode it, so `board.sleep(0)`/`sleep(30)` returned immediately and the repeater loop spun a core at 100%. Only `NRF52Board` and `ESP32Board` implement it. Implement it with `sleep()`/`usleep()`, and add a `delay(1)` in the main loop's non-powersaving branch so platforms without power management do not spin either. Reported and fixed by brianhealey, measured at 99% -> 0-9% CPU on a Pi Compute Module 5 with an SX1262. Cherry-picked from #21, which could not be merged. (cherry picked from commit 11d6ddf) Co-authored-by: brianhealey <brian.healey@gmail.com>
mmmorks
added a commit
to mmmorks/meshcore-linux
that referenced
this pull request
Sep 12, 2026
…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. Every path that would return without waiting has to be closed, or the busy loop comes straight back. LinuxEventLoop backs off 1 ms and reports nothing readable on POLLNVAL, POLLHUP and non-EINTR poll() failure, since poll() returns a positive count for all three; and on a source that reports it could not drain, since a descriptor that failed to drain stays readable. LinuxEventSource::drainEvents() returns bool for that last case -- a read error on the event fd swallowed inside the drain would otherwise restore a silent 100%-core loop. 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 line that never reports costs latency and a log line, never a wrong answer. That holds only if the wait actually happens, so waitForRadioIrq() waits its deadline out even with no IRQ pin configured: returning early there would have the caller read a status register with neither CAD_DONE nor CAD_DETECTED latched, which RadioLib reports as RADIOLIB_ERR_UNKNOWN and isChannelActive() reads as busy -- on every channel, on every transmit. 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, an unfiltered poll() count, or a swallowed drain failure 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.
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.
mesh::MainBoard::sleep()is a no-op by default andLinuxBoardnever overrode it, soboard.sleep(0)/sleep(30)returned immediately and the repeater loop spun a core at 100%. OnlyNRF52BoardandESP32Boardimplement it.Implement it with
sleep()/usleep(), and add adelay(1)in the main loop's non-powersaving branch so platforms without power management do not spin either.Reported and fixed by brianhealey, measured at 99% -> 0-9% CPU on a Pi Compute Module 5 with an SX1262. Cherry-picked from #21, which could not be merged.
(cherry picked from commit 11d6ddf), closes #21