Skip to content

MUI: stop holding the SPI bus across the whole UI cycle - #11278

Merged
thebentern merged 3 commits into
developfrom
mui-spilock-flush-yield
Jul 31, 2026
Merged

MUI: stop holding the SPI bus across the whole UI cycle#11278
thebentern merged 3 commits into
developfrom
mui-spilock-flush-yield

Conversation

@thebentern

@thebentern thebentern commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Implements Option B from @mverch67's architectural analysis. Paired with meshtastic/device-ui#356.

Problem

tft_task_handler held spiLock for the entire LVGL cycle. Most of that cycle is timer work and rendering into the draw buffer, which issues no SPI at all — but on boards where the TFT, SD card and LoRa radio share one bus (T-Deck: GPIO 40/41), every radio operation on the main loop still waited it out. That's tens to hundreds of milliseconds whenever the UI animates, felt as mesh RX/TX latency.

Change

device-ui now takes the lock around its own transfers, so the coarse hold here goes away entirely and the bus is contended only during real traffic.

The firmware lends spiLock through a reentrant adapter. device-ui nests its guards — SdFsCard::usedBytes() calls cardSize() and freeBytes(), each of which takes the lock — while spiLock is a plain binary semaphore (xSemaphoreCreateBinary) that would self-deadlock on the second take. The adapter tracks the owning task and only touches the underlying lock on the outermost acquire.

Requires the device-ui pin bump included here; #356 needs to land first and then this pin moves to the merged master hash.

Testing

T-Deck on hardware: flush, touch, panel init, SD detect, powersave sleep and wake all exercised; LoRa RX decoding under a live UI (received and displayed a text message mid-run); no deadlocks and no watchdog resets over sustained monitoring — the latter being the signal a missed nesting case would produce. Builds t-deck-tft and seeed-sensecap-indicator-tft.

SD read/write paths are build-verified only — no card in the slot on my unit.

Summary by CodeRabbit

  • Bug Fixes
    • Improved display rendering reliability by refining SPI bus locking so the display task no longer risks self-deadlocks during screen updates.
    • Reduced lock contention during redraws, improving responsiveness while the display task runs.
  • Chores
    • Updated the pinned version of the UI library used by relevant builds to a newer archived revision.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

TFT setup adds a reentrant SPI lock, delegates task-handler locking to device-ui, passes the lock through both display creation paths, and updates the pinned device-ui dependency revision.

Changes

TFT bus coordination

Layer / File(s) Summary
Device screen SPI lock wiring
src/graphics/tftSetup.cpp
Adds reentrant SPI lock behavior, removes manual locking from tft_task_handler(), and passes the lock to both display setup paths.
Device-ui dependency revision
platformio.ini
Updates the pinned device-ui archive revision for the device-ui_base environment.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: needs-review, dependencies

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately describes the main change: reducing coarse SPI lock holding during the UI cycle.
Description check ✅ Passed The description covers the problem, change, dependency, and testing details, matching the template well despite missing attestations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mui-spilock-flush-yield

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/graphics/tftSetup.cpp (1)

17-22: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce these explanatory comment blocks.

The new comments substantially exceed the project’s one- or two-line limit. Keep only the non-obvious rationale; the function name and registration site cover the rest.

Proposed simplification
-// device-ui configures LVGL through its private build flags (-Iinclude
-// -DLV_CONF_INCLUDE_SIMPLE in its library.json), which do not propagate to firmware
-// translation units. Point LVGL at that same lv_conf.h explicitly before including any
-// LVGL header here: parsing them under LVGL's default config instead produces struct
-// and inline-function definitions that mismatch the compiled library (an ODR
-// violation), which corrupts lv_init() at boot.
+// Use the same LVGL configuration as the device-ui library.
 
-/**
- * Give waiting SPI users a window before each chunk of pixels is pushed.
- *
- * ...
- */
+// Let waiters acquire the shared SPI bus between flush chunks.

As per coding guidelines, “Keep code comments minimal—one or two lines maximum—and comment only when the reason is not obvious.”

Also applies to: 40-58, 166-168

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graphics/tftSetup.cpp` around lines 17 - 22, Shorten the explanatory
comments in the LVGL setup and the additional locations at lines 40–58 and
166–168 to no more than one or two lines each. Retain only the non-obvious
rationale, specifically the need to use the matching LVGL configuration, and
remove details already evident from the function names or registration code.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/graphics/tftSetup.cpp`:
- Around line 17-22: Shorten the explanatory comments in the LVGL setup and the
additional locations at lines 40–58 and 166–168 to no more than one or two lines
each. Retain only the non-obvious rationale, specifically the need to use the
matching LVGL configuration, and remove details already evident from the
function names or registration code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 78f95766-d180-4ad4-8262-5fc27aa5dd7c

📥 Commits

Reviewing files that changed from the base of the PR and between b4ff1df and 194f40a.

📒 Files selected for processing (1)
  • src/graphics/tftSetup.cpp

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Flash this PR in the Web Flasher

firmware commit boards expires

Warning

This is an automated, unreviewed CI test build. Back up your device configuration
before flashing, and only flash devices you are able to recover.

Supported boards built by this PR (31)
Device Board Platform
Crowpanel Adv 3.5 TFT elecrow-adv-35-tft esp32-s3
Heltec HT62 heltec-ht62-esp32c3-sx1262 esp32-c3
Heltec Mesh Node 096 heltec-mesh-node-t096 nrf52840
Heltec Mesh Node T1 heltec-mesh-node-t1 nrf52840
Heltec Mesh Node T114 heltec-mesh-node-t114 nrf52840
Heltec V3 heltec-v3 esp32-s3
Heltec V4 heltec-v4 esp32-s3
Meshnology W10 meshnology_w10 esp32-s3
Meshnology W12 meshnology_w12 esp32-s3
Raspberry Pi Pico pico rp2040
Raspberry Pi Pico W picow rp2040
RAK WisMesh Pocket V3 rak_wismesh_pocket nrf52840
RAK WisMesh Pod rak_wismesh_pod nrf52840
RAK WisMesh Repeater Mini V2 rak_wismesh_repeater_mini nrf52840
RAK WisMesh Tag rak_wismeshtag nrf52840
RAK WisBlock 11200 rak11200 esp32
RAK WisBlock 11310 rak11310 rp2040
RAK3312 rak3312 esp32-s3
RAK WisBlock 4631 rak4631 nrf52840
Seeed SenseCAP Mesh-Tracker-X1 seeed_mesh_tracker_X1 nrf52840
Seeed Wio Tracker L1 seeed_wio_tracker_L1 nrf52840
Seeed Xiao NRF52840 Kit seeed_xiao_nrf52840_kit nrf52840
Seeed Xiao ESP32-S3 seeed-xiao-s3 esp32-s3
Station G2 station-g2 esp32-s3
Station G3 station-g3 esp32-s3
LILYGO T-Deck t-deck-tft esp32-s3
LILYGO T-Echo t-echo nrf52840
LILYGO T-Echo Plus t-echo-plus nrf52840
LILYGO T-Impulse Plus t-impulse-plus nrf52840
LilyGo T3-C6 tlora-c6 esp32-c6
Seeed SenseCAP T1000-E tracker-t1000-e nrf52840

Build artifacts expire on 2026-08-29. Updated for 3c1b5da.

@thebentern thebentern added the bugfix Pull request that fixes bugs label Jul 29, 2026
@thebentern
thebentern added this pull request to the merge queue Jul 29, 2026

@mverch67 mverch67 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From software architecture point of view it would be better to provide a callback std::function to DeviceGUI (either as parameter to init() or separate method) that is called by the lvgl library in case of LV_EVENT_FLUSH_START.
This way you can keep all lvgl related code out of the firmware and achieve exactly the same behavior.

Edit: providing the possibility for device-ui to acquire a lock is also required when accessing further SPI devices, not only TFT display as instructed by LVGL but also SD card access.

@thebentern
thebentern removed this pull request from the merge queue due to a manual request Jul 29, 2026
@mverch67

mverch67 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Architectural Analysis: SPI Lock Synchronization

Current Problem

The tft_task_handler holds spiLock for the entire task_handler() duration, which encompasses:

  1. LVGL timer/event processing (CPU-only, no SPI)
  2. LVGL render to framebuffer (CPU-only, no SPI)
  3. Display flush (actual SPI transfer to TFT) — lock legitimately needed
  4. Potentially SD card I/O from within device-ui — lock legitimately needed

Phases 1 and 2 can account for the majority of the cycle time, unnecessarily blocking the LoRa thread from the SPI bus for tens to hundreds of milliseconds.


Option A: Keep current firmware lock + add yield callback

The most natural hook is LV_EVENT_FLUSH_START / LV_EVENT_FLUSH_READY, or a yield call inserted between lv_timer_handler() invocations.

Why it's insufficient:

  • LV_EVENT_FLUSH_START fires immediately before the SPI transfer — releasing the lock there and reacquiring gives only a microsecond-scale window, not meaningful relief.
  • The genuinely idle periods (phases 1–2 above) are inside the lv_timer_handler() call stack and can't be interrupted without async-DMA flush support on the display driver side.
  • SD card I/O is entirely outside the LVGL flush cycle. A flush-based yield callback provides zero benefit there. device-ui accessing the SPI SD card during task_handler() remains fully opaque to the yield mechanism.
  • You'd be adding callback plumbing complexity for marginal gain in the display-only case and no gain for SD.

Option B: Explicit lock() / unlock() / optional yield() in device-ui

Invert ownership: instead of the firmware holding the lock around the entire UI cycle, device-ui calls back into a ISpiLock interface only around actual SPI operations.

Architecture sketch: image

Changes required in device-ui:

Location Change
DeviceGUI / DisplayDriver Accept and store ISpiLock*
Display flush callback Wrap with lock() / unlock()
SD card driver (any SPI I/O) Wrap each operation with lock() / unlock()
tftSetup.cpp Remove lock from task loop; pass ISpiLock to DeviceScreen::create()

Scope assessment: The changes are localized to driver-level call sites — not pervasive. The ISpiLock interface is 2–3 virtual methods. The flush callback wrapping is a single location per display driver. SD card wrapping is however many spi_transfer call sites exist in device-ui.


Does the gain justify the changes?

Yes, clearly — particularly in Meshtastic's case:

  • LoRa receive latency is the primary concern. The LoRa interrupt handler needs SPI access to read the received packet. Every millisecond the SPI bus is blocked by LVGL rendering increases the risk of buffer overflow in the radio FIFO. A 240×320 TFT full redraw can hold the bus for 30–80ms.
  • The firmware/task separation is already established. The SPI bus sharing contract is already encoded in spiLock. Option B formalizes that contract at the correct level of granularity.
  • yield() becomes an optional additive optimization on top of Option B — useful if you later add DMA-based async flushing (the display driver signals flush complete asynchronously, device-ui can release the lock while DMA runs). It's not a prerequisite.

Recommendation

Implement Option B. Option A is a partial workaround that doesn't address SD card I/O and provides negligible benefit for the display case without async DMA. The ISpiLock interface is small, the changes in device-ui are localized, and the architectural result is correct: the SPI bus is contended only during actual transfers, giving LoRa the bus during the much longer LVGL rendering window.

@thebentern

Copy link
Copy Markdown
Contributor Author

Good call — done in meshtastic/device-ui#356, and this PR now just hands the lock over via DeviceScreen::setBusLock(). No LVGL in the firmware at all.

That turned out to matter for more than layering: device-ui configures LVGL through private library.json flags that don't reach firmware translation units, so the firmware-side #include <lvgl.h> was silently parsing LVGL under its default config. The struct/inline mismatch against the compiled library corrupted lv_init() and crashed at boot on hardware — took a LV_CONF_PATH workaround to get around, which your approach deletes entirely.

On the edit re: acquiring the lock for SD access — I exposed lock alongside unlock so device-ui can guard its own regions, but there's a constraint worth flagging: spiLock is a plain binary semaphore (xSemaphoreCreateBinary), not recursive. So if device-ui acquires it while tft_task_handler still holds it across the cycle, that self-deadlocks on portMAX_DELAY. Letting device-ui guard SD and panel access itself means dropping the firmware's coarse hold first (or making spiLock recursive) — happy to do that as a follow-up if you want to point me at all the SPI touch points on your side.

