From 629557fcfe7be069202c7633b421f92d718449ff Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 02:22:26 -0700 Subject: [PATCH] Fix nRF52 idle sleep event and CryptoCell handling --- .github/workflows/run-unit-tests.yml | 2 + docs/nrf52_sleep_event_validation.md | 151 ++++++++ docs/power_saving_diagnostics.md | 4 + examples/companion_radio/main.cpp | 3 + examples/simple_repeater/main.cpp | 3 + examples/simple_room_server/main.cpp | 3 + examples/simple_sensor/main.cpp | 3 + src/helpers/NRF52Board.cpp | 100 ++++- src/helpers/NRF52Board.h | 3 + src/helpers/UsbLogging.cpp | 22 +- src/helpers/nrf52/SerialBLEInterface.cpp | 4 + src/helpers/nrf52/SleepCycleState.h | 85 +++++ test/fixtures/morse_main_loop.cpp | 20 +- test/fixtures/nrf52_sleep_cycle.cpp | 307 +++++++++++++++ test/fixtures/nrf52_sleep_cycle_other.cpp | 5 + test/fixtures/nrf52_sleep_events.cpp | 410 +++++++++++++++++++++ test/test_nrf52_sleep_cycle.py | 292 +++++++++++++++ test/test_nrf52_sleep_events.py | 198 ++++++++++ test/test_nrf52_usb_logging_contract.py | 140 ++++++- test/test_power_saving_role_diagnostics.py | 11 + test/test_usb_logging_watchdog_roles.py | 9 +- test/test_wireless_control.py | 12 +- 22 files changed, 1767 insertions(+), 20 deletions(-) create mode 100644 docs/nrf52_sleep_event_validation.md create mode 100644 src/helpers/nrf52/SleepCycleState.h create mode 100644 test/fixtures/nrf52_sleep_cycle.cpp create mode 100644 test/fixtures/nrf52_sleep_cycle_other.cpp create mode 100644 test/fixtures/nrf52_sleep_events.cpp create mode 100644 test/test_nrf52_sleep_cycle.py create mode 100644 test/test_nrf52_sleep_events.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e2a8b14a9..77f129f6f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -320,6 +320,8 @@ jobs: python3 -B test/test_power_saving_diagnostics.py -v python3 -B test/test_power_saving_role_diagnostics.py -v python3 -B test/test_companion_power_diagnostics.py -v + python3 -B test/test_nrf52_sleep_cycle.py -v + python3 -B test/test_nrf52_sleep_events.py -v python3 -B test/test_esp32_scanner_power_yield.py -v python3 -B test/test_esp32_gpio_hold.py -v diff --git a/docs/nrf52_sleep_event_validation.md b/docs/nrf52_sleep_event_validation.md new file mode 100644 index 000000000..e7e1e3bf5 --- /dev/null +++ b/docs/nrf52_sleep_event_validation.md @@ -0,0 +1,151 @@ +# nRF52 idle-sleep regression + +## Cause + +The MCU could repeatedly enter the board's sleep function without actually +sleeping. Three operations could leave the Cortex-M event register set: + +1. The pinned Adafruit core yields after every Arduino `loop()`. Its FreeRTOS + yield issues `SEV`. +2. The old board sleep function queried SoftDevice state immediately before + waiting. In the measured SoftDevice-off case this SVC recreated the event. + Moving only the scheduler-event clear was therefore insufficient. +3. Adafruit CDC `available()` yields when its receive FIFO is empty. A USB + client that never asserted DTR but established an RX session could therefore + recreate an event on every idle console poll. + +With SoftDevice enabled there is an additional, separate application-interrupt +history. The core's PendSV yield updates it. Clearing the ARM event register +does not acknowledge that history, so the next `sd_app_evt_wait()` still +returns immediately. This follows from the public SDK contract and the pinned +S140 6.1.1/7.3.0 interrupt-forwarder/wait code, not just the ARM event model. +The failed SoftDevice-on captures below had another blocker as well. + +There was also a disabled but pending CryptoCell IRQ42 after BLE startup. +SoftDevice's documented disabled-pending-IRQ rule prevents sleep in this +state, regardless of the event-register/history fixes. The pinned CC310 +library polls rather than enabling that interrupt; the pinned BSP already +clears its pending bit in its own idle wait. The application-owned wait had +missed that cleanup. + +This is distinct from the existing sleep-policy guards. A large eligible time +or a high number of wait calls does not establish CPU sleep residency. + +## Fix and ordering + +All four nRF52 role loops now begin with `prepareSleepCycle()`. It queries the +actual SDK SoftDevice state and USB power status. When SoftDevice is active, +preparation forces a real application PendSV with `taskYIELD()`, checks that it +was serviced in an unmasked application thread, and acknowledges its SDK +history with one early `sd_app_evt_wait()`. A bare `SEV` cannot guarantee that +this SDK call returns: the SDK can internally wait again without an application +interrupt. The real PendSV is required. This ordering depends on the pinned +core: the other SDK wait caller is the lower-priority idle task, which cannot +consume that acknowledgement while the sketch task is ready. +Preparation rejects exception context, masked interrupts, a suspended/stopped +scheduler, a still-pending PendSV, or an invalidated cycle instead of trying +that early SDK acknowledgement without proof. + +Preparation then clears the stale ARM event with `SEV; WFE`, before servicing +work or checking sleep predicates. A current +loop's USB-power snapshot also avoids another state-query SVC during UI work. + +On chips with CryptoCell, preparation also clears that specific pending IRQ +only when its interrupt is disabled and the SDK is not in a critical region. +It never sweeps other pending bits or clears an enabled CryptoCell owner's +interrupt. Clearing NVIC pending does not discard the polling crypto result. + +The snapshot is valid for one loop and at most one sleep attempt. Both +application-task `Bluefruit.begin()` call sites invalidate it before startup, +including partial or failed startup. Invalid, missing or failed preparation +declines sleep. The existing SDK/direct-register USB access policy is retained. +SoftDevice startup remains owned by the application task; the cache is not +permission to add an asynchronous SDK enable/disable path racing a wait. + +Primary USB RX polling now uses the native CDC0 FIFO query, with the existing +session checks both before and after that query. It does not change DTR policy, +CDC1 logging ownership, or the facade's blocking-read/write protections. + +The late wait remains a single `sd_app_evt_wait()` when SoftDevice is enabled, +or the existing `WFE; SEV; WFE` sequence when disabled. There is no late +`SEV; WFE; WFE` double-wait: that could discard the only radio IRQ arriving +after the caller checked for work. No blanket IRQ clearing or SEVONPEND change +was added. + +## Controlled hardware evidence + +The test device was a RAK3401/nRF52840 with an external RAK13302 SX1262 on +Mercerwood. Tests used 908.5 MHz, BW250, SF9, CR5, power saving on, manual +RXPS 30000/10000 us, AGC 0, TX power 2, no forwarding/adverts and USB watchdog +off. A single private benchmark binary allowed changes without reflashing +between the decisive controls. Benchmark controls are not production features. + +| Control | Capture | Time-weighted sleeping observations | +| --- | --- | --- | +| Original behavior | 30 s | 0% | +| Early clear only | 30 s | 0% | +| Non-yielding USB poll only | 30 s | 0% | +| Early clear plus USB poll, still with late state SVC | 30 s | 0% | +| No late SVC, non-yielding USB poll, without early clear | 30 s | 0% | +| Early clear, no late SVC, non-yielding USB poll | 90 s | 76.7% | +| Never-DTR client held open, old empty USB poll | 30 s | 0% | +| Same client held open, non-yielding USB poll | 30 s | 82.8% | + +The first production candidate restored sleeping observations with SoftDevice +off (6522/8331 samples, about 78% over 90 s), but not with SoftDevice on +(0/8344 samples with a never-DTR client over 90 s; 0/2776 with the client closed +over 30 s). This negative result exposed the separate SDK history and is why +the final preparation also acknowledges a real PendSV before servicing work. +The second candidate added that acknowledgement but still did not sleep with +SoftDevice on. A breakpoint then confirmed the SDK branch, the actual S140 +6.1.1 vector, and disabled IRQ42 pending (`ISER1=0x81`, `ISPR1=0x400`). Clearing +only that bit, with the same second binary and no configuration change, +restored 1911/2775 sleeping observations over 30 s. That control justified the +specific disabled-CryptoCell cleanup, rather than a speculative IRQ change. + +The final, normal Full repeater artifact was then tested without benchmark +controls or a DTR bypass. Its application readback matched the packaged image +byte-for-byte (SHA-256 +`8f6a1654f95d0179dad358d1fc68887eaacc41f4c60281a23099482e1f44af94`). + +| Final production test | Capture | Sleeping samples | Weighted estimate | +| --- | --- | --- | --- | +| SoftDevice off, USB terminal closed | 90 s | 6526/8310 | 78.3% | +| SoftDevice on after OTA start/stop, never-DTR client held open | 90 s | 6077/8341 | 73.0% | +| SoftDevice on, client closed | 30 s | 1943/2776 | 70.0% | + +The client received valid CLI replies both before and after the capture. +The final frozen SDK-on diagnostics had 281272 completed late waits, only +12 short returns, and zero vetoes/errors. Their accounting window was longer +than the capture; those counters are not its sleep-residency measurement. +All final samples had the same valid debugger/clock state described below. + +The never-DTR client successfully issued `get powersaving` and held the same +file descriptor open across both captures. Early clearing and removal of the +late state SVC were held constant in that pair, isolating the USB poll change. +A separate unsafe late-double-wait +control also slept, but was rejected because of the lost-work race above. + +Samples read DHCSR S_SLEEP at jittered 7-13 ms intervals. All causal captures +had C_DEBUGEN set, HFCLKSTAT 0x10001 and DCDC baseline 1, without sampled halt, +step, mask, lockup or reset flags. All timestamps were monotonic with no gap +over 20 ms. Percentages weight each sampled state until the next timestamp; +they are point-observation estimates, not exact sleep-residency accounting. +Zero sleeping observations does not exclude extremely short sleeps. Different +calibration phases can contribute to variation between otherwise fixed captures. + +## Limits + +There was no current meter. These results establish the CPU-sleep regression, +not a measured mA reduction or a complete explanation of the T096/Wio L1 +screenshots. SWD and USB alter the power/clock environment. Radio RXPS, FEM/LNA, +GPS, screen, regulator and USB clock loads require matched electrical tests. +In particular the T096 FEM/LNA default differs from EasySkyMesh; this RAK +cannot measure that board-specific load. No RF delivery or long battery-life +claim follows from these captures. + +See [power-saving diagnostics](power_saving_diagnostics.md) for the permanent +on-demand CLI counters. Counters remain off by default. Wait-call counters +cover the late eligible wait, not the guaranteed-nonblocking early SDK +acknowledgement. Preparation failures are reported through the board error or +veto path instead. diff --git a/docs/power_saving_diagnostics.md b/docs/power_saving_diagnostics.md index d74c319b3..a1596d86f 100644 --- a/docs/power_saving_diagnostics.md +++ b/docs/power_saving_diagnostics.md @@ -62,3 +62,7 @@ wrap (about 49 days). See the [CLI reference](cli_commands.md#diagnose-what-prevents-power-saving) for all page names and commands. + +The [nRF52 idle-sleep investigation](nrf52_sleep_event_validation.md) describes +an eligible-but-not-sleeping regression, its fix, controlled hardware evidence +and the limits of CPU-state sampling without a current meter. diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 7707262e4..549e95821 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -4086,6 +4086,9 @@ static bool usbLoggingRecoverySafe(void*) { } void loop() { +#if defined(NRF52_PLATFORM) + board.prepareSleepCycle(); +#endif #if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) if (pico_wifi_active && WiFi.status() != WL_CONNECTED && static_cast(millis() - last_wifi_reconnect_attempt) diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 697f228c1..be72c0ff3 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -579,6 +579,9 @@ static bool usbLoggingRecoverySafe(void*) { } void loop() { +#if defined(NRF52_PLATFORM) + board.prepareSleepCycle(); +#endif mesh::serviceUsbLoggingPort(); mesh::wireless::control().service(millis()); #if defined(NRF52_PLATFORM) diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index cf4d35af1..6f6498f87 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -298,6 +298,9 @@ static bool usbLoggingRecoverySafe(void*) { } void loop() { +#if defined(NRF52_PLATFORM) + board.prepareSleepCycle(); +#endif mesh::serviceUsbLoggingPort(); mesh::wireless::control().service(millis()); #if defined(NRF52_PLATFORM) diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index bd270d057..68cdd9967 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -275,6 +275,9 @@ static bool usbLoggingRecoverySafe(void*) { } void loop() { +#if defined(NRF52_PLATFORM) + board.prepareSleepCycle(); +#endif mesh::serviceUsbLoggingPort(); #if defined(NRF52_PLATFORM) board.feedWatchdog(the_mesh.getNodePrefs()->system_watchdog_enabled != 0); diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 44729c4ed..7c5c0d2d4 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -4,6 +4,7 @@ #include "NRF52VoltagePolicy.h" #include "Nrf52BootloaderVersion.h" #include "nrf52/SoftDeviceState.h" +#include "nrf52/SleepCycleState.h" #include "nrf52/ShutdownLeds.h" #include #include "UserGpioPinPolicy.h" @@ -13,6 +14,7 @@ #include "ble_gap.h" #include "ble_hci.h" #include +#include #include #ifdef USE_TINYUSB #include @@ -464,19 +466,88 @@ void NRF52BoardDCDC::begin() { } bool NRF52Board::isExternalPowered() { - // Check if SoftDevice is enabled before using its API + mesh_nrf52::SleepCycleSnapshot cycle; + if (mesh_nrf52::sleepCycleState().snapshot(cycle)) + return (cycle.usb_status & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0; + + // Setup and callers outside a prepared loop retain the SDK access policy. + // Never reuse a cached value after a SoftDevice transition invalidated it. uint8_t sd_enabled = 0; - mesh_nrf52::softdeviceIsEnabled(sd_enabled); + if (mesh_nrf52::softdeviceIsEnabled(sd_enabled) != NRF_SUCCESS) return false; if (sd_enabled) { - uint32_t usb_status; - sd_power_usbregstatus_get(&usb_status); + volatile uint32_t status = 0; + const uint32_t error = sd_power_usbregstatus_get((uint32_t*)&status); + __asm volatile ("" ::: "memory"); + if (error != NRF_SUCCESS) return false; + const uint32_t usb_status = status; return (usb_status & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0; } else { return (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0; } } +void NRF52Board::prepareSleepCycle() { + auto& state = mesh_nrf52::sleepCycleState(); + const uint32_t token = state.begin(); + const auto application_context = []() { + return __get_IPSR() == 0 && __get_PRIMASK() == 0 + && __get_BASEPRI() == 0 && __get_FAULTMASK() == 0 + // SDK critical regions can mask application IRQs in the NVIC even + // while the CPU mask registers are clear. This public field is + // volatile: read it afresh before queries and after the forced yield. + && nrf_nvic_state.__cr_flag == 0 + && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING; + }; + if (!application_context()) { + state.publish(token, false, 0, NRF_ERROR_INVALID_STATE); + return; + } +#ifdef NRF_CRYPTOCELL + // The pinned Adafruit CC310 library polls (no-interrupts archive), but can + // leave its disabled NVIC IRQ pending. The SDK treats that as application + // work forever. Match the core's waitForEvent cleanup only at loop entry; + // never clear an enabled IRQ belonging to an interrupt-driven owner. + if (NVIC_GetEnableIRQ(CRYPTOCELL_IRQn) == 0) + NVIC_ClearPendingIRQ(CRYPTOCELL_IRQn); +#endif + uint8_t sd_enabled = 0; + uint32_t error = mesh_nrf52::softdeviceIsEnabled(sd_enabled); + uint32_t usb_status = 0; + if (error == NRF_SUCCESS) { + if (sd_enabled) { + volatile uint32_t status = 0; + error = sd_power_usbregstatus_get((uint32_t*)&status); + __asm volatile ("" ::: "memory"); + usb_status = status; + } else { + usb_status = NRF_POWER->USBREGSTATUS; + } + } + if (error == NRF_SUCCESS && sd_enabled && state.current(token)) { + // Raw SEV/WFE does not acknowledge the SDK's separate application-IRQ + // bookkeeping. First force a real application PendSV while the loop task + // remains ready (so the RTOS idle task cannot consume that acknowledgement). + // The public SDK contract then makes this one early wait non-blocking. + // The pinned S140 6.1.1/7.3.0 forwarding/wait code confirms its private IRQ + // sequence cannot wrap back to the last acknowledged value. Do not access + // those SDK-private counters or rely on SEV alone to release an SDK wait. + taskYIELD(); + if (!application_context() || (SCB->ICSR & SCB_ICSR_PENDSVSET_Msk)) { + error = NRF_ERROR_INVALID_STATE; + } else if (state.current(token)) { + error = sd_app_evt_wait(); + } + } + state.publish(token, sd_enabled != 0, usb_status, error); + + // FreeRTOS yields and the state-query SVC can both leave a stale event. + // Clear it ONLY at loop entry, before servicing/checking application work. + // A radio interrupt after this point must remain visible to the late wait. + __SEV(); + __WFE(); +} + bool NRF52Board::isUsbDataConnected() { #if defined(USE_TINYUSB) #if defined(CFG_TUD_CDC) && CFG_TUD_CDC @@ -498,12 +569,25 @@ bool NRF52Board::isUsbHostConnected() { } void NRF52Board::sleep(uint32_t secs) { + (void)secs; const bool diagnostics = mesh::powerdiag::enabled(); + auto& state = mesh_nrf52::sleepCycleState(); + mesh_nrf52::SleepCycleSnapshot cycle; + const bool prepared = state.take(cycle); if (isRadioTestActive()) { if (diagnostics) mesh::powerdiag::boardVeto(mesh::powerdiag::Reason::Board, millis(), 2); delay(1); return; } + if (!prepared || !state.current(cycle.generation)) { + if (diagnostics) { + const uint32_t error = state.lastError(); + if (error != NRF_SUCCESS) mesh::powerdiag::boardError(static_cast(error), millis()); + else mesh::powerdiag::boardVeto(mesh::powerdiag::Reason::Board, millis(), 3); + } + delay(1); + return; + } // Clear FPU interrupt flags to avoid insomnia // see errata 87 for details https://docs.nordicsemi.com/bundle/errata_nRF52840_Rev3/page/ERR/nRF52840/Rev3/latest/anomaly_840_87.html #if (__FPU_USED == 1) @@ -514,10 +598,7 @@ void NRF52Board::sleep(uint32_t secs) { // On nRF52, we use event-driven sleep instead of timed sleep // The 'secs' parameter is ignored - we wake on any interrupt - uint8_t sd_enabled = 0; - mesh_nrf52::softdeviceIsEnabled(sd_enabled); - - if (sd_enabled) { + if (cycle.softdevice_enabled) { // A single call is required here. If an interrupt arrived since the last // wait, SoftDevice returns immediately so the main loop can service the // flag or BLE queue before sleeping again. @@ -708,6 +789,9 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[], bool force_ap) { Bluefruit.configPrphConn(BLE_GATT_ATT_MTU_DEFAULT, BLE_GAP_EVENT_LENGTH_MIN, 2, 1); + // Even a failed begin can leave SoftDevice enabled partway through SDK + // startup. Require a fresh authoritative query before the next sleep. + mesh_nrf52::sleepCycleState().invalidate(); if (!Bluefruit.begin(1, 0)) { return false; } diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 92cb3ae3e..06b6ec548 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -73,6 +73,9 @@ public: virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; virtual bool stopOTAUpdate(char reply[]) override; + // Must precede all work servicing/checks in each application loop. The + // authoritative SDK queries and stale-event clear are never done at sleep. + void prepareSleepCycle(); virtual void sleep(uint32_t secs) override; bool isUserGpioAvailable(uint8_t pin) const override; bool isExternalPowered() override; diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index a9ba825cb..3f09da4fe 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -736,7 +736,27 @@ static size_t writeTinyUsbCdcOnce(void* context, const uint8_t* data, return tud_cdc_n_write(instance, data, attempt); } -static SingleAttemptNonBlockingStream nonblocking_primary_usb_companion_port( +#if defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB) +// Adafruit's empty CDC available() calls yield(), which sets the Cortex-M +// event register through FreeRTOS. Polling an idle console must not recreate +// that scheduler event after the loop has prepared its sleep decision. +// Only CDC0 is the primary protocol/console; CDC1 keeps its logging owner. +class Nrf52PrimaryUsbStream : public SingleAttemptNonBlockingStream { + public: + using SingleAttemptNonBlockingStream::SingleAttemptNonBlockingStream; + + int available() override { + if (!canAccessPrimaryUsbSession(nullptr)) return 0; + const int count = static_cast(tud_cdc_n_available(0)); + // A USB owner callback may have closed the epoch during the FIFO query. + return canAccessPrimaryUsbSession(nullptr) ? count : 0; + } +}; +#else +using Nrf52PrimaryUsbStream = SingleAttemptNonBlockingStream; +#endif + +static Nrf52PrimaryUsbStream nonblocking_primary_usb_companion_port( Serial, writeTinyUsbCdcOnce, reinterpret_cast(uintptr_t{0}) #if defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB) , canAccessPrimaryUsbSession diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index d67ee38c3..b0a5a4599 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -2,6 +2,7 @@ #include "../BluetoothMac.h" #include "../CompanionFrameQueue.h" #include "SoftDeviceState.h" +#include "SleepCycleState.h" #include "BleDebugTrace.h" #include #include @@ -712,6 +713,9 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, // task observation incorrectly reports that the BLE/SOC workers never // started. Bluefruit.begin() is the reliable startup result available to // this application. + // A partial/failed begin must not leave the current loop's cached + // SoftDevice state eligible for bare-metal sleep. + mesh_nrf52::sleepCycleState().invalidate(); if (!Bluefruit.begin()) { uint8_t softdevice_enabled = 0; const uint32_t softdevice_status = diff --git a/src/helpers/nrf52/SleepCycleState.h b/src/helpers/nrf52/SleepCycleState.h new file mode 100644 index 000000000..414bd4736 --- /dev/null +++ b/src/helpers/nrf52/SleepCycleState.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include + +namespace mesh_nrf52 { + +struct SleepCycleSnapshot { + uint32_t generation = 0; + uint32_t usb_status = 0; + bool softdevice_enabled = false; +}; + +// The application task publishes once at loop entry, before checking work. +// A SoftDevice startup attempt can invalidate from another task without +// racing the publication. No SDK state is inferred from NVIC/vector state. +class SleepCycleState { + std::atomic generation_{0}; + std::atomic prepared_generation_{0}; + std::atomic usb_status_{0}; + std::atomic error_{0}; + std::atomic softdevice_enabled_{false}; + std::atomic prepared_{false}; + +public: + uint32_t begin() { + const uint32_t token = generation_.fetch_add(1, std::memory_order_acq_rel) + 1; + prepared_.store(false, std::memory_order_release); + return token; + } + + bool publish(uint32_t token, bool softdevice_enabled, uint32_t usb_status, + uint32_t error = 0) { + if (!current(token)) return false; + usb_status_.store(usb_status, std::memory_order_relaxed); + softdevice_enabled_.store(softdevice_enabled, std::memory_order_relaxed); + error_.store(error, std::memory_order_relaxed); + prepared_generation_.store(token, std::memory_order_relaxed); + prepared_.store(error == 0, std::memory_order_release); + return current(token); + } + + void invalidate() { + generation_.fetch_add(1, std::memory_order_acq_rel); + } + + bool current(uint32_t token) const { + return generation_.load(std::memory_order_acquire) == token; + } + + bool snapshot(SleepCycleSnapshot& out) const { + const uint32_t token = generation_.load(std::memory_order_acquire); + if (!prepared_.load(std::memory_order_acquire)) return false; + SleepCycleSnapshot candidate; + candidate.generation = prepared_generation_.load(std::memory_order_relaxed); + candidate.usb_status = usb_status_.load(std::memory_order_relaxed); + candidate.softdevice_enabled = softdevice_enabled_.load(std::memory_order_relaxed); + if (candidate.generation != token || !current(token) + || !prepared_.load(std::memory_order_acquire)) return false; + out = candidate; + return true; + } + + bool take(SleepCycleSnapshot& out) { + if (!snapshot(out)) return false; + prepared_.store(false, std::memory_order_release); + return current(out.generation); + } + + uint32_t lastError() const { + const uint32_t token = generation_.load(std::memory_order_acquire); + const uint32_t prepared_token = prepared_generation_.load(std::memory_order_relaxed); + const uint32_t error = error_.load(std::memory_order_relaxed); + return prepared_token == token && current(token) ? error : 0; + } +}; + +static_assert(sizeof(SleepCycleState) <= 24, "Sleep-cycle cache exceeded its RAM budget"); + +inline SleepCycleState& sleepCycleState() { + static SleepCycleState value; + return value; +} + +} // namespace mesh_nrf52 diff --git a/test/fixtures/morse_main_loop.cpp b/test/fixtures/morse_main_loop.cpp index b856ab242..b1b4ce451 100644 --- a/test/fixtures/morse_main_loop.cpp +++ b/test/fixtures/morse_main_loop.cpp @@ -2,6 +2,7 @@ #include #include static unsigned usb_services=0,command_services=0,transport_services=0,delays=0; +static bool usb_watchdog_armed=false,wireless_pending=false; struct Stream { int available() const { return 0; } char read() const { return 0; } @@ -18,17 +19,18 @@ namespace mesh { void serviceUsbLoggingPort() { ++usb_services; } void serviceUsbTerminalPort() { ++usb_services; } bool isUsbLoggingEnabled() { return false; } -bool isUsbLoggingWatchdogArmed() { return false; } +bool isUsbLoggingWatchdogArmed() { return usb_watchdog_armed; } bool serviceUsbLoggingWatchdog(bool(*)()) { ++usb_services; return false; } Stream& usbConsolePort() { return console; } void noteUsbLoggingStatsCommand(const char*) { ++usb_services; } namespace wireless { -struct Control { void service(uint32_t) { ++transport_services; } bool pending() { return false; } } controller; +struct Control { void service(uint32_t) { ++transport_services; } bool pending() { return wireless_pending; } } controller; Control& control() { return controller; } } } struct Board { - unsigned calls=0,watchdogs=0,sleeps=0,fans=0,reboots=0; + unsigned calls=0,watchdogs=0,sleeps=0,fans=0,reboots=0,preparations=0; + void prepareSleepCycle() { ++preparations; } void loop() { ++calls; } void feedWatchdog(bool=true) { ++watchdogs; } void sleep(uint32_t) { ++sleeps; } @@ -102,6 +104,7 @@ int main() { loop(); assert(sensors.calls==3 && ui_task.calls==6 && board.sleeps==3 && !delays); assert(the_mesh.calls==6 && rtc_clock.calls==6 && board.watchdogs==6); + assert(board.preparations==6); // Prepare even when Morse vetoes actual sleep. #if !ROLE_SENSOR assert(board.fans==6); #endif @@ -113,5 +116,16 @@ int main() { assert(transport_services==6 && board.calls==6); #else assert(board.calls==6); +#endif + const auto sleeps_before_hold=board.sleeps; + usb_watchdog_armed=true; + loop(); + assert(board.sleeps==sleeps_before_hold); + usb_watchdog_armed=false; +#if !ROLE_SENSOR + wireless_pending=true; + loop(); + assert(board.sleeps==sleeps_before_hold); + wireless_pending=false; #endif } diff --git a/test/fixtures/nrf52_sleep_cycle.cpp b/test/fixtures/nrf52_sleep_cycle.cpp new file mode 100644 index 000000000..f747def48 --- /dev/null +++ b/test/fixtures/nrf52_sleep_cycle.cpp @@ -0,0 +1,307 @@ +// Exercise the real portable cache and extracted board methods. Hardware +// primitives implement the documented event contract, not Nordic SDK internals. +#include +#include +#include +#include +#include + +void invalidateSleepCycleFromOtherTranslationUnit(); + +static bool event_latched = true, application_work = false; +static bool softdevice_enabled = false, interrupt_during_query = false; +static bool invalidate_during_query = false; +static uint32_t state_error = 0, usb_error = 0, wait_error = 0, clock_us = 10000; +static unsigned state_queries = 0, usb_queries = 0, sdk_waits = 0; +static unsigned raw_wfes = 0, real_sleeps = 0, short_returns = 0; +static uint8_t app_irq_current = 1, app_irq_acknowledged = 0; +static bool cc_enabled = false, cc_pending = false, other_irq_pending = false; +static bool sdk_critical_after_yield = false; +static unsigned cc_clears = 0; +constexpr uint32_t NRF_SUCCESS = 0; +constexpr uint32_t NRF_ERROR_INVALID_STATE = 8; +constexpr uint32_t POWER_USBREGSTATUS_VBUSDETECT_Msk = 1; +constexpr uint32_t SCB_ICSR_PENDSVSET_Msk = 1u << 28; +constexpr int taskSCHEDULER_RUNNING = 1; +#define NRF_CRYPTOCELL 1 +constexpr int CRYPTOCELL_IRQn = 42; +struct SdkNvicState { volatile uint32_t __cr_flag = 0; } nrf_nvic_state; +static uint32_t NVIC_GetEnableIRQ(int irq) { + assert(irq == CRYPTOCELL_IRQn); + return cc_enabled; +} +static void NVIC_ClearPendingIRQ(int irq) { + assert(irq == CRYPTOCELL_IRQn); + ++cc_clears; + cc_pending = false; +} +struct SystemRegisters { uint32_t ICSR = 0; } system_registers; +static SystemRegisters* const SCB = &system_registers; +static uint32_t __get_IPSR() { return 0; } +static uint32_t __get_PRIMASK() { return 0; } +static uint32_t __get_BASEPRI() { return 0; } +static uint32_t __get_FAULTMASK() { return 0; } +static int xTaskGetSchedulerState() { return taskSCHEDULER_RUNNING; } +struct PowerRegisters { uint32_t USBREGSTATUS = 0; } power_registers; +static PowerRegisters* const NRF_POWER = &power_registers; + +static uint32_t micros() { return clock_us; } +static uint32_t millis() { return clock_us / 1000; } +static void delay(unsigned amount) { clock_us += amount * 1000; } +static void recordApplicationInterrupt() { + const uint8_t next = static_cast(app_irq_current + 1); + if (next != app_irq_acknowledged) app_irq_current = next; + event_latched = true; +} +void taskYIELD() { + recordApplicationInterrupt(); + if (sdk_critical_after_yield) nrf_nvic_state.__cr_flag = 1; +} +static void applicationIrq() { application_work = true; recordApplicationInterrupt(); } +namespace mesh_nrf52 { +static uint32_t softdeviceIsEnabled(uint8_t& enabled) { + ++state_queries; + event_latched = true; // The state-query SVC is itself an event source. + if (interrupt_during_query) applicationIrq(); + if (invalidate_during_query) invalidateSleepCycleFromOtherTranslationUnit(); + enabled = softdevice_enabled; + return state_error; +} +} +static uint32_t sd_power_usbregstatus_get(uint32_t* status) { + ++usb_queries; + event_latched = true; + *status = power_registers.USBREGSTATUS; + return usb_error; +} +static void waitOnce() { + if (event_latched) { + ++short_returns; + event_latched = false; + } else { + assert(!application_work); // A second late wait would lose this work. + ++real_sleeps; + clock_us += 547; + recordApplicationInterrupt(); // The next periodic IRQ wakes the CPU. + } +} +static void __SEV() { event_latched = true; } +static void __WFE() { ++raw_wfes; waitOnce(); } +static uint32_t sd_app_evt_wait() { + assert(softdevice_enabled); + ++sdk_waits; + const bool app_irq_pending = app_irq_current != app_irq_acknowledged; + // Conservative API abstraction, not the exact ROM counter-copy timing: + // retaining a waking IRQ in private history avoids hiding application work. + // The ROM copies after its first WFE triplet/comparison, not at this point. + app_irq_acknowledged = app_irq_current; + // Public SVC entry consumes its own ARM event, then the SDK's separate + // application-IRQ latch decides immediate return versus a real sleep. + event_latched = false; + if (!app_irq_pending && !cc_pending && !other_irq_pending) { + assert(!application_work); + ++real_sleeps; + clock_us += 547; + recordApplicationInterrupt(); + } else { + ++short_returns; + } + __SEV(); + event_latched = false; + return wait_error; +} + +struct NRF52Board { + bool radio_test = false; + bool isRadioTestActive() const { return radio_test; } + void prepareSleepCycle(); + bool isExternalPowered(); + void sleep(uint32_t secs); +}; + +@BOARD_METHODS@ + +static void cacheContract() { + using mesh_nrf52::SleepCycleSnapshot; + mesh_nrf52::SleepCycleState state; + SleepCycleSnapshot value = {}; + assert(!state.snapshot(value) && !state.take(value)); + const uint32_t first = state.begin(); + assert(state.publish(first, false, 1)); + assert(state.current(first) && state.snapshot(value)); + assert(value.generation == first && !value.softdevice_enabled && value.usb_status == 1); + assert(state.take(value) && !state.take(value)); + + const uint32_t stale = state.begin(); + state.invalidate(); + assert(!state.current(stale) && !state.publish(stale, true, 2)); + assert(!state.snapshot(value) && !state.take(value)); + const uint32_t failed = state.begin(); + assert(state.publish(failed, false, 0, 23)); + assert(state.lastError() == 23 && !state.take(value)); + const uint32_t recovery = state.begin(); + assert(state.publish(recovery, true, 2)); + assert(state.lastError() == 0 && state.snapshot(value)); + // A late completion from an obsolete cycle cannot overwrite new state. + assert(!state.publish(stale, false, 99, 44)); + assert(state.snapshot(value) && value.generation == recovery); + assert(value.softdevice_enabled && value.usb_status == 2); +} + +static void boardFlow(bool enabled) { + NRF52Board board; + softdevice_enabled = enabled; + // No historic app IRQ may be assumed when preparing the first SDK cycle. + // A real forced PendSV, not SEV alone, must guarantee early SDK return. + app_irq_acknowledged = app_irq_current; + power_registers.USBREGSTATUS = 1; + board.prepareSleepCycle(); + assert(state_queries == 1 && !event_latched && real_sleeps == 0); + assert(board.isExternalPowered()); + const unsigned queries_before_sleep = state_queries + usb_queries; + board.sleep(0); + assert(state_queries + usb_queries == queries_before_sleep); + assert(real_sleeps == 1 && sdk_waits == (enabled ? 2u : 0u)); + const unsigned waits = raw_wfes + sdk_waits; + board.sleep(0); + assert(raw_wfes + sdk_waits == waits); // Prepared attempt is single-use. +} + +static void lateIrq(bool enabled) { + NRF52Board board; + softdevice_enabled = enabled; + board.prepareSleepCycle(); + assert(!event_latched); + // The main loop has already serviced inputs and accepted its idle predicate. + applicationIrq(); + board.sleep(0); + assert(application_work && real_sleeps == 0); + board.prepareSleepCycle(); + assert(application_work); // Preparation consumes only the event, not work. + application_work = false; // The next actual service pass processes it. + board.sleep(0); + assert(real_sleeps == 1); +} + +static void transition() { + NRF52Board board; + board.prepareSleepCycle(); + invalidateSleepCycleFromOtherTranslationUnit(); + softdevice_enabled = true; // Also models a partial Bluefruit.begin failure. + const unsigned waits = raw_wfes + sdk_waits; + board.sleep(0); + assert(raw_wfes + sdk_waits == waits && real_sleeps == 0); + board.prepareSleepCycle(); + board.sleep(0); + assert(sdk_waits == 2 && real_sleeps == 1); +} + +static void failedQuery() { + NRF52Board board; + state_error = 17; + board.prepareSleepCycle(); + const unsigned waits = raw_wfes + sdk_waits; + board.sleep(0); + assert(raw_wfes + sdk_waits == waits && real_sleeps == 0); + state_error = 0; + board.prepareSleepCycle(); + board.sleep(0); + assert(real_sleeps == 1); +} + +static void invalidationDuringQuery() { + NRF52Board board; + invalidate_during_query = true; + board.prepareSleepCycle(); + const unsigned waits = raw_wfes + sdk_waits; + board.sleep(0); + assert(raw_wfes + sdk_waits == waits && real_sleeps == 0); +} + +static void irqDuringPreparation() { + NRF52Board board; + interrupt_during_query = true; + board.prepareSleepCycle(); + assert(application_work && real_sleeps == 0); + application_work = false; // Work is serviced after preparation, never before. + board.sleep(0); + assert(real_sleeps == 1); +} + +static void usbSnapshot() { + NRF52Board board; + power_registers.USBREGSTATUS = 1; + board.prepareSleepCycle(); + assert(board.isExternalPowered()); + power_registers.USBREGSTATUS = 0; + const unsigned queries = state_queries + usb_queries; + assert(board.isExternalPowered()); // Per-cycle value, not another late SVC. + assert(state_queries + usb_queries == queries); + board.prepareSleepCycle(); + assert(!board.isExternalPowered()); +} + +static void ccRecovery(bool late) { + NRF52Board board; + softdevice_enabled = true; + cc_pending = !late; + board.prepareSleepCycle(); + assert(!cc_pending && cc_clears == 1); + if (late) { + cc_pending = true; + event_latched = true; + board.sleep(0); + assert(cc_pending && real_sleeps == 0); // No unsafe late pending clear. + board.prepareSleepCycle(); + assert(!cc_pending && cc_clears == 2); + } + board.sleep(0); + assert(real_sleeps == 1); +} + +static void preserveOwner(bool other_irq) { + NRF52Board board; + softdevice_enabled = true; + cc_enabled = !other_irq; + cc_pending = !other_irq; + other_irq_pending = other_irq; + board.prepareSleepCycle(); + assert(other_irq ? other_irq_pending : cc_pending); + if (!other_irq) assert(cc_clears == 0); + board.sleep(0); + assert(real_sleeps == 0); + assert(other_irq ? other_irq_pending : cc_pending); +} + +static void sdkCriticalRegion(bool after_yield) { + NRF52Board board; + softdevice_enabled = true; + cc_pending = true; + sdk_critical_after_yield = after_yield; + nrf_nvic_state.__cr_flag = after_yield ? 0 : 1; + board.prepareSleepCycle(); + assert(sdk_waits == 0 && real_sleeps == 0); + assert(mesh_nrf52::sleepCycleState().lastError() == NRF_ERROR_INVALID_STATE); + if (!after_yield) assert(state_queries == 0 && cc_clears == 0 && cc_pending); +} + +int main(int argc, char** argv) { + assert(argc == 2); + if (!std::strcmp(argv[1], "cache")) cacheContract(); + else if (!std::strcmp(argv[1], "raw")) boardFlow(false); + else if (!std::strcmp(argv[1], "sd")) boardFlow(true); + else if (!std::strcmp(argv[1], "raw_irq")) lateIrq(false); + else if (!std::strcmp(argv[1], "sd_irq")) lateIrq(true); + else if (!std::strcmp(argv[1], "transition")) transition(); + else if (!std::strcmp(argv[1], "query_error")) failedQuery(); + else if (!std::strcmp(argv[1], "during_query")) invalidationDuringQuery(); + else if (!std::strcmp(argv[1], "prepare_irq")) irqDuringPreparation(); + else if (!std::strcmp(argv[1], "usb")) usbSnapshot(); + else if (!std::strcmp(argv[1], "cc_recover")) ccRecovery(false); + else if (!std::strcmp(argv[1], "cc_late")) ccRecovery(true); + else if (!std::strcmp(argv[1], "cc_owner")) preserveOwner(false); + else if (!std::strcmp(argv[1], "other_owner")) preserveOwner(true); + else if (!std::strcmp(argv[1], "sdk_critical")) sdkCriticalRegion(false); + else if (!std::strcmp(argv[1], "sdk_critical_after_yield")) sdkCriticalRegion(true); + else assert(false); +} diff --git a/test/fixtures/nrf52_sleep_cycle_other.cpp b/test/fixtures/nrf52_sleep_cycle_other.cpp new file mode 100644 index 000000000..c8779243d --- /dev/null +++ b/test/fixtures/nrf52_sleep_cycle_other.cpp @@ -0,0 +1,5 @@ +#include + +void invalidateSleepCycleFromOtherTranslationUnit() { + mesh_nrf52::sleepCycleState().invalidate(); +} diff --git a/test/fixtures/nrf52_sleep_events.cpp b/test/fixtures/nrf52_sleep_events.cpp new file mode 100644 index 000000000..93d2a4b80 --- /dev/null +++ b/test/fixtures/nrf52_sleep_events.cpp @@ -0,0 +1,410 @@ +// Host-only causal model, not an emulator of the opaque Nordic SoftDevice. +// +// ARM Cortex-M4: SEV sets the event register; WFE consumes an already-set +// event without sleeping. With SEVONPEND an IRQ can leave the event latched +// after its handler has run. The installed FreeRTOS portYIELD also sends SEV. +// Nordic sd_app_evt_wait's documented contract preserves an application IRQ +// that happened before its single wait so the main loop can service it. +// +// This model proves ordering properties and known counterexamples only. +// It cannot prove actual firmware call placement, SDK filtering, ISR details, +// elapsed sleep time, or current consumption; those need integration/hardware. + +#include +#include +#include +#include + +enum class Backend { Raw, SoftDevice }; +enum class Point { + None, + BeforePrepare, + BeforeForcedYield, + AfterForcedYield, + AfterEntrySdkWait, + BetweenEarlySevAndWfe, + AfterPrepare, + BeforeService, + AfterService, + BeforePredicate, + AfterPredicate, + BeforeWait, + DuringWait, + AfterFirstWait, + BetweenCleanupSevAndWfe +}; + +struct Cpu { + bool event = true; // Prior loop-task yield already sent SEV. + bool work_pending = false; + bool slept_with_work = false; + unsigned services = 0; + unsigned sleeps = 0; + unsigned immediate_returns = 0; + unsigned waits = 0; + unsigned sdk_calls = 0; + unsigned yields = 0; + unsigned polls = 0; + unsigned predicate_vetoes = 0; + Point injection = Point::None; + bool injected = false; + uint8_t app_irq_current = 1, app_irq_acknowledged = 0; + bool cc_pending = false, cc_enabled = false, other_irq_pending = false; + unsigned cc_clears = 0; + + void sev() { event = true; } + + void recordApplicationInterrupt() { + const uint8_t next = static_cast(app_irq_current + 1); + // Model the ROM's wrap-safe private latch: never let unacknowledged + // application interrupts wrap back to the previous wait's count. + if (next != app_irq_acknowledged) app_irq_current = next; + event = true; + } + + void schedulerYield() { + ++yields; + recordApplicationInterrupt(); // Real application PendSV, not just SEV. + } + + void pendCryptoCell() { + if (!cc_pending) event = true; // New pending transition with SEVONPEND. + cc_pending = true; // A disabled IRQ does not run an ISR/update SDK counter. + } + + void applicationIrq() { + work_pending = true; // ISR-published work survives event consumption. + recordApplicationInterrupt(); + } + + void inject(Point point) { + if (!injected && injection == point) { + applicationIrq(); + injected = true; + } + } + + // Exactly one WFE decision. A simulated next periodic IRQ ends real sleep, + // without generating application work unless DuringWait was requested. + void waitOnce() { + ++waits; + if (event) { + event = false; + ++immediate_returns; + return; + } + ++sleeps; + if (work_pending) slept_with_work = true; + inject(Point::DuringWait); + // WFE has now been woken by an IRQ. Cleanup below consumes its event. + recordApplicationInterrupt(); + } + + // A guaranteed nonblocking consume, performed only before all work polls. + void prepare(Backend backend, bool acknowledge_sd = true, bool clean_cc = true) { + inject(Point::BeforePrepare); + if (clean_cc && !cc_enabled) { + cc_pending = false; + ++cc_clears; + } + if (backend == Backend::SoftDevice && acknowledge_sd) { + inject(Point::BeforeForcedYield); + schedulerYield(); + inject(Point::AfterForcedYield); + const unsigned before = sleeps; + singleWait(backend, true); + assert(sleeps == before); // The real PendSV guarantees a pending app IRQ. + inject(Point::AfterEntrySdkWait); + } + sev(); + inject(Point::BetweenEarlySevAndWfe); + assert(event); + event = false; // Early WFE; deliberately no second WFE here. + inject(Point::AfterPrepare); + } + + void service() { + inject(Point::BeforeService); + if (work_pending) { + ++services; + work_pending = false; + } + inject(Point::AfterService); + } + + void pollRx(bool yielding_empty_poll) { + ++polls; + if (yielding_empty_poll) { + schedulerYield(); // Adafruit_USBD_CDC::available -> yield -> portYIELD. + } + } + + void singleWait(Backend backend, bool entry_ack = false) { + if (backend == Backend::SoftDevice) { + ++sdk_calls; + ++waits; + const bool app_irq_pending = app_irq_current != app_irq_acknowledged; + // Conservative public-contract abstraction, not exact ROM timing: ack + // the pre-call history before simulating the wait. This intentionally + // retains a new IRQ from during the wait for the next call. The ROM + // copies its counter after an initial WFE triplet/comparison instead. + app_irq_acknowledged = app_irq_current; + // Public SDK entry itself uses SVC and consumes that ARM event first. + // Its private application-IRQ counter then decides whether to return + // or loop into a real wait. Raw ER consumption does not acknowledge it. + sev(); + event = false; + if (!app_irq_pending && !cc_pending && !other_irq_pending) { + ++sleeps; + if (work_pending) slept_with_work = true; + inject(Point::DuringWait); + recordApplicationInterrupt(); // Periodic application IRQ wakes SDK. + } else { + ++immediate_returns; + } + } else { + waitOnce(); + } + if (!entry_ack) inject(Point::AfterFirstWait); + sev(); + if (!entry_ack) inject(Point::BetweenCleanupSevAndWfe); + assert(event); + event = false; // Cleanup WFE cannot sleep because its own SEV is set. + } + + void iteration(Backend backend, bool entry_clear, bool yielding_empty_poll, + bool predicate_sees_work = true, bool unsafe_late_clear = false, + bool acknowledge_sd = true, bool clean_cc = true) { + if (entry_clear) prepare(backend, acknowledge_sd, clean_cc); + service(); + pollRx(yielding_empty_poll); + inject(Point::BeforePredicate); + const bool eligible = !predicate_sees_work || !work_pending; + inject(Point::AfterPredicate); + if (eligible) { + inject(Point::BeforeWait); + if (unsafe_late_clear) { + // Raw SEV/WFE/WFE or two consecutive SD waits: the first consumes + // the late application's only event, the second can then sleep. + if (backend == Backend::SoftDevice) { + singleWait(backend); + } else { + sev(); + event = false; + } + } + singleWait(backend); + } else { + ++predicate_vetoes; + } + // Actual pinned Arduino main.cpp yields after every sketch loop(). + schedulerYield(); + } +}; + +static void scheduler() { + for (Backend backend : {Backend::Raw, Backend::SoftDevice}) { + Cpu cpu; + for (unsigned n = 0; n < 100; ++n) cpu.iteration(backend, false, false); + assert(cpu.sleeps == 0 && cpu.immediate_returns == 100); + assert(cpu.waits == 100 && cpu.yields == 100); + assert(!cpu.slept_with_work); + assert(cpu.sdk_calls == (backend == Backend::SoftDevice ? 100u : 0u)); + } +} + +static void entryIdle() { + for (Backend backend : {Backend::Raw, Backend::SoftDevice}) { + Cpu cpu; + for (unsigned n = 0; n < 100; ++n) cpu.iteration(backend, true, false); + assert(cpu.sleeps == 100); + assert(cpu.immediate_returns == (backend == Backend::SoftDevice ? 100u : 0u)); + assert(cpu.polls == 100 && cpu.yields == (backend == Backend::SoftDevice ? 200u : 100u)); + assert(!cpu.slept_with_work && cpu.services == 0); + } +} + +static void pollYield() { + for (Backend backend : {Backend::Raw, Backend::SoftDevice}) { + Cpu cpu; + for (unsigned n = 0; n < 100; ++n) cpu.iteration(backend, true, true); + assert(cpu.sleeps == 0); + assert(cpu.immediate_returns == (backend == Backend::SoftDevice ? 200u : 100u)); + assert(cpu.polls == 100 && cpu.yields == (backend == Backend::SoftDevice ? 300u : 200u)); + assert(!cpu.slept_with_work); + } +} + +static const Point irq_points[] = { + Point::BeforePrepare, Point::BeforeForcedYield, Point::AfterForcedYield, + Point::AfterEntrySdkWait, Point::BetweenEarlySevAndWfe, Point::AfterPrepare, + Point::BeforeService, Point::AfterService, Point::BeforePredicate, + Point::AfterPredicate, Point::BeforeWait, Point::DuringWait, + Point::AfterFirstWait, Point::BetweenCleanupSevAndWfe +}; + +static void injections(bool predicate_sees_work) { + for (Backend backend : {Backend::Raw, Backend::SoftDevice}) { + for (Point point : irq_points) { + if (backend == Backend::Raw && (point == Point::BeforeForcedYield + || point == Point::AfterForcedYield || point == Point::AfterEntrySdkWait)) continue; + Cpu cpu; + cpu.injection = point; + cpu.iteration(backend, true, false, predicate_sees_work); + assert(cpu.injected); + assert(!cpu.slept_with_work); + if (point == Point::AfterPredicate || point == Point::BeforeWait) { + assert(cpu.sleeps == 0); + assert(cpu.immediate_returns == (backend == Backend::SoftDevice ? 2u : 1u)); + assert(cpu.work_pending && cpu.services == 0); + } + if (!predicate_sees_work && + (point == Point::AfterService || point == Point::BeforePredicate)) { + assert(cpu.sleeps == 0); + assert(cpu.immediate_returns == (backend == Backend::SoftDevice ? 2u : 1u)); + } + // Work arriving during cleanup must not be followed by another wait. + // Entry clear next time is safe because service follows it. + cpu.iteration(backend, true, false, predicate_sees_work); + assert(cpu.services == 1 && !cpu.work_pending); + assert(!cpu.slept_with_work); + assert(cpu.sdk_calls <= 4); + } + } +} + +static void lateDouble() { + for (Backend backend : {Backend::Raw, Backend::SoftDevice}) { + for (Point point : {Point::AfterPredicate, Point::BeforeWait}) { + Cpu cpu; + cpu.injection = point; + cpu.iteration(backend, true, false, true, true); + assert(cpu.injected && cpu.services == 0 && cpu.work_pending); + assert(cpu.sleeps == 1 && cpu.slept_with_work); + if (backend == Backend::SoftDevice) { + assert(cpu.sdk_calls == 3 && cpu.waits == 3); + assert(cpu.immediate_returns == 2); + } + } + } +} + +static void cleanup() { + for (Point point : {Point::AfterFirstWait, Point::BetweenCleanupSevAndWfe}) { + Cpu cpu; + cpu.injection = point; + cpu.iteration(Backend::Raw, true, false); + assert(cpu.waits == 1 && cpu.sleeps == 1); + assert(cpu.injected && cpu.work_pending && !cpu.slept_with_work); + cpu.iteration(Backend::Raw, true, false); + assert(cpu.services == 1 && !cpu.work_pending && !cpu.slept_with_work); + } +} + +static void privateCounter() { + Cpu cpu; + for (unsigned n = 0; n < 100; ++n) + cpu.iteration(Backend::SoftDevice, true, false, true, false, false); + assert(cpu.sleeps == 0 && cpu.sdk_calls == 100); + assert(cpu.immediate_returns == 100); // Raw early clear cannot ack SDK state. +} + +static void sevOnlyEntry() { + Cpu cpu; + cpu.app_irq_acknowledged = cpu.app_irq_current; + cpu.work_pending = true; // Preexisting service work, not a new IRQ. + cpu.sev(); + cpu.singleWait(Backend::SoftDevice); + assert(cpu.sleeps == 1 && cpu.slept_with_work); +} + +static void privateCounterWrap() { + Cpu cpu; + cpu.app_irq_current = cpu.app_irq_acknowledged = 19; + for (unsigned n = 0; n < 2048; ++n) { + cpu.recordApplicationInterrupt(); + assert(cpu.app_irq_current != cpu.app_irq_acknowledged); + } + const unsigned before = cpu.sleeps; + cpu.singleWait(Backend::SoftDevice); + assert(cpu.sleeps == before && cpu.app_irq_current == cpu.app_irq_acknowledged); +} + +static void waitIrqHistory() { + Cpu cpu; + cpu.app_irq_acknowledged = cpu.app_irq_current; + cpu.injection = Point::DuringWait; + cpu.singleWait(Backend::SoftDevice); + assert(cpu.sleeps == 1 && cpu.work_pending && !cpu.slept_with_work); + assert(cpu.app_irq_current != cpu.app_irq_acknowledged); + const unsigned sleeps = cpu.sleeps; + // The next acknowledgement cannot sleep or erase the ISR-published work. + cpu.singleWait(Backend::SoftDevice); + assert(cpu.sleeps == sleeps && cpu.work_pending); + assert(cpu.app_irq_current == cpu.app_irq_acknowledged); + cpu.service(); + assert(!cpu.work_pending && cpu.services == 1); +} + +static void persistentDisabledIrq() { + Cpu cpu; + cpu.pendCryptoCell(); + for (unsigned n = 0; n < 100; ++n) + cpu.iteration(Backend::SoftDevice, true, false, true, false, true, false); + assert(cpu.cc_pending && cpu.sleeps == 0); + // ER consumption and private SDK acknowledgements did not clear NVIC state. + cpu.iteration(Backend::SoftDevice, true, false); + assert(!cpu.cc_pending && cpu.sleeps == 1 && cpu.cc_clears == 1); +} + +static void lateDisabledIrq() { + Cpu cpu; + cpu.prepare(Backend::SoftDevice); + cpu.service(); + cpu.pendCryptoCell(); // New pending IRQ after all service/predicate checks. + cpu.singleWait(Backend::SoftDevice); + assert(cpu.cc_pending && cpu.sleeps == 0); + cpu.iteration(Backend::SoftDevice, true, false); + assert(!cpu.cc_pending && cpu.sleeps == 1); +} + +static void preserveIrqOwner() { + Cpu cpu; + cpu.cc_enabled = true; + cpu.pendCryptoCell(); + cpu.prepare(Backend::SoftDevice); + assert(cpu.cc_pending && cpu.cc_clears == 0); + cpu.singleWait(Backend::SoftDevice); + assert(cpu.sleeps == 0 && cpu.cc_pending); + // Only the enabled owner's ISR consumes its pending bit and publishes work. + cpu.cc_pending = false; + cpu.applicationIrq(); + cpu.iteration(Backend::SoftDevice, true, false); + assert(cpu.services == 1 && !cpu.work_pending && cpu.sleeps == 1); + assert(cpu.cc_clears == 0); + + Cpu other; + other.other_irq_pending = true; + for (unsigned n = 0; n < 3; ++n) + other.iteration(Backend::SoftDevice, true, false); + assert(other.other_irq_pending && other.sleeps == 0); +} + +int main(int argc, char** argv) { + assert(argc == 2); + if (!std::strcmp(argv[1], "scheduler")) scheduler(); + else if (!std::strcmp(argv[1], "entry_idle")) entryIdle(); + else if (!std::strcmp(argv[1], "poll_yield")) pollYield(); + else if (!std::strcmp(argv[1], "injections")) injections(true); + else if (!std::strcmp(argv[1], "unobserved")) injections(false); + else if (!std::strcmp(argv[1], "late_double")) lateDouble(); + else if (!std::strcmp(argv[1], "cleanup")) cleanup(); + else if (!std::strcmp(argv[1], "private_counter")) privateCounter(); + else if (!std::strcmp(argv[1], "sev_only")) sevOnlyEntry(); + else if (!std::strcmp(argv[1], "counter_wrap")) privateCounterWrap(); + else if (!std::strcmp(argv[1], "wait_irq_history")) waitIrqHistory(); + else if (!std::strcmp(argv[1], "disabled_irq")) persistentDisabledIrq(); + else if (!std::strcmp(argv[1], "late_disabled_irq")) lateDisabledIrq(); + else if (!std::strcmp(argv[1], "irq_owner")) preserveIrqOwner(); + else assert(false); +} diff --git a/test/test_nrf52_sleep_cycle.py b/test/test_nrf52_sleep_cycle.py new file mode 100644 index 000000000..9d138cc7b --- /dev/null +++ b/test/test_nrf52_sleep_cycle.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Execute the one-cycle SDK snapshot and its actual nRF52 board consumers.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +class Nrf52SleepCycleTests(unittest.TestCase): + def execute(self, code): + with tempfile.TemporaryDirectory(prefix="meshcore-sleep-cycle-") as directory: + source = Path(directory) / "test.cpp" + binary = Path(directory) / "test" + source.write_text(code, encoding="ascii") + result = subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", + "-pthread", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", "-I", str(ROOT / "src"), + str(source), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_cache_one_shot_error_invalidation_and_concurrent_publication(self): + self.execute(r''' +#include +#include +#include +int main() { + using namespace mesh_nrf52; + SleepCycleState state; + SleepCycleSnapshot out; + assert(!state.snapshot(out) && !state.take(out) && state.lastError() == 0); + auto token = state.begin(); + assert(!state.snapshot(out)); + assert(state.publish(token, false, 3)); + assert(state.snapshot(out) && !out.softdevice_enabled && out.usb_status == 3); + assert(state.take(out) && !state.take(out) && !state.snapshot(out)); + token = state.begin(); + state.invalidate(); // Startup began during an authoritative SDK query. + assert(!state.publish(token, false, 1) && !state.snapshot(out)); + token = state.begin(); + assert(state.publish(token, true, 1, 17)); + assert(!state.take(out) && state.lastError() == 17); + state.invalidate(); + assert(state.lastError() == 0); // Do not attribute the old error to a new transition. + token = state.begin(); + assert(state.publish(token, true, 0) && state.snapshot(out)); + const auto previous = out; + token = state.begin(); + assert(!state.current(previous.generation) && !state.snapshot(out)); + assert(state.publish(token, false, 1) && state.snapshot(out)); + assert(!out.softdevice_enabled && out.usb_status == 1); + + std::atomic done{false}; + std::thread writer([&]() { + for (unsigned i = 0; i < 100000; ++i) { + const auto next = state.begin(); + state.publish(next, (next & 1) != 0, next ^ 0x75201346U); + } + done.store(true, std::memory_order_release); + }); + // Seed a consistent record before accepting the publication stress cases. + state.invalidate(); + while (!done.load(std::memory_order_acquire)) { + if (state.snapshot(out)) { + assert(out.usb_status == (out.generation ^ 0x75201346U)); + assert(out.softdevice_enabled == ((out.generation & 1) != 0)); + } + } + writer.join(); +} +''') + + def test_actual_prepare_power_cache_sleep_and_partial_startup_fail_closed(self): + source = (ROOT / "src/helpers/NRF52Board.cpp").read_text(encoding="ascii") + methods = "\n".join(extract_braced(source, signature) for signature in ( + "void NRF52Board::prepareSleepCycle()", + "bool NRF52Board::isExternalPowered()", + "void NRF52Board::sleep(uint32_t secs)", + )) + code = r''' +#include +#include +#include +#include +constexpr uint32_t NRF_SUCCESS = 0, NRF_ERROR_INVALID_STATE = 8; +constexpr uint32_t POWER_USBREGSTATUS_VBUSDETECT_Msk = 1; +constexpr uint32_t SCB_ICSR_PENDSVSET_Msk = 1UL << 28; +constexpr int taskSCHEDULER_RUNNING = 2; +#define NRF_CRYPTOCELL 1 +constexpr int CRYPTOCELL_IRQn = 42; +struct NvicState { volatile uint32_t __cr_flag = 0; } nrf_nvic_state; +static bool cc_enabled = false, cc_pending = false; +static unsigned cc_clears = 0; +static uint32_t NVIC_GetEnableIRQ(int irq) { + assert(irq == CRYPTOCELL_IRQn); return cc_enabled; +} +static void NVIC_ClearPendingIRQ(int irq) { + assert(irq == CRYPTOCELL_IRQn); cc_pending = false; ++cc_clears; +} +struct Scb { uint32_t ICSR = 0; } scb; +static Scb* SCB = &scb; +struct Power { uint32_t USBREGSTATUS = 0; } power; +static Power* NRF_POWER = &power; +static bool actual_sd = false, invalidate_during_query = false, event = false; +static uint32_t actual_usb = 0, sd_error = 0, usb_error = 0; +static unsigned state_queries = 0, usb_queries = 0, sd_waits = 0, bare_waits = 0, delays = 0; +static unsigned acknowledgements = 0, forced_yields = 0; +static uint32_t ipsr = 0, primask = 0, basepri = 0, faultmask = 0; +static int scheduler_state = taskSCHEDULER_RUNNING; +static bool yielded_app_irq = false, defer_pendsv = false, invalidate_during_yield = false; +static bool critical_during_yield = false; +static uint32_t acknowledgement_error = 0; +static uint32_t __get_IPSR() { return ipsr; } +static uint32_t __get_PRIMASK() { return primask; } +static uint32_t __get_BASEPRI() { return basepri; } +static uint32_t __get_FAULTMASK() { return faultmask; } +static int xTaskGetSchedulerState() { return scheduler_state; } +static void taskYIELD() { + ++forced_yields; event = true; + if (defer_pendsv) scb.ICSR |= SCB_ICSR_PENDSVSET_Msk; + else yielded_app_irq = true; + if (invalidate_during_yield) mesh_nrf52::sleepCycleState().invalidate(); + if (critical_during_yield) nrf_nvic_state.__cr_flag = 1; +} +static uint32_t now_us = 1000; +static uint32_t millis() { return now_us / 1000; } +static uint32_t micros() { return now_us; } +static void delay(unsigned ms) { delays += ms; } +namespace mesh_nrf52 { +uint32_t softdeviceIsEnabled(uint8_t& out) { + ++state_queries; out = actual_sd; event = true; // Model the SVC-caused event. + if (invalidate_during_query) sleepCycleState().invalidate(); + return sd_error; +}} +static uint32_t sd_power_usbregstatus_get(uint32_t* out) { + ++usb_queries; *out = actual_usb; event = true; return usb_error; +} +static uint32_t sd_app_evt_wait() { + if (yielded_app_irq) { + yielded_app_irq = false; ++acknowledgements; + return acknowledgement_error; + } + ++sd_waits; now_us += 300; return 0; +} +static void __SEV() { event = true; } +static void __WFE() { + if (event) event = false; + else { ++bare_waits; now_us += 500; } +} +struct NRF52Board { + bool radio_test = false; + bool isRadioTestActive() const { return radio_test; } + void prepareSleepCycle(); + bool isExternalPowered(); + void sleep(uint32_t secs); +}; +@METHODS@ +int main() { + using namespace mesh::powerdiag; + NRF52Board board; + board.sleep(0); assert(state_queries == 0 && sd_waits == 0 && bare_waits == 0); + power.USBREGSTATUS = 1; + board.prepareSleepCycle(); + assert(state_queries == 1 && usb_queries == 0 && !event); + assert(forced_yields == 0 && acknowledgements == 0); + for (int i = 0; i < 20; ++i) assert(board.isExternalPowered()); + assert(state_queries == 1 && !event); // UI power polling must not create late SVC events. + power.USBREGSTATUS = 0; + assert(board.isExternalPowered()); // Stable only within this prepared cycle. + board.prepareSleepCycle(); + assert(!board.isExternalPowered() && state_queries == 2); + board.sleep(0); + assert(bare_waits == 1 && sd_waits == 0 && state_queries == 2); + board.sleep(0); assert(bare_waits == 1 && state_queries == 2); // One-shot, not a global SD cache. + + // Startup can partially enable SD before failing. Invalidation is BEFORE + // the attempt, not conditional on a successful Bluefruit return value. + board.prepareSleepCycle(); + mesh_nrf52::sleepCycleState().invalidate(); actual_sd = true; actual_usb = 1; + assert(board.isExternalPowered()); // Stale SD-off cycle cannot bypass the SDK. + assert(usb_queries == 1); + const auto before = state_queries; + board.sleep(0); assert(state_queries == before && sd_waits == 0); + power.USBREGSTATUS = 0; + board.prepareSleepCycle(); + assert(!event && board.isExternalPowered() && usb_queries == 2); + assert(forced_yields == 1 && acknowledgements == 1); + board.sleep(0); assert(sd_waits == 1 && bare_waits == 1); + + setEnabled(true, millis()); reset(millis()); + sd_error = 17; + board.prepareSleepCycle(); board.sleep(0); + auto value = snapshot(millis()); + assert(value.errors == 1 && value.detail == 17 && value.calls == 0); + assert(sd_waits == 1 && bare_waits == 1); + sd_error = 0; usb_error = 23; reset(millis()); + board.prepareSleepCycle(); board.sleep(0); + value = snapshot(millis()); + assert(value.errors == 1 && value.detail == 23 && value.calls == 0); + usb_error = 0; invalidate_during_query = true; reset(millis()); + board.prepareSleepCycle(); board.sleep(0); + value = snapshot(millis()); + assert(value.board_vetoes == 1 && value.detail == 3 && value.calls == 0); + invalidate_during_query = false; + for (uint32_t* mask : {&ipsr, &primask, &basepri, &faultmask}) { + *mask = 1; reset(millis()); + const auto before_query = state_queries, before_ack = acknowledgements; + board.prepareSleepCycle(); board.sleep(0); + value = snapshot(millis()); + assert(value.errors == 1 && value.detail == NRF_ERROR_INVALID_STATE); + assert(state_queries == before_query && acknowledgements == before_ack); + *mask = 0; + } + scheduler_state = 0; reset(millis()); + board.prepareSleepCycle(); board.sleep(0); + assert(snapshot(millis()).detail == NRF_ERROR_INVALID_STATE); + scheduler_state = taskSCHEDULER_RUNNING; + defer_pendsv = true; reset(millis()); + const auto before_ack = acknowledgements; + board.prepareSleepCycle(); board.sleep(0); + assert(acknowledgements == before_ack && snapshot(millis()).detail == NRF_ERROR_INVALID_STATE); + defer_pendsv = false; scb.ICSR = 0; + acknowledgement_error = 31; reset(millis()); + board.prepareSleepCycle(); board.sleep(0); + assert(snapshot(millis()).errors == 1 && snapshot(millis()).detail == 31); + acknowledgement_error = 0; + invalidate_during_yield = true; reset(millis()); + const auto before_invalid_ack = acknowledgements; + board.prepareSleepCycle(); board.sleep(0); + assert(acknowledgements == before_invalid_ack && snapshot(millis()).detail == 3); + invalidate_during_yield = false; yielded_app_irq = false; + board.prepareSleepCycle(); board.radio_test = true; board.sleep(0); + board.radio_test = false; board.sleep(0); + assert(sd_waits == 1); // A veto consumed the prior preparation too. + + // Only the disabled polling CryptoCell IRQ may be cleaned, and only at + // entry. An enabled owner and unrelated NVIC sources are never touched. + cc_pending = true; + const auto before_cc_clear = cc_clears; + board.prepareSleepCycle(); + assert(!cc_pending && cc_clears == before_cc_clear + 1); + cc_enabled = true; cc_pending = true; + board.prepareSleepCycle(); + assert(cc_pending && cc_clears == before_cc_clear + 1); + cc_enabled = false; + // A public SDK critical region can virtually mask an enabled owner even + // with all CPU mask registers clear. Refuse preparation before cleanup. + nrf_nvic_state.__cr_flag = 1; + const auto before_critical_query = state_queries; + board.prepareSleepCycle(); + assert(state_queries == before_critical_query && cc_pending); + assert(cc_clears == before_cc_clear + 1); + assert(mesh_nrf52::sleepCycleState().lastError() == NRF_ERROR_INVALID_STATE); + nrf_nvic_state.__cr_flag = 0; + // Recheck the volatile SDK critical flag after taskYIELD, not just at entry. + critical_during_yield = true; + const auto before_critical_ack = acknowledgements; + board.prepareSleepCycle(); + assert(acknowledgements == before_critical_ack); + assert(mesh_nrf52::sleepCycleState().lastError() == NRF_ERROR_INVALID_STATE); +} +'''.replace("@METHODS@", methods) + self.execute(code) + + def test_every_application_bluefruit_attempt_invalidates_before_call(self): + for filename, call in ( + ("src/helpers/NRF52Board.cpp", "if (!Bluefruit.begin(1, 0))"), + ("src/helpers/nrf52/SerialBLEInterface.cpp", "if (!Bluefruit.begin())"), + ): + with self.subTest(filename=filename): + source = (ROOT / filename).read_text(encoding="ascii") + at = source.index(call) + self.assertTrue(source[:at].rstrip().endswith( + "mesh_nrf52::sleepCycleState().invalidate();")) + board = (ROOT / "src/helpers/NRF52Board.cpp").read_text(encoding="ascii") + late = extract_braced(board, "void NRF52Board::sleep(uint32_t secs)") + self.assertNotIn("softdeviceIsEnabled", late) + self.assertNotIn("sd_power_usbregstatus_get", late) + self.assertNotIn("NVIC->", late) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_sleep_events.py b/test/test_nrf52_sleep_events.py new file mode 100644 index 000000000..053054775 --- /dev/null +++ b/test/test_nrf52_sleep_events.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Execute sleep preparation/cache code with modeled nRF52 event semantics. + +The pinned BSP's loop-task yield and empty CDC available() poll both send SEV +through portYIELD. The fixture models the documented event-register/single-SD- +wait contract so fixes cannot trade stale-event insomnia for lost application +work. Production cache and extracted board methods run against these hardware +primitives. Actual residency and SoftDevice behavior still need hardware tests. +""" + +from pathlib import Path +import os +import shlex +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + + +class Nrf52SleepEventTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-nrf52-event-test-") + cls.addClassCleanup(cls.temp.cleanup) + cls.binary = Path(cls.temp.name) / "sleep-events" + result = subprocess.run( + shlex.split(os.environ.get("CXX", "g++")) + + ["-std=c++11", "-Wall", "-Wextra", "-Werror", "-pedantic", + str(ROOT / "test/fixtures/nrf52_sleep_events.cpp"), + "-o", str(cls.binary)], + capture_output=True, text=True, + ) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + board = (ROOT / "src/helpers/NRF52Board.cpp").read_text(encoding="ascii") + methods = [body(board, signature) for signature in ( + "void NRF52Board::prepareSleepCycle()", + "bool NRF52Board::isExternalPowered()", + "void NRF52Board::sleep(uint32_t secs)", + )] + fixture = (ROOT / "test/fixtures/nrf52_sleep_cycle.cpp").read_text(encoding="ascii") + cls.cycle_source = fixture.replace("@BOARD_METHODS@", "\n".join(methods)) + cls.cycle_binary = cls.compile_cycle("cycle", cls.cycle_source) + # Negative control executes the real code with only the old late state + # SVC restored. It must reproduce insomnia with non-yielding RX polls. + late_query = ("uint8_t ignored_sd_state = 0;\n" + " mesh_nrf52::softdeviceIsEnabled(ignored_sd_state);\n ") + late_methods = methods.copy() + late_methods[-1] = late_methods[-1].replace( + "if (cycle.softdevice_enabled)", late_query + "if (cycle.softdevice_enabled)", 1) + cls.late_query_binary = cls.compile_cycle( + "late-query", fixture.replace("@BOARD_METHODS@", "\n".join(late_methods))) + no_ack = cls.cycle_source.replace("error = sd_app_evt_wait();", "error = NRF_SUCCESS;", 1) + cls.no_ack_binary = cls.compile_cycle("no-sdk-ack", no_ack) + no_yield = cls.cycle_source.replace(" taskYIELD();", " // Negative control: no real PendSV.", 1) + cls.no_yield_binary = cls.compile_cycle("no-forced-pendsv", no_yield) + + @classmethod + def compile_cycle(cls, name, source): + cpp = Path(cls.temp.name) / (name + ".cpp") + binary = Path(cls.temp.name) / name + cpp.write_text(source, encoding="ascii") + result = subprocess.run( + shlex.split(os.environ.get("CXX", "g++")) + + ["-std=c++11", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", "-I", str(ROOT / "src"), str(cpp), + str(ROOT / "test/fixtures/nrf52_sleep_cycle_other.cpp"), + "-o", str(binary)], + capture_output=True, text=True, + ) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + return binary + + def run_case(self, name): + result = subprocess.run([str(self.binary), name], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def run_cycle_case(self, name): + result = subprocess.run([str(self.cycle_binary), name], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_prior_scheduler_yield_prevents_every_single_late_wait(self): + self.run_case("scheduler") + + def test_entry_clear_and_non_yielding_rx_allow_idle_sleep(self): + self.run_case("entry_idle") + + def test_entry_clear_alone_cannot_fix_empty_cdc_poll_yield(self): + self.run_case("poll_yield") + + def test_irqs_at_every_entry_work_predicate_and_wait_boundary_are_safe(self): + self.run_case("injections") + + def test_event_latch_protects_work_not_visible_to_the_sleep_predicate(self): + self.run_case("unobserved") + + def test_late_double_wait_can_sleep_with_unserviced_application_work(self): + self.run_case("late_double") + + def test_raw_cleanup_after_first_wait_does_not_wait_again(self): + self.run_case("cleanup") + + def test_raw_entry_clear_cannot_acknowledge_private_sdk_irq_state(self): + self.run_case("private_counter") + + def test_sev_alone_cannot_guarantee_nonblocking_early_sdk_wait(self): + self.run_case("sev_only") + + def test_private_irq_latch_cannot_wrap_back_to_acknowledged_state(self): + self.run_case("counter_wrap") + + def test_sdk_entry_ack_preserves_irq_history_from_during_its_wait(self): + self.run_case("wait_irq_history") + + def test_persistent_disabled_irq_survives_event_clear_and_sdk_ack(self): + self.run_case("disabled_irq") + + def test_late_disabled_crypto_irq_returns_then_next_entry_recovers(self): + self.run_case("late_disabled_irq") + + def test_enabled_crypto_owner_and_other_pending_irqs_are_preserved(self): + self.run_case("irq_owner") + + def test_all_sleeping_roles_prepare_before_any_work_or_policy_check(self): + entry = "\nvoid loop() {\n#if defined(NRF52_PLATFORM)\n board.prepareSleepCycle();\n#endif\n" + for role in ("simple_repeater", "simple_room_server", "simple_sensor", + "companion_radio"): + with self.subTest(role=role): + source = (ROOT / "examples" / role / "main.cpp").read_text(encoding="ascii") + actual = body(source, "\nvoid loop() {") + self.assertTrue(actual.startswith(entry)) + self.assertEqual(actual.count("board.prepareSleepCycle();"), 1) + + def test_actual_cache_single_attempt_stale_publication_errors_and_recovery(self): + self.run_cycle_case("cache") + + def test_actual_board_raw_wait_does_not_issue_a_late_state_query(self): + self.run_cycle_case("raw") + + def test_actual_board_softdevice_wait_does_not_issue_a_late_state_query(self): + self.run_cycle_case("sd") + + def test_actual_raw_wait_keeps_an_irq_after_the_predicate_visible(self): + self.run_cycle_case("raw_irq") + + def test_actual_softdevice_wait_keeps_an_irq_after_the_predicate_visible(self): + self.run_cycle_case("sd_irq") + + def test_actual_cross_translation_unit_start_or_partial_start_invalidates_sleep(self): + self.run_cycle_case("transition") + + def test_actual_query_errors_remain_awake_and_next_valid_cycle_recovers(self): + self.run_cycle_case("query_error") + + def test_actual_invalidation_during_query_cannot_publish_stale_state(self): + self.run_cycle_case("during_query") + + def test_actual_irq_during_preparation_preserves_work_for_following_service(self): + self.run_cycle_case("prepare_irq") + + def test_actual_usb_power_snapshot_avoids_ui_svcs_and_refreshes_each_loop(self): + self.run_cycle_case("usb") + + def test_actual_disabled_crypto_pending_cleanup_allows_sdk_sleep(self): + self.run_cycle_case("cc_recover") + + def test_actual_late_crypto_pending_bit_is_not_cleared_until_next_entry(self): + self.run_cycle_case("cc_late") + + def test_actual_enabled_crypto_owner_and_other_irqs_are_not_cleared(self): + self.run_cycle_case("cc_owner") + self.run_cycle_case("other_owner") + + def test_actual_sdk_critical_region_prevents_queries_or_early_ack(self): + self.run_cycle_case("sdk_critical") + self.run_cycle_case("sdk_critical_after_yield") + + def test_restoring_old_late_svc_is_a_detected_insomnia_negative_control(self): + result = subprocess.run( + [str(self.late_query_binary), "raw"], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Assertion", result.stderr) + + def test_missing_early_sdk_ack_or_forced_real_irq_are_detected_negative_controls(self): + for binary in (self.no_ack_binary, self.no_yield_binary): + with self.subTest(binary=binary.name): + result = subprocess.run([str(binary), "sd"], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Assertion", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_usb_logging_contract.py b/test/test_nrf52_usb_logging_contract.py index 45aade847..4f0afd147 100644 --- a/test/test_nrf52_usb_logging_contract.py +++ b/test/test_nrf52_usb_logging_contract.py @@ -1,8 +1,12 @@ #!/usr/bin/env python3 -"""Static integration guards for nRF52 nonblocking USB diagnostics.""" +"""Integration guards and native tests for nRF52 nonblocking USB diagnostics.""" from pathlib import Path +import os import re +import shutil +import subprocess +import tempfile import unittest from test_replay_reset_integration import extract_braced @@ -133,11 +137,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() main = (ROOT / "examples/companion_radio/main.cpp").read_text() - helper_start = source.index("static size_t writeTinyUsbCdcOnce(") - helper_end = source.index( - "static SingleAttemptNonBlockingStream", helper_start - ) - helper = source[helper_start:helper_end] + helper = extract_braced(source, "static size_t writeTinyUsbCdcOnce(") self.assertEqual(helper.count("tud_cdc_n_write(instance, data, attempt)"), 1) self.assertNotIn("Adafruit_USBD_CDC::write", helper) self.assertIn("tud_cdc_n_connected(instance)", helper) @@ -148,6 +148,134 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase): "usb_serial_interface.begin(mesh::usbCompanionPort(),", main ) + def test_primary_empty_rx_query_never_yields_and_preserves_session_gates(self): + source = (ROOT / "src/helpers/UsbLogging.cpp").read_text(encoding="ascii") + facade = extract_braced(source, "class Nrf52PrimaryUsbStream") + ";" + access = extract_braced(source, "static bool canAccessPrimaryUsbSession(") + self.assertIn("static Nrf52PrimaryUsbStream nonblocking_primary_usb_companion_port(", source) + self.assertIn("using Nrf52PrimaryUsbStream = SingleAttemptNonBlockingStream;", source) + fixture = r''' +#include +#include +#include +#include +static bool mounted = true; +static unsigned queried[2] = {}, delegate_queries = 0, delegate_yields = 0; +static unsigned rx[2] = {}; +static std::function during_query, during_read; +static bool tud_mounted() { return mounted; } +static uint32_t tud_cdc_n_available(uint8_t instance) { + assert(instance < 2); + ++queried[instance]; + const uint32_t count = rx[instance]; + if (during_query) { + auto callback = std::move(during_query); + during_query = nullptr; + callback(); + } + return count; +} +struct Delegate : Stream { + int available() override { + ++delegate_queries; + if (rx[0] == 0) ++delegate_yields; + return static_cast(rx[0]); + } + int read() override { + const bool had_byte = rx[0] != 0; + if (had_byte) --rx[0]; + if (during_read) { + auto callback = std::move(during_read); + during_read = nullptr; + callback(); + } + return had_byte ? 42 : -1; + } + int peek() override { return rx[0] ? 42 : -1; } +}; +namespace mesh { +static std::atomic primary_usb_reset_generation{0}; +static std::atomic primary_usb_allowed_generation{0}; +static std::atomic primary_usb_terminal_discard_pending{false}; +static std::atomic nrf52_usb_reenumeration_pending{false}; +static std::atomic primary_usb_line_state_dtr{true}; +static std::atomic primary_usb_rx_generation{UINT32_MAX}; +static uint32_t primaryUsbSessionGeneration() { return primary_usb_reset_generation.load(); } +@ACCESS@ +@FACADE@ +} +int main() { + Delegate delegate; + mesh::Nrf52PrimaryUsbStream port(delegate, nullptr, nullptr, + mesh::canAccessPrimaryUsbSession); + static_assert(sizeof(port) == sizeof(mesh::SingleAttemptNonBlockingStream), + "Primary RX polling must not add per-stream RAM"); + for (unsigned i = 0; i < 1000; ++i) assert(port.available() == 0); + assert(delegate_queries == 0 && delegate_yields == 0); + assert(queried[0] == 1000 && queried[1] == 0); + rx[1] = 17; + assert(port.available() == 0 && queried[1] == 0); // Never borrow CDC1 input. + rx[0] = 3; + assert(port.available() == 3 && port.peek() == 42); + assert(port.read() == 42 && rx[0] == 2); // Keep the inherited read/peek route. + during_query = [] { ++mesh::primary_usb_reset_generation; }; + assert(port.available() == 0); // Closed during the count query. + const unsigned closed_queries = queried[0]; + assert(port.available() == 0 && queried[0] == closed_queries); + mesh::primary_usb_allowed_generation = mesh::primaryUsbSessionGeneration(); + mesh::primary_usb_line_state_dtr = false; + mesh::primary_usb_rx_generation = UINT32_MAX; + rx[0] = 0; + assert(port.available() == 0); + assert(mesh::primary_usb_rx_generation == UINT32_MAX); // Empty RX is not proof. + rx[0] = 2; + assert(port.available() == 2); // MeshCLI may send with DTR low. + assert(mesh::primary_usb_rx_generation == mesh::primaryUsbSessionGeneration()); + assert(port.read() == 42 && port.available() == 1); + during_read = [] { ++mesh::primary_usb_reset_generation; }; + assert(port.read() == -1); // Inherited read drops an old-epoch byte. + assert(port.available() == 0); + mesh::primary_usb_allowed_generation = mesh::primaryUsbSessionGeneration(); + mesh::primary_usb_line_state_dtr = true; + for (auto* gate : {&mesh::primary_usb_terminal_discard_pending, + &mesh::nrf52_usb_reenumeration_pending}) { + *gate = true; + const unsigned before = queried[0]; + assert(port.available() == 0 && queried[0] == before); + *gate = false; + } + mounted = false; + assert(port.available() == 0); + assert(delegate_queries == 0 && delegate_yields == 0 && queried[1] == 0); +} +'''.replace("@ACCESS@", access).replace("@FACADE@", facade) + controls = { + "production": fixture, + "delegated-empty-query": fixture.replace( + "static_cast(tud_cdc_n_available(0))", "SingleAttemptNonBlockingStream::available()"), + "missing-post-query-gate": fixture.replace( + "return canAccessPrimaryUsbSession(nullptr) ? count : 0;", "return count;"), + "wrong-cdc-index": fixture.replace( + "static_cast(tud_cdc_n_available(0))", "static_cast(tud_cdc_n_available(1))"), + } + compiler = shutil.which(os.environ.get("CXX", "g++")) + self.assertIsNotNone(compiler, "a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-nrf52-empty-rx-") as directory: + for label, code in controls.items(): + with self.subTest(control=label): + cpp, binary = Path(directory) / f"{label}.cpp", Path(directory) / label + cpp.write_text(code, encoding="ascii") + built = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-sign-compare", + *([] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]), + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode == 0, label == "production", ran.stdout + ran.stderr) + def test_dedicated_logging_is_drained_only_from_tinyusb_task(self): source = (ROOT / "src/helpers/UsbLogging.cpp").read_text() stream = (ROOT / "src/helpers/NonBlockingWriteStream.h").read_text() diff --git a/test/test_power_saving_role_diagnostics.py b/test/test_power_saving_role_diagnostics.py index f48f66c3d..85a17ba0b 100644 --- a/test/test_power_saving_role_diagnostics.py +++ b/test/test_power_saving_role_diagnostics.py @@ -215,6 +215,7 @@ int main() { #include #include #include +#include static uint32_t now_us = 1000, wait_duration = 547, sdk_error = 0; static bool softdevice = true; static char sequence[32] = {}; static unsigned steps = 0, yields = 0; @@ -234,28 +235,38 @@ struct NRF52Board { void sleep(uint32_t secs); }; @METHOD@ +static void prepare() { + auto& state = mesh_nrf52::sleepCycleState(); + const uint32_t token = state.begin(); + assert(state.publish(token, softdevice, 0)); +} int main() { using namespace mesh::powerdiag; NRF52Board board; + prepare(); board.sleep(0); assert(steps == 1 && sequence[0] == 'A'); assert(snapshot(millis()).calls == 0); setEnabled(true, millis()); reset(millis()); + prepare(); board.sleep(0); auto value = snapshot(millis()); assert(value.calls == 1 && value.returns == 1 && value.last_call_us == 547); assert(value.errors == 0 && value.board_vetoes == 0); board.test = true; + prepare(); board.sleep(0); value = snapshot(millis()); assert(steps == 2 && yields == 1); assert(value.calls == 1 && value.board_vetoes == 1 && value.detail == 2); assert(value.current & mask(Reason::Board)); board.test = false; sdk_error = 17; + prepare(); board.sleep(0); value = snapshot(millis()); assert(value.errors == 1 && value.detail == 17 && value.calls == 2 && value.returns == 2); softdevice = false; steps = 0; memset(sequence, 0, sizeof(sequence)); + prepare(); board.sleep(0); assert(!strcmp(sequence, "WSW")); // Do not erase a radio GPIO edge with SEV/WFE/WFE. value = snapshot(millis()); diff --git a/test/test_usb_logging_watchdog_roles.py b/test/test_usb_logging_watchdog_roles.py index 3b63db12c..bb2817223 100644 --- a/test/test_usb_logging_watchdog_roles.py +++ b/test/test_usb_logging_watchdog_roles.py @@ -284,7 +284,14 @@ class UsbLoggingWatchdogRoleTests(unittest.TestCase): self.assertIn('mesh::serviceUsbLoggingPort();', loop) self.assertIn('if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) ' 'board.reboot();', loop) - self.assertIn('!mesh::isUsbLoggingWatchdogArmed()', source) + if role == 'companion_radio': + guard = method(loop, 'mesh::powerdiag::Mask sleep_reason = [&]()') + self.assertIn('if (mesh::isUsbLoggingWatchdogArmed()) ' + 'return mask(Reason::UsbRecovery);', guard) + self.assertIn('const bool can_sleep = sleep_reason == 0;', loop) + self.assertLess(loop.index(guard), loop.index('if (can_sleep)')) + else: + self.assertIn('!mesh::isUsbLoggingWatchdogArmed()', loop) @staticmethod def _harness(declaration, definition, callback, role, commands, companion): diff --git a/test/test_wireless_control.py b/test/test_wireless_control.py index 32288947d..3833c0572 100644 --- a/test/test_wireless_control.py +++ b/test/test_wireless_control.py @@ -301,7 +301,17 @@ int main(){ source = (ROOT/'examples'/role/'main.cpp').read_text() self.assertIn(f'control().begin({backend})', source) self.assertIn('control().service(millis())', source[source.index('\nvoid loop()'):]) - self.assertIn('&& !mesh::wireless::control().pending()', source[source.index('\nvoid loop()'):]) + loop = method(source, '\nvoid loop()') + if role == 'companion_radio': + guard = method(loop, 'mesh::powerdiag::Mask sleep_reason = [&]()') + self.assertIn('if (mesh::wireless::control().pending()) ' + 'return mask(Reason::Wireless);', guard) + self.assertIn('const bool can_sleep = sleep_reason == 0;', loop) + self.assertLess(loop.index(guard), loop.index('if (can_sleep)')) + else: + self.assertIn('&& sleep_guard(!mesh::wireless::control().pending(), ' + 'mesh::powerdiag::Reason::Wireless)', loop) + self.assertIn('return allowed;', method(loop, 'const auto sleep_guard = []')) gates = { 'src/helpers/bridges/MQTTBridge.cpp': 'WiFi', 'src/helpers/bridges/ESPNowBridge.cpp': 'EspNow',