Skip to content

Fix LVGL thread race between the main thread and the DisplayApp task#198

Open
faisal-shah wants to merge 2 commits into
InfiniTimeOrg:mainfrom
faisal-shah:fix-lvgl-thread-race
Open

Fix LVGL thread race between the main thread and the DisplayApp task#198
faisal-shah wants to merge 2 commits into
InfiniTimeOrg:mainfrom
faisal-shah:fix-lvgl-thread-race

Conversation

@faisal-shah

@faisal-shah faisal-shah commented Jul 14, 2026

Copy link
Copy Markdown

The complete fix needs a small companion change in InfiniTime: InfiniTimeOrg/InfiniTime#2447.

The bug

LVGL is not thread-safe, but InfiniSim drives it from two threads:

  • the DisplayApp task, LVGL's normal owner (lv_task_handler, screen loads), and
  • the SDL main thread, whose refresh_screen() creates and deletes the "Screen is OFF" overlay objects and runs lv_task_handler while the display task sleeps.

The wake/sleep handoff between the two corrupts the LVGL heap. It reproduces easily. Toggle sleep/wake with the right mouse button every ~600 ms, and an AddressSanitizer build crashes within about 4 cycles:

ERROR: AddressSanitizer: heap-use-after-free
READ of size 8 ... in get_property_index lvgl/src/lv_core/lv_style.c:996
    ... _lv_disp_refr_task <- lv_task_handler <- DisplayApp::Refresh   (thread "displayapp")
freed by thread T0 (main) here:
    ... lv_obj_del <- Framework::refresh_screen <- Framework::refresh <- main
previously allocated by thread T0 here:
    ... lv_obj_set_style_local_bg_color <- Framework::refresh_screen  (screen_off_bg)

In a normal build the same corruption surfaces later as a SIGSEGV in the style walk during refresh (_lv_style_get_int reading a freed style map). The existing screen_off_delete_cb double-free guard covers one symptom but not the underlying race.

The fix

A std::recursive_mutex behind LVGL_GUARD() (sim/displayapp/LvglGuard.h), with the two sides locking differently on purpose:

  • the DisplayApp task takes it blocking, since LVGL is its whole job;
  • the main thread takes it with try_lock (LVGL_TRY_GUARD()) and skips the overlay frame if the display task holds it. The main loop also pumps SDL input, so it must never park on a lock held by another thread. Doing so would turn any stall inside the display task into a whole-process wedge, dead input, rather than a frozen watch screen. A skipped overlay frame is harmless; the loop runs again about 30 ms later. I hit exactly this while testing a blocking first version: the process stayed alive with the main thread in futex_wait.

The other half of the race is the DisplayApp task itself, which is InfiniTime code. InfiniTimeOrg/InfiniTime#2447 adds a no-op displayapp/LvglGuard.h to InfiniTime and takes LVGL_GUARD() around the two LVGL regions of DisplayApp::Refresh, not across the blocking queue wait. On hardware the macro compiles to nothing, since LVGL has a single owner there. In the sim, this repo's header shadows it via include-path order (the same mechanism the sim already uses for nrf_log.h), so both threads serialize.

Without the InfiniTime side, this change alone is safe but only partial: it serializes the overlay operations against the sim's own off-screen lv_task_handler, not against the display task.

Verification

  • Stress: 300 wake/sleep cycles under ASan, zero reports, sim alive. Unfixed, it crashes at cycle 4.
  • This branch compiles standalone against the vendored InfiniTime submodule (cmake && make), no new warnings.
  • Normal interactive use, and alarms and notifications firing from sleep, are unaffected.

faisal-shah and others added 2 commits July 14, 2026 02:48
LVGL is not thread-safe, but the simulator drives it from two threads:
the DisplayApp task, and the SDL main thread whose refresh_screen()
creates/deletes the "Screen is OFF" overlay and runs lv_task_handler
while the display task sleeps. The wake/sleep handoff between them
corrupts the LVGL heap.

AddressSanitizer shows a heap-use-after-free: main-thread
lv_obj_del(screen_off_bg) racing the display task's lv_task_handler
(alloc/free in refresh_screen on T0, use in _lv_disp_refr_task on the
displayapp thread). A wake/sleep stress loop (right-click toggle, ~600ms
period) crashes an ASan build within ~4 cycles.

This adds sim/displayapp/LvglGuard.h, a recursive mutex behind an
LVGL_GUARD() macro, and takes it in refresh_screen(). The header is
placed so it shadows a matching no-op header in InfiniTime, whose
DisplayApp::Refresh takes the same guard around its two LVGL regions
(companion change, see PR description); on hardware the macro stays a
no-op. With both sides guarded, 300 stress cycles run clean under ASan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeA4vFDz8WACLogQhg53Kd
The main loop also pumps SDL input, so parking it on a lock held by the
DisplayApp task would turn any stall inside that task into a whole-process
wedge (input dead) rather than a frozen watch screen. Observed once during
long-running use: the process was alive with the main thread in futex_wait.

The main thread now try-locks and skips the overlay frame if the display
task holds the lock (it runs again ~30 ms later), so it can never be part
of a deadlock cycle. Mutual exclusion is unchanged: LVGL is still only
touched under the lock, and the 300-cycle ASan stress stays clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeA4vFDz8WACLogQhg53Kd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant