From db771e5b60e2229c4ddceb3443699603bd70c095 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 17 Feb 2026 13:00:53 +1100 Subject: [PATCH 1/3] unix: Add signal-based scheduler event notification. Install an empty signal handler (without SA_RESTART) for a dedicated signal so that select() calls return EINTR when the scheduler queues a callback. mp_hal_signal_event() sends this signal via kill(getpid()), and MICROPY_SCHED_HOOK_SCHEDULED calls it from mp_sched_schedule(). This replaces the need for a self-pipe mechanism while remaining async-signal-safe. Signed-off-by: Andrew Leech --- ports/unix/main.c | 8 ++++++ ports/unix/mpconfigport.h | 9 ++++--- ports/unix/mphalport.h | 5 ++++ ports/unix/unix_mphal.c | 52 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/ports/unix/main.c b/ports/unix/main.c index 9e9704aa801db..ea70de69bfd29 100644 --- a/ports/unix/main.c +++ b/ports/unix/main.c @@ -495,6 +495,10 @@ MP_NOINLINE int main_(int argc, char **argv) { mp_init(); + #ifndef _WIN32 + mp_unix_init_sched_signal(); + #endif + #if MICROPY_EMIT_NATIVE // Set default emitter options MP_STATE_VM(default_emit_opt) = emit_opt; @@ -738,6 +742,10 @@ MP_NOINLINE int main_(int argc, char **argv) { gc_sweep_all(); #endif + #ifndef _WIN32 + mp_unix_deinit_sched_signal(); + #endif + mp_deinit(); #if MICROPY_ENABLE_GC && !defined(NDEBUG) diff --git a/ports/unix/mpconfigport.h b/ports/unix/mpconfigport.h index e290935bca93b..ff8f9faa16c03 100644 --- a/ports/unix/mpconfigport.h +++ b/ports/unix/mpconfigport.h @@ -121,10 +121,6 @@ typedef long mp_off_t; // port modtime functions use time_t #define MICROPY_TIMESTAMP_IMPL (MICROPY_TIMESTAMP_IMPL_TIME_T) -// Assume that select() call, interrupted with a signal, and erroring -// with EINTR, updates remaining timeout value. -#define MICROPY_SELECT_REMAINING_TIME (1) - // Disable stackless by default. #ifndef MICROPY_STACKLESS #define MICROPY_STACKLESS (0) @@ -225,6 +221,11 @@ static inline unsigned long mp_random_seed_init(void) { #include #define MICROPY_UNIX_MACHINE_IDLE sched_yield(); +#ifndef _WIN32 +void mp_hal_signal_event(void); +#define MICROPY_SCHED_HOOK_SCHEDULED mp_hal_signal_event() +#endif + #ifndef MICROPY_PY_BLUETOOTH_ENABLE_CENTRAL_MODE #define MICROPY_PY_BLUETOOTH_ENABLE_CENTRAL_MODE (1) #endif diff --git a/ports/unix/mphalport.h b/ports/unix/mphalport.h index 57184c4c2ecbc..4f26df7ece00e 100644 --- a/ports/unix/mphalport.h +++ b/ports/unix/mphalport.h @@ -114,6 +114,11 @@ static inline void mp_hal_delay_us(mp_uint_t us) { void mp_hal_get_random(size_t n, void *buf); +#ifndef _WIN32 +void mp_unix_init_sched_signal(void); +void mp_unix_deinit_sched_signal(void); +#endif + #if MICROPY_PY_BLUETOOTH enum { MP_HAL_MAC_BDADDR, diff --git a/ports/unix/unix_mphal.c b/ports/unix/unix_mphal.c index 39311c98729f5..9a69181fff6fc 100644 --- a/ports/unix/unix_mphal.c +++ b/ports/unix/unix_mphal.c @@ -30,12 +30,62 @@ #include #include #include +#include #include "py/mphal.h" #include "py/mpthread.h" #include "py/runtime.h" #include "extmod/misc.h" +#ifndef _WIN32 + +// Use a real-time signal if available, avoiding conflicts with GC +// (SIGRTMIN + 5) and thread terminate (SIGRTMIN + 6). Fall back to +// SIGURG which is ignored by default and rarely used. +#ifdef SIGRTMIN +#define MP_SCHED_SIGNAL (SIGRTMIN + 7) +#else +#define MP_SCHED_SIGNAL (SIGURG) +#endif + +static void sched_sighandler(int signum) { + (void)signum; +} + +void mp_unix_init_sched_signal(void) { + struct sigaction sa; + sa.sa_flags = 0; // No SA_RESTART: select() returns EINTR. + sa.sa_handler = sched_sighandler; + sigemptyset(&sa.sa_mask); + sigaction(MP_SCHED_SIGNAL, &sa, NULL); +} + +void mp_unix_deinit_sched_signal(void) { + signal(MP_SCHED_SIGNAL, SIG_DFL); +} + +void mp_hal_signal_event(void) { + kill(getpid(), MP_SCHED_SIGNAL); +} + +// Wait for an event or timeout. Returns early if callbacks are already +// pending (checked via sched_state) or if a signal interrupts select(). +// +// Note: there is a narrow race on threaded builds where a signal could +// arrive between the sched_state check and the select() call, causing +// select() to block despite a newly-pending callback. The impact is +// bounded -- the caller's next timeout expiry will process it. This is +// a deliberate simplification over pselect() with process-wide signal +// masking. +void mp_unix_sched_sleep(uint32_t timeout_ms) { + if (MP_STATE_VM(sched_state) == MP_SCHED_PENDING) { + return; + } + struct timeval tv = {timeout_ms / 1000, (timeout_ms % 1000) * 1000}; + select(0, NULL, NULL, NULL, &tv); +} +#endif + #if defined(__GLIBC__) && defined(__GLIBC_PREREQ) #if __GLIBC_PREREQ(2, 25) #include @@ -44,8 +94,6 @@ #endif #ifndef _WIN32 -#include - static void sighandler(int signum) { if (signum == SIGINT) { #if MICROPY_ASYNC_KBD_INTR From 5b097564d3e38c3f34695b44f766c00cbd09b70e Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 17 Feb 2026 13:01:29 +1100 Subject: [PATCH 2/3] unix: Process pending callbacks during time.sleep(). Add a drain loop before the sleep to process any already-pending callbacks, subtracting the time taken from the requested duration. The existing EINTR/MICROPY_SELECT_REMAINING_TIME loop handles new callbacks scheduled during the sleep via the signal mechanism. Also add ValueError for negative sleep values (matching CPython) and a test exercising the drain loop and error handling. Signed-off-by: Andrew Leech --- ports/unix/modtime.c | 21 +++++++++- ports/unix/mpconfigport.h | 4 ++ ports/unix/mphalport.h | 1 + tests/ports/unix/time_sleep_signal.py | 48 +++++++++++++++++++++++ tests/ports/unix/time_sleep_signal.py.exp | 3 ++ 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/ports/unix/time_sleep_signal.py create mode 100644 tests/ports/unix/time_sleep_signal.py.exp diff --git a/ports/unix/modtime.c b/ports/unix/modtime.c index 97c9b292d06f3..d0c1b7b65e79f 100644 --- a/ports/unix/modtime.c +++ b/ports/unix/modtime.c @@ -90,6 +90,19 @@ static mp_obj_t mp_time_sleep(mp_obj_t arg) { #if MICROPY_PY_BUILTINS_FLOAT struct timeval tv; mp_float_t val = mp_obj_get_float(arg); + if (val < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("sleep length must be non-negative")); + } + // Drain any already-pending callbacks before computing sleep time, + // subtracting the time taken from the requested sleep duration. + uint64_t drain_start = mp_hal_ticks_ms(); + while (MP_STATE_VM(sched_state) == MP_SCHED_PENDING) { + mp_handle_pending(MP_HANDLE_PENDING_CALLBACKS_AND_EXCEPTIONS); + } + val -= (mp_hal_ticks_ms() - drain_start) / MICROPY_FLOAT_CONST(1000.0); + if (val <= 0) { + return mp_const_none; + } mp_float_t ipart; tv.tv_usec = (time_t)MICROPY_FLOAT_C_FUN(round)(MICROPY_FLOAT_C_FUN(modf)(val, &ipart) * MICROPY_FLOAT_CONST(1000000.)); tv.tv_sec = (suseconds_t)ipart; @@ -105,7 +118,6 @@ static mp_obj_t mp_time_sleep(mp_obj_t arg) { if (res != -1 || errno != EINTR) { break; } - // printf("select: EINTR: %ld:%ld\n", tv.tv_sec, tv.tv_usec); #else break; #endif @@ -113,6 +125,13 @@ static mp_obj_t mp_time_sleep(mp_obj_t arg) { RAISE_ERRNO(res, errno); #else int seconds = mp_obj_get_int(arg); + if (seconds < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("sleep length must be non-negative")); + } + // Drain any already-pending callbacks. + while (MP_STATE_VM(sched_state) == MP_SCHED_PENDING) { + mp_handle_pending(MP_HANDLE_PENDING_CALLBACKS_AND_EXCEPTIONS); + } for (;;) { mp_handle_pending(MP_HANDLE_PENDING_CALLBACKS_AND_EXCEPTIONS); MP_THREAD_GIL_EXIT(); diff --git a/ports/unix/mpconfigport.h b/ports/unix/mpconfigport.h index ff8f9faa16c03..4b8d1d551fdb1 100644 --- a/ports/unix/mpconfigport.h +++ b/ports/unix/mpconfigport.h @@ -121,6 +121,10 @@ typedef long mp_off_t; // port modtime functions use time_t #define MICROPY_TIMESTAMP_IMPL (MICROPY_TIMESTAMP_IMPL_TIME_T) +// Assume that select() call, interrupted with a signal, and erroring +// with EINTR, updates remaining timeout value. +#define MICROPY_SELECT_REMAINING_TIME (1) + // Disable stackless by default. #ifndef MICROPY_STACKLESS #define MICROPY_STACKLESS (0) diff --git a/ports/unix/mphalport.h b/ports/unix/mphalport.h index 4f26df7ece00e..398d5658c885e 100644 --- a/ports/unix/mphalport.h +++ b/ports/unix/mphalport.h @@ -117,6 +117,7 @@ void mp_hal_get_random(size_t n, void *buf); #ifndef _WIN32 void mp_unix_init_sched_signal(void); void mp_unix_deinit_sched_signal(void); +void mp_unix_sched_sleep(uint32_t timeout_ms); #endif #if MICROPY_PY_BLUETOOTH diff --git a/tests/ports/unix/time_sleep_signal.py b/tests/ports/unix/time_sleep_signal.py new file mode 100644 index 0000000000000..488552e3a7d74 --- /dev/null +++ b/tests/ports/unix/time_sleep_signal.py @@ -0,0 +1,48 @@ +# Test time.sleep() signal-based scheduler integration on unix port. +# +# The signal-based EINTR wakeup during select() cannot be reliably tested +# from Python because kill(getpid()) delivers to an arbitrary thread, and +# the scheduling thread may receive it instead of the sleeping thread. +# Instead, test the drain loop (processes already-pending callbacks before +# sleeping) and ValueError for negative values. + +import micropython +import time + +# Test 1: ValueError for negative sleep. +try: + time.sleep(-1) + print("FAIL: no ValueError") +except ValueError: + print("ValueError") + +# Test 2: Pending callbacks are processed during time.sleep(). +# Schedule a chain of callbacks from within a callback so they accumulate +# while the scheduler is locked. time.sleep()'s drain loop should process +# them all before entering select(). +results = [] + + +def callback(n): + results.append(n) + if n < 3: + micropython.schedule(callback, n + 1) + + +micropython.schedule(callback, 0) + +# Small sleep to allow drain loop to process the chain. +time.sleep(0.01) + +# All callbacks in the chain should have been processed. +print("callbacks:", sorted(results)) + +# Test 3: Basic sleep timing still works (not broken by signal changes). +start = time.ticks_ms() +time.sleep(0.05) +elapsed = time.ticks_diff(time.ticks_ms(), start) +# Should be at least 40ms (allowing for timing imprecision) and under 500ms. +if 40 <= elapsed < 500: + print("timing ok") +else: + print("timing FAIL:", elapsed, "ms") diff --git a/tests/ports/unix/time_sleep_signal.py.exp b/tests/ports/unix/time_sleep_signal.py.exp new file mode 100644 index 0000000000000..1d9e55f94a5a5 --- /dev/null +++ b/tests/ports/unix/time_sleep_signal.py.exp @@ -0,0 +1,3 @@ +ValueError +callbacks: [0, 1, 2, 3] +timing ok From 44436737f1b4ff4126b8d8196fcace91eebb17ed Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 17 Feb 2026 13:01:50 +1100 Subject: [PATCH 3/3] unix: Use sched signal in MICROPY_INTERNAL_WFE. Replace the fixed 500us delay in MICROPY_INTERNAL_WFE with mp_unix_sched_sleep(), which sleeps for the full requested timeout but wakes immediately on EINTR from the scheduler signal. Signed-off-by: Andrew Leech --- ports/unix/mphalport.h | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ports/unix/mphalport.h b/ports/unix/mphalport.h index 398d5658c885e..97e3bd689f86b 100644 --- a/ports/unix/mphalport.h +++ b/ports/unix/mphalport.h @@ -37,16 +37,23 @@ #define MICROPY_END_ATOMIC_SECTION(x) (void)x; mp_thread_unix_end_atomic_section() #endif -// In lieu of a WFI(), slow down polling from being a tight loop. -// -// Note that we don't delay for the full TIMEOUT_MS, as execution -// can't be woken from the delay. +// Wait for an event (scheduled callback) or timeout. A signal from +// mp_hal_signal_event() causes select() to return EINTR, waking the wait. +#ifndef _WIN32 +#define MICROPY_INTERNAL_WFE(TIMEOUT_MS) \ + do { \ + MP_THREAD_GIL_EXIT(); \ + mp_unix_sched_sleep(TIMEOUT_MS); \ + MP_THREAD_GIL_ENTER(); \ + } while (0) +#else #define MICROPY_INTERNAL_WFE(TIMEOUT_MS) \ do { \ MP_THREAD_GIL_EXIT(); \ mp_hal_delay_us(500); \ MP_THREAD_GIL_ENTER(); \ } while (0) +#endif // The port provides `mp_hal_stdio_mode_raw()` and `mp_hal_stdio_mode_orig()`. #define MICROPY_HAL_HAS_STDIO_MODE_SWITCH (1)