Note this PR now needs #356 to land first; the pin here points at the branch commit and I'll bump it to the merged master hash.

Tested on T-Deck: hook installs, boots clean, LoRa RX under an animating UI, no crashes.

@thebentern
thebentern force-pushed the mui-spilock-flush-yield branch from c21f7ee to e477e2e Compare July 29, 2026 11:51
@thebentern thebentern changed the title MUI: yield the shared SPI bus between LVGL flush chunks MUI: stop holding the SPI bus across the whole UI cycle Jul 29, 2026
@thebentern

Copy link
Copy Markdown
Contributor Author

Agreed on Option B — rebuilt both PRs that way. The yield version is gone; the firmware no longer holds the lock at all, and device-ui takes it around actual transfers (meshtastic/device-ui#356).

You were right about SD being the deciding factor. One correction on the display case though, since it affects how you weigh the interim: the yield wasn't microsecond-scale. The tft task is pinned to core 0 and loopTask runs on core 1, so giving the semaphore lets a blocked loopTask acquire it and run its whole transaction on its own core while the tft task's re-take blocks behind it. Moot now, but the real reason Option A was insufficient is the one you gave second — the lock stayed held through the CPU-only render phase, and SD was untouched.

Two things from implementing it that are worth your eyes:

The lock holder has to be static, not a member. Your table had DeviceGUI/DisplayDriver storing an ISpiLock*, but that can't reach two of the sites: the LVGL flush and touch callbacks are static with no this, and the SD layer (sdCard, SDFs in SdCard.cpp) is file-scope globals with no route to a DeviceGUI at all. I used a static holder with an RAII guard. If you want DeviceScreen::create() to take the pointer as the public spelling I'll add that on top, but something globally reachable is needed underneath it.

Reentrancy is mandatory, and that's on the host. SdFsCard::usedBytes() calls cardSize() and freeBytes(), each of which takes the lock. Since spiLock is a binary semaphore, the firmware side wraps it in a task-tracking adapter rather than pushing depth-counting into device-ui. The ISpiLock header documents the requirement for other hosts.

Also: nothing is held across view->sleep() (that suspends the whole SoC), and calibrateTouch() does hold the bus for as long as the user takes to tap — acceptable for a modal once-per-device flow, but flagging it rather than hiding it.

Sites guarded: flush, SPI touch reads, panel init/fillScreen, printConfig's readCommand (easy one to miss — it's inside a logging function), calibration, sleep/wake/power-save, and every SD entry point including both lv_fs tile services and TFTView's backup/restore. If you know of bus access I haven't caught, that's the risk area — a missed site is now unsynchronized rather than covered by the old blanket hold.

Tested on T-Deck: flush, touch, init, SD detect, powersave sleep and wake all exercised, LoRa RX decoding under a live UI, no deadlocks or watchdog resets. SD read/write is build-verified only — no card in my slot, so usedBytes()'s nested acquire hasn't run on hardware.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/graphics/tftSetup.cpp (2)

32-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Comment block exceeds the project's minimal-comment guideline.

This 21-line multi-paragraph explanation conflicts with the guideline restricting comments to one or two lines, reserved for non-obvious reasoning. Consider trimming this to a short pointer (e.g., 1-2 lines noting reentrancy is required because device-ui nests lock acquisitions) and moving the detailed rationale to the PR description or a design doc.

As per coding guidelines, "Keep code comments minimal—one or two lines maximum—and comment only when the reason is not obvious; do not restate straightforward code or add multi-paragraph explanatory blocks."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graphics/tftSetup.cpp` around lines 32 - 52, Trim the large comment block
above the SPI lock handling to one or two lines. Retain only the non-obvious
requirement that reentrancy is needed because device-ui can nest lock
acquisitions, and remove the detailed performance, ownership, and implementation
rationale from the source comment.

Source: Coding guidelines


72-82: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider an assert() on the depth invariant before decrementing.

If unlock() is ever called without a matching lock() (a bug elsewhere), --depth on an already-zero uint32_t wraps to UINT32_MAX, silently masking the bug and leaving the lock permanently held from that thread's perspective. An assert(depth > 0) before the decrement would surface such misuse immediately during development.

As per coding guidelines, "Use assert() for invariants that should never fail."

🛡️ Proposed fix
     void unlock(void) override
     {
 `#ifdef` HAS_FREE_RTOS
