From 61bdd8f1d3f1915a5dd2850734b5674c1223b11e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:52:09 -0600 Subject: [PATCH 1/2] MicroPython: a failed LVGL assert raises RuntimeError instead of hanging On MicroPython, LV_ASSERT_HANDLER was while(1): a failed assertion spun with the scheduler locked, so a board's USB serial and WebREPL went silent until a power cycle. The MicroPython port now provides lv_micropython_assert_failed(), which raises RuntimeError("LVGL assert failed in (:)"). Inside a Python callback the binding's callback wrapper catches it, prints it and returns to LVGL, which carries on. With no Python callback in between it unwinds out of LVGL to the Python caller, so the handler first clears lv_timer_handler()'s running flag and each display's rendering flag, which would otherwise stay set past a soft reset. The generated binding exports mp_lv_nesting_depth() so the handler can tell the two apart. Checked on a unix build: a flush callback that changes a widget mid-render (lv_inv_area) hung the previous build and now raises, and rendering carries on; a draw-task allocation failing mid-render (lv_draw_add_task, heap full) hung the previous build and now raises out of lv.timer_handler(), and the next refresh draws. With the flag repair removed, every later invalidation raised again. An RPI_PICO2 build links the handler. --- binding/emit_c_micropython_style.py | 10 +++ generated/lvgl_circuitpython.c | 10 +++ generated/lvgl_micropython.c | 10 +++ lv_conf.h | 14 ++++- ports/micropython/micropython.cmake | 2 +- ports/micropython/micropython.mk | 3 + ports/micropython/src/lv_assert_micropython.c | 62 +++++++++++++++++++ 7 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 ports/micropython/src/lv_assert_micropython.c diff --git a/binding/emit_c_micropython_style.py b/binding/emit_c_micropython_style.py index a42974b..c8d9281 100644 --- a/binding/emit_c_micropython_style.py +++ b/binding/emit_c_micropython_style.py @@ -1015,6 +1015,16 @@ def emit_c(ctx): return result; } +// How many Python callbacks LVGL is inside right now. The MicroPython port's +// assert handler reads it: at zero, an exception raised from a failed LVGL +// assert unwinds out of every LVGL frame, so the handler first clears the +// state LVGL holds for the length of a pass; above zero, the callback's own +// nlr frame catches it and LVGL carries on, so that state must stay. +int mp_lv_nesting_depth(void) +{ + return _nesting; +} + // Function pointers wrapper static mp_obj_t mp_lv_funcptr(const mp_lv_obj_fun_builtin_var_t *mp_fun, void *lv_fun, void *lv_callback, qstr func_name, void *user_data) diff --git a/generated/lvgl_circuitpython.c b/generated/lvgl_circuitpython.c index 77d511c..db187f2 100644 --- a/generated/lvgl_circuitpython.c +++ b/generated/lvgl_circuitpython.c @@ -854,6 +854,16 @@ static mp_obj_t mp_lv_nesting_call(mp_obj_t func, size_t n_args, const mp_obj_t return result; } +// How many Python callbacks LVGL is inside right now. The MicroPython port's +// assert handler reads it: at zero, an exception raised from a failed LVGL +// assert unwinds out of every LVGL frame, so the handler first clears the +// state LVGL holds for the length of a pass; above zero, the callback's own +// nlr frame catches it and LVGL carries on, so that state must stay. +int mp_lv_nesting_depth(void) +{ + return _nesting; +} + // Function pointers wrapper static mp_obj_t mp_lv_funcptr(const mp_lv_obj_fun_builtin_var_t *mp_fun, void *lv_fun, void *lv_callback, qstr func_name, void *user_data) diff --git a/generated/lvgl_micropython.c b/generated/lvgl_micropython.c index 0b54672..818600a 100644 --- a/generated/lvgl_micropython.c +++ b/generated/lvgl_micropython.c @@ -852,6 +852,16 @@ static mp_obj_t mp_lv_nesting_call(mp_obj_t func, size_t n_args, const mp_obj_t return result; } +// How many Python callbacks LVGL is inside right now. The MicroPython port's +// assert handler reads it: at zero, an exception raised from a failed LVGL +// assert unwinds out of every LVGL frame, so the handler first clears the +// state LVGL holds for the length of a pass; above zero, the callback's own +// nlr frame catches it and LVGL carries on, so that state must stay. +int mp_lv_nesting_depth(void) +{ + return _nesting; +} + // Function pointers wrapper static mp_obj_t mp_lv_funcptr(const mp_lv_obj_fun_builtin_var_t *mp_fun, void *lv_fun, void *lv_callback, qstr func_name, void *user_data) diff --git a/lv_conf.h b/lv_conf.h index 908c88a..2c760f5 100644 --- a/lv_conf.h +++ b/lv_conf.h @@ -299,9 +299,21 @@ #define LV_ASSERT_HANDLER_INCLUDE __attribute__((weak)) void lv_circuitpython_assert_failed(void); #define LV_ASSERT_HANDLER { if (lv_circuitpython_assert_failed) { lv_circuitpython_assert_failed(); } while(1); } -#else +#elif defined(LV_CPYTHON_BUILD) #define LV_ASSERT_HANDLER_INCLUDE #define LV_ASSERT_HANDLER while(1); /*Halt by default*/ +#else + /* On MicroPython a spinning assert locks the scheduler out: USB serial and + * WebREPL go silent until a power cycle. The MicroPython port's handler + * raises RuntimeError naming the function, file and line instead, which + * the nearest Python frame or LVGL callback reports, and the board stays + * reachable. It is weak here, so a build without it still links and + * halts as before. */ + #define LV_ASSERT_HANDLER_INCLUDE + #if !defined(PYCPARSER) && !defined(__ASSEMBLY__) + __attribute__((weak)) void lv_micropython_assert_failed(const char *file, int line, const char *func); + #endif + #define LV_ASSERT_HANDLER { if (lv_micropython_assert_failed) { lv_micropython_assert_failed(__FILE__, __LINE__, __func__); } while(1); } #endif /*------------- diff --git a/ports/micropython/micropython.cmake b/ports/micropython/micropython.cmake index cbdeac6..0af131d 100644 --- a/ports/micropython/micropython.cmake +++ b/ports/micropython/micropython.cmake @@ -45,7 +45,7 @@ if(NOT "${USER_C_MODULES}" MATCHES "jpegio") endif() add_library(lv_micropython INTERFACE) -target_sources(lv_micropython INTERFACE ${LVMP_C} ${LVMP_DIR}/src/lvgl_micropython_build.c) +target_sources(lv_micropython INTERFACE ${LVMP_C} ${LVMP_DIR}/src/lvgl_micropython_build.c ${LVMP_DIR}/src/lv_assert_micropython.c) # --- which commit this firmware was built from --------------------- # Computed at build time from this repository's git, never stored; "unknown" when diff --git a/ports/micropython/micropython.mk b/ports/micropython/micropython.mk index 9964130..420a9c3 100644 --- a/ports/micropython/micropython.mk +++ b/ports/micropython/micropython.mk @@ -70,6 +70,9 @@ CFLAGS_USERMOD += -I$(BINDINGS_DIR) -I$(LVMP_DIR) -Wno-unused-function SRC_USERMOD_LIB_C += $(SOURCES) SRC_USERMOD_C += $(LVMP_C) SRC_USERMOD_C += $(LVMP_DIR)/src/lvgl_micropython_build.c +# What a failed LVGL assert does (lv_conf.h). Here rather than with LVGL's +# sources so its message text is scanned like the binding's. +SRC_USERMOD_C += $(LVMP_DIR)/src/lv_assert_micropython.c # --- which commit this firmware was built from --------------------- # Computed at build time from this repository's git, never stored; "unknown" when diff --git a/ports/micropython/src/lv_assert_micropython.c b/ports/micropython/src/lv_assert_micropython.c new file mode 100644 index 0000000..8351ebe --- /dev/null +++ b/ports/micropython/src/lv_assert_micropython.c @@ -0,0 +1,62 @@ +/** + * @file lv_assert_micropython.c + * + * What a failed LVGL assertion does on MicroPython (lv_conf.h names this + * function in LV_ASSERT_HANDLER for MicroPython builds). LVGL's default is + * to spin forever, and on a board that locks the MicroPython scheduler out: + * USB serial and WebREPL go silent and only a power cycle brings it back. + * + * Instead this raises RuntimeError("LVGL assert failed in + * (:)"). LVGL's log is off in MicroPython builds, so the + * function and line are how to find the assertion's message in LVGL's source. + * + * Where the exception lands decides what LVGL state needs repair: + * + * - Inside a Python callback (a flush callback, an event, a timer), the + * binding's callback wrapper catches it, prints it and returns to LVGL, + * which carries on with its pass. The call that asserted is abandoned and + * nothing else changes. This is the usual case: Python code changing a + * widget while LVGL renders. + * - With no Python callback in between, it unwinds out of every LVGL frame + * to whoever called LVGL from Python. A pass in progress is abandoned, so + * the flags LVGL holds for its length are cleared first: otherwise + * lv_timer_handler() would return at once forever after, or every later + * invalidation would assert again, and neither resets on a soft reset. + * + * With no Python frame at all to catch it, nlr_jump() ends in the port's + * nlr_jump_fail(), the port's own fatal path. + */ +#include "py/runtime.h" + +#include "lvgl/lvgl.h" +#include "lvgl/src/core/lv_global.h" +#include "lvgl/src/display/lv_display_private.h" + +#include + +void lv_micropython_assert_failed(const char *file, int line, const char *func); + +// From the generated binding: how many Python callbacks LVGL is inside. +int mp_lv_nesting_depth(void); + +static void lv_micropython_end_pass(void) { + if (!lv_is_initialized()) { + return; + } + LV_GLOBAL_DEFAULT()->timer_state.already_running = false; + for (lv_display_t *disp = lv_display_get_next(NULL); disp != NULL; disp = lv_display_get_next(disp)) { + disp->rendering_in_progress = 0; + } +} + +void lv_micropython_assert_failed(const char *file, int line, const char *func) { + const char *base = strrchr(file, '/'); + if (base == NULL) { + base = strrchr(file, '\\'); + } + base = base ? base + 1 : file; + if (mp_lv_nesting_depth() == 0) { + lv_micropython_end_pass(); + } + mp_raise_msg_varg(&mp_type_RuntimeError, MP_ERROR_TEXT("LVGL assert failed in %s (%s:%d)"), func, base, line); +} From f753022d520ce753b5dde4ea684c6a3267b88d92 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:10:27 -0600 Subject: [PATCH 2/2] Binding: declare mp_lv_nesting_depth() before defining it CircuitPython's unix coverage build compiles the generated binding with -Werror=missing-declarations, which refused the new exported function. --- binding/emit_c_micropython_style.py | 1 + generated/lvgl_circuitpython.c | 1 + generated/lvgl_micropython.c | 1 + 3 files changed, 3 insertions(+) diff --git a/binding/emit_c_micropython_style.py b/binding/emit_c_micropython_style.py index c8d9281..3845282 100644 --- a/binding/emit_c_micropython_style.py +++ b/binding/emit_c_micropython_style.py @@ -1020,6 +1020,7 @@ def emit_c(ctx): // assert unwinds out of every LVGL frame, so the handler first clears the // state LVGL holds for the length of a pass; above zero, the callback's own // nlr frame catches it and LVGL carries on, so that state must stay. +int mp_lv_nesting_depth(void); int mp_lv_nesting_depth(void) { return _nesting; diff --git a/generated/lvgl_circuitpython.c b/generated/lvgl_circuitpython.c index db187f2..93448d6 100644 --- a/generated/lvgl_circuitpython.c +++ b/generated/lvgl_circuitpython.c @@ -859,6 +859,7 @@ static mp_obj_t mp_lv_nesting_call(mp_obj_t func, size_t n_args, const mp_obj_t // assert unwinds out of every LVGL frame, so the handler first clears the // state LVGL holds for the length of a pass; above zero, the callback's own // nlr frame catches it and LVGL carries on, so that state must stay. +int mp_lv_nesting_depth(void); int mp_lv_nesting_depth(void) { return _nesting; diff --git a/generated/lvgl_micropython.c b/generated/lvgl_micropython.c index 818600a..7283ca7 100644 --- a/generated/lvgl_micropython.c +++ b/generated/lvgl_micropython.c @@ -857,6 +857,7 @@ static mp_obj_t mp_lv_nesting_call(mp_obj_t func, size_t n_args, const mp_obj_t // assert unwinds out of every LVGL frame, so the handler first clears the // state LVGL holds for the length of a pass; above zero, the callback's own // nlr frame catches it and LVGL carries on, so that state must stay. +int mp_lv_nesting_depth(void); int mp_lv_nesting_depth(void) { return _nesting;