Skip to content

Implement LinuxBoard::sleep() to stop the main loop busy-spinning - #24

Merged
l5yth merged 1 commit into
linuxfrom
l5y-linux-cpu-sleep
Aug 31, 2026
Merged

Implement LinuxBoard::sleep() to stop the main loop busy-spinning#24
l5yth merged 1 commit into
linuxfrom
l5y-linux-cpu-sleep

Conversation

@l5yth

@l5yth l5yth commented Aug 31, 2026

Copy link
Copy Markdown
Owner

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), closes #21

`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
l5yth merged commit f114d19 into linux Aug 31, 2026
1 check passed
@l5yth
l5yth deleted the l5y-linux-cpu-sleep branch August 31, 2026 16:57
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.
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.

2 participants