+        assert(depth > 0);
         if (--depth == 0) {
             owner = nullptr;
             spiLock->unlock();
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graphics/tftSetup.cpp` around lines 72 - 82, In the HAS_FREE_RTOS branch
of unlock(), validate the depth invariant with assert(depth > 0) before
decrementing depth. Keep the existing owner reset and spiLock->unlock() behavior
unchanged when the resulting depth reaches zero.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/graphics/tftSetup.cpp`:
- Around line 56-82: Update ReentrantSpiLock’s non-HAS_FREE_RTOS lock and unlock
paths to track an owner and recursion depth, matching the existing reentrant
behavior without FreeRTOS task handles. Use a portable owner representation and
ensure owner/depth state is initialized, incremented for nested acquisitions,
and cleared only when the outermost unlock releases spiLock.

---

Nitpick comments:
In `@src/graphics/tftSetup.cpp`:
- Around line 32-52: Trim the large comment block above the SPI lock handling to
one or two lines. Retain only the non-obvious requirement that reentrancy is
needed because device-ui can nest lock acquisitions, and remove the detailed
performance, ownership, and implementation rationale from the source comment.
- Around line 72-82: In the HAS_FREE_RTOS branch of unlock(), validate the depth
invariant with assert(depth > 0) before decrementing depth. Keep the existing
owner reset and spiLock->unlock() behavior unchanged when the resulting depth
reaches zero.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 974a120c-6e6f-44c3-b284-abe8a622220c

📥 Commits

Reviewing files that changed from the base of the PR and between c21f7ee and e477e2e.

📒 Files selected for processing (2)
  • platformio.ini
  • src/graphics/tftSetup.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • platformio.ini

Comment thread src/graphics/tftSetup.cpp
@thebentern
thebentern force-pushed the mui-spilock-flush-yield branch from e477e2e to d581388 Compare July 29, 2026 12:44

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/graphics/tftSetup.cpp (3)

71-77: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard against depth underflow in unlock().

If unlock() is ever called without a matching lock() (e.g. a device-ui guard bug or double-unlock on an error path), --depth wraps uint32_t to UINT32_MAX, silently skipping the release branch. The design happens to self-heal on the next fresh acquisition (since the fast path also checks owner == self), but this mismatch goes completely undetected — exactly the kind of invariant an assert() should catch during development.

As per coding guidelines, "Use assert() for invariants that should never fail."

🛡️ Proposed fix
     void unlock(void) override
     {
+        assert(depth > 0 && owner == currentThread());
         if (--depth == 0) {
             owner = ThreadId();
             spiLock->unlock();
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graphics/tftSetup.cpp` around lines 71 - 77, Guard the depth invariant in
unlock() by asserting that depth is greater than zero before decrementing it.
Keep the existing owner reset and spiLock release behavior when the decremented
depth reaches zero.

Source: Coding guidelines


35-55: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Trim the class doc comment to fit the comment-length guideline.

This 20-line, multi-paragraph explanatory block (history, rationale, and synchronization proof) exceeds the repo's stated comment style. Condense to the essential "why" (reentrancy needed because device-ui guards per-method while spiLock is a plain binary semaphore) and drop the historical narrative/paragraph breaks.

As per coding guidelines, "Keep code comments minimal—one or two lines maximum—and comment only when the reason is not obvious; do not restate straightforward code or add multi-paragraph explanatory blocks."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graphics/tftSetup.cpp` around lines 35 - 55, Trim the class documentation
above the spiLock implementation to one or two lines stating only that
reentrancy is required because device-ui guards individual methods while spiLock
is a plain binary semaphore. Remove the historical context, performance
rationale, and synchronization proof without changing the implementation.

Source: Coding guidelines


59-92: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Verify plain owner/depth reads are safe across ESP32-S3's two cores.

lock()'s fast path reads owner/depth without holding spiLock, while unlock() writes them with only the doc comment's informal reasoning as protection ("only the owner writes... so no additional synchronization is needed"). T-Deck's ESP32-S3 is a genuine dual-core part, and tft_task_handler is pinned to core 0 (xTaskCreatePinnedToCore(..., 0) at line 198) while other callers of the shared reentrantSpiLock/spiLock (e.g. LoRa RX callers per the PR description) likely run on the other core — so this is real cross-core shared state, not just same-core reordering. Per the C++ memory model this is a data race (UB) even if the current owner-check logic tends to fail safe in practice.

Consider making owner and depth std::atomic for defense-in-depth. Note: std::atomic<TaskHandle_t> (a pointer) is fine, but std::atomic<std::thread::id> is not portably guaranteed to compile since the standard does not guarantee std::thread::id is trivially copyable (LWG issue 1277) — verify this compiles for the Portduino/native target before adopting it there, or use a raw integer/pointer-based thread identifier instead of std::thread::id for that branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graphics/tftSetup.cpp` around lines 59 - 92, Make the shared owner and
depth state in the reentrant lock implementation race-free across cores. Update
lock() and unlock() to use atomic-compatible storage and operations for both
fields, while preserving recursive ownership and release behavior; use an
identifier representation that supports portable atomic storage in the
non-FreeRTOS currentThread() branch, and verify the HAS_FREE_RTOS and
Portduino/native builds compile.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/graphics/tftSetup.cpp`:
- Around line 71-77: Guard the depth invariant in unlock() by asserting that
depth is greater than zero before decrementing it. Keep the existing owner reset
and spiLock release behavior when the decremented depth reaches zero.
- Around line 35-55: Trim the class documentation above the spiLock
implementation to one or two lines stating only that reentrancy is required
because device-ui guards individual methods while spiLock is a plain binary
semaphore. Remove the historical context, performance rationale, and
synchronization proof without changing the implementation.
- Around line 59-92: Make the shared owner and depth state in the reentrant lock
implementation race-free across cores. Update lock() and unlock() to use
atomic-compatible storage and operations for both fields, while preserving
recursive ownership and release behavior; use an identifier representation that
supports portable atomic storage in the non-FreeRTOS currentThread() branch, and
verify the HAS_FREE_RTOS and Portduino/native builds compile.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 029c7d35-85d1-4911-9f3b-5075e920e8ac

📥 Commits

Reviewing files that changed from the base of the PR and between e477e2e and d581388.

📒 Files selected for processing (2)
  • platformio.ini
  • src/graphics/tftSetup.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • platformio.ini

@thebentern
thebentern force-pushed the mui-spilock-flush-yield branch from d581388 to 318e86f Compare July 29, 2026 12:56
tft_task_handler held spiLock for the entire LVGL cycle. Most of that
cycle is timer work and rendering into the draw buffer, which issues no
SPI at all - but on boards where the TFT, SD card and LoRa radio share
one bus (T-Deck), every radio operation on the main loop still waited it
out. That is tens to hundreds of milliseconds whenever the UI animates,
felt as mesh RX/TX latency.

device-ui now takes the lock around its own transfers instead
(meshtastic/device-ui#356), so the coarse hold here can go and the bus is
contended only during real traffic.

Lend it spiLock through a reentrant adapter. device-ui nests its guards -
SdFsCard::usedBytes() calls cardSize() and freeBytes(), each of which
takes the lock - while spiLock is a plain binary semaphore that would
self-deadlock on the second take, so track the owning task and only touch
the underlying lock on the outermost acquire.

Requires the device-ui pin bump included here.

Tested on T-Deck: flush, touch, panel init, SD detect, powersave sleep
and wake all exercised; LoRa RX decoding under a live UI, no deadlocks,
no watchdog resets. Also builds seeed-sensecap-indicator-tft.
@thebentern
thebentern force-pushed the mui-spilock-flush-yield branch from 318e86f to 6d0a289 Compare July 29, 2026 13:17
@thebentern
thebentern added this pull request to the merge queue Jul 31, 2026
Merged via the queue into develop with commit e2460b5 Jul 31, 2026
56 checks passed
@caveman99
caveman99 deleted the mui-spilock-flush-yield branch August 26, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Pull request that fixes bugs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants