Skip to content

MicroPython: a failed LVGL assert raises RuntimeError instead of hanging - #13

Merged
bdbarnett merged 2 commits into
mainfrom
assert-recoverable
Nov 8, 2026
Merged

bdbarnett merged 2 commits into
mainfrom
assert-recoverable

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

On MicroPython a failed LVGL assertion spun in while(1) with the scheduler locked, so a board's USB serial and WebREPL went silent until a power cycle. Now the MicroPython port provides lv_micropython_assert_failed() (weak in lv_conf.h, like the CircuitPython hook), which raises RuntimeError("LVGL assert failed in lv_inv_area (lv_refr.c:286)"). LVGL's log is off in these builds, so the function, file and line are how to find the assertion's message. CircuitPython and the CPython wheel are unchanged.

Where the exception lands decides what happens next. Inside a Python callback (a flush callback, an event, a timer), the binding's callback wrapper already catches exceptions, prints them and returns to LVGL, so the call that asserted is abandoned and LVGL finishes its pass. This is the case in the issue: Python code changing a widget while LVGL renders. With no Python callback in between, as when an allocation fails inside a render, the exception unwinds out of LVGL to whoever called lv.timer_handler() or lv.refr_now(). Before raising, the handler clears lv_timer_handler()'s running flag and each display's rendering flag, because they'd otherwise stay set and survive a soft reset. To tell the two cases apart, the generated binding exports mp_lv_nesting_depth(), the callback depth it already keeps. Both generated C files are regenerated, and ./regenerate_all.sh --check matches. I didn't add a separate print before the raise: the exception already shows up wherever it's caught, so a second copy would only repeat it. If no Python frame at all can catch it, nlr_jump() ends in the port's own nlr_jump_fail().

I checked this on a unix build with this branch. First, a flush callback that calls label.set_text() mid-render. The previous build hung (killed by timeout after 15 s). This build prints the RuntimeError from inside the callback, lv.refr_now() returns, and a second refresh flushes. Second, with the heap filled so that lv_draw_add_task() fails mid-render: the previous build hung, and this build raises out of lv.timer_handler(); once the heap is freed, the next refresh draws two flushes. With the flag repair taken out (a planted build), every later invalidation raised lv_inv_area again, which is why it's there. An RPI_PICO2 build compiles and links the handler (CMake path, compressed error text). pytest ports/micropython/tests tests/test_nesting_export.py tests/test_generation_tools.py passes.

I didn't run this on a board, and I didn't check every LVGL assert. An assert that fires halfway through changing a linked list can still leave that object inconsistent, but the board stays reachable.

Fixes #12

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 <function> (<file>:<line>)").

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.
CircuitPython's unix coverage build compiles the generated binding with
-Werror=missing-declarations, which refused the new exported function.
@bdbarnett
bdbarnett merged commit 2206123 into main Nov 8, 2026
5 checks passed
@bdbarnett
bdbarnett deleted the assert-recoverable branch November 8, 2026 06:13
bdbarnett added a commit that referenced this pull request Nov 10, 2026
The assert handler from #13 passed the full source path, the line and the
function name, and each of LVGL's ~1,230 assert sites tested the weak
handler before calling it. On an ESP32-S3 that added 50.8 KB to the
firmware (23 KB of code, 20 KB of function names, 6 KB of paths). With
CONFIG_SPIRAM_XIP_FROM_PSRAM, where ESP-IDF copies code and constants into
PSRAM in 64 KB pages, it pushed both over a page boundary and took 128 KB
from the heap.

Now each site passes the file's base name (__FILE_NAME__, or __FILE__ on a
compiler without it) and the line to a noreturn handler: a load, a move
and a call. The message is "LVGL assert failed at lv_display.c:614". Built
for ESP32_GENERIC_S3, the growth over v9.5.53 is 6.3 KB and stays within
the same pages.

The smoke test triggers an assert (set_tile_cnt(256)) on MicroPython,
checks the message and that LVGL keeps working; it fails against the
previous handler.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

MicroPython: a failed LVGL assert hangs the board until power cycle

1 participant