Skip to content

fix(firmware/web): /profiles page reliability + perf for large libraries#716

Open
FR4NKLN wants to merge 9 commits into
jniebuhr:masterfrom
FR4NKLN:feat/profile-page-reliability
Open

fix(firmware/web): /profiles page reliability + perf for large libraries#716
FR4NKLN wants to merge 9 commits into
jniebuhr:masterfrom
FR4NKLN:feat/profile-page-reliability

Conversation

@FR4NKLN

@FR4NKLN FR4NKLN commented May 28, 2026

Copy link
Copy Markdown
Contributor

What & why

Severity: high. Once the favorited library is large — which happens fast,
since every profile is auto-favorited on import — both the /profiles page and
the on-device carousel become unreliable. Several failure modes converge around
30+ profiles: a coex_enable boot-loop, an async_tcp watchdog reboot when
/profiles loads, a long dashboard freeze during the periodic OTA check on
flaky networks, laggy selects after bulk saves, and a spinner-forever state on a
transient WS allocation failure. This PR scales both paths and addresses each.

Firmware

  • Lazy-load the on-device carousel (DefaultUI). Cold-start builds the
    favorited-ID list (cheap strings) but defers loading the Profile structs;
    only a small window around the displayed entry is resident at any time.
    Loading every favorited profile synchronously put 100 KB+ on the internal
    heap, starving the Wi-Fi/BLE coexistence allocator during Bluetooth bring-up
    (coex_enable aborts → boot loop). Lazy-load bounds the resident set
    regardless of library size.

  • O(1) incremental cache mutators for select / favorite / unfavorite / save,
    replacing a blanket invalidation that re-read every favorited profile from
    SPIFFS on the profile-loop task after each change.

  • Newly-favorited profiles keep their configured order. onProfileFavorited
    inserts at the carousel index derived from getFavoritedProfiles() (already
    profileOrder-sorted), discounting the selected entry pinned at index 0,
    instead of appending at the tail — so favoriting no longer reorders the
    carousel. (Addresses review feedback.)

  • Offload req:profiles:list to a dedicated worker task (WebUIPlugin).
    Building a large list on the AsyncTCP task stalled the WebSocket and tripped
    the async_tcp task watchdog; it now runs on its own FreeRTOS task fed by a
    queue, so AsyncTCP returns immediately.

  • Null-check ws.makeBuffer() at all four call sites — the buffer allocates
    from internal heap and can fail under fragmentation; results were previously
    dereferenced unconditionally.

  • Run the OTA update check on a one-shot task so a slow HTTPS check can't
    block the main loop and stall the status broadcast.

Web

  • /profiles load-error handling. The list request is wrapped in try/catch;
    on failure the page shows an error alert with a Retry button instead of a
    permanent spinner, and non-array responses are handled defensively.

Test plan

  • Boots cleanly on current master with a large auto-favorited library — no
    coex_enable abort; Wi-Fi, controller link (protocol match), and webserver
    all come up
  • Newly-favorited profile lands at its profileOrder slot in the carousel,
    not the tail
  • /profiles loads without an infinite spinner; a timeout / non-2xx surfaces
    the Retry banner
  • req:profiles:list build runs off the AsyncTCP task (worker queue) — no
    async_tcp watchdog reboot
  • Dashboard stays responsive while a background OTA check is slow
  • Rapid select / favorite / unfavorite is snappy (no O(N) SPIFFS reload per
    event)

Notes

Summary by CodeRabbit

  • Performance

    • Profile list and OTA checking now run off the main thread/task, improving responsiveness.
    • Profile cache switched to lazy loading with a bounded in-memory window for reduced memory use.
  • Improvements

    • Added error messaging and a retry button when profile list loading fails.
    • WebSocket responses are now hardened to avoid crashes when message buffers cannot be allocated.

FR4NKLN added 4 commits May 27, 2026 20:56
…ousel

Two coupled changes to DefaultUI's favorited-profile handling that fix
post-bulk-import select lag AND the coex_enable boot loop at 50+ profiles.

Incremental mutators replace per-event reloadProfiles(). The four
profiles:profile:* handlers previously each set profileLoaded=0 and
forced profileLoopTask to re-read every favorited profile from SPIFFS
on its next tick — O(N) SPIFFS opens per change, ~1.5-4 s of contention
at 50 favorites. New onProfileSelected / onProfileFavorited /
onProfileUnfavorited / onProfileSaved touch only the affected entry,
O(1) per change. The cold-start full-reload path is preserved as a
safety net when the cache hasn't completed its initial build yet.

Lazy-load carousel decouples cache size from favorite count.
favoritedProfiles was a std::vector<Profile> with the default
(internal-heap) allocator, populated for every favorited entry at boot.
At 50+ Pro profiles this consumed ~100 KB of the ~320 KB internal RAM
budget — enough to starve the WiFi/BLE coexistence allocator that
coex_enable needs during NimBLEDevice::init after WiFi connect, causing
a boot loop with the backtrace:

  coex_core_enable -> coex_enable -> esp_bt_controller_enable
    -> NimBLEDevice::init -> Controller::setupBluetooth

favoritedProfileIds stays the full list (cheap — just Strings). The
parallel favoritedProfiles vector now holds a warm window of
PROFILE_CACHE_RADIUS=1 around currentProfileIdx; entries outside it are
default-constructed placeholders (no String/phases heap). onNextProfile
and onPreviousProfile synchronously load the new edge so the LVGL
effect renders populated data on the next frame; loopProfiles
maintains the window every 25 ms thereafter and evicts entries that
drift outside it. Internal-heap usage is now O(cache window), not
O(favorites).
…dProfile doc

Three firmware defenses and one frontend resilience change that together
close the failure modes PR jniebuhr#710 set out to address but whose
implementations did not actually land in the merged diff.

ws.makeBuffer() null-check at three call sites (handleProfileRequest +
two req:history paths). PR jniebuhr#710's description claimed this guard
shipped; verifying against origin/master, it did not. Without it the
next line null-derefs when internal heap can't satisfy a 25-50 KB
buffer allocation against post-broadcast fragmentation — crashes the
WS task and disconnects every client.

Per-client status-broadcast backlog guard. Also documented in PR jniebuhr#710
but not implemented — only setCloseClientOnQueueFull(false) landed. The
guard skips clients whose per-client send queue is >= 8 deep, so 500ms
status ticks no longer pile up behind a larger in-flight response and
overflow the queue.

PSRAM-back the per-call JsonDocument in ProfileManager::loadProfile.
Each call previously allocated a fresh 6-12 KB node pool from the
~320 KB internal heap and released it on scope exit; for
req:profiles:list that's N round-trips through internal heap per
request, contributing to the fragmentation jniebuhr#710 set out to address.
ESP32-S3 boards have 8 MB PSRAM otherwise idle.

Frontend: try/catch + Array.isArray() guard around loadProfiles() in
ProfileList/index.jsx, plus a red error banner with Retry button when
the request fails. Previously a timed-out or undefined response sent
setProfiles(undefined), which then crashed .map() and the
ErrorBoundary swallowed the page — the dreaded infinite spinner.
Building the full profile-list JSON involves N synchronous SPIFFS opens
and ArduinoJson parses. At 50+ Pro profiles that is 5+ seconds of work,
which when run inside the AsyncTCP WS_EVT_DATA callback exceeds the
async_tcp task watchdog and reboots the device. Verified via serial:

  E (75555) task_wdt: Task watchdog got triggered.
  E (75555) task_wdt:  - async_tcp (CPU 1)

Adds a ProfileListJob{clientId, rid, minimal} struct and a depth-4
FreeRTOS queue. handleProfileRequest for req:profiles:list now
allocates a job, xQueueSend's it, and returns immediately — AsyncTCP
unblocks in microseconds.

A dedicated WebUI::profileList task (core 0, priority 1, 6 KB stack)
drains the queue and runs buildAndSendProfileList, which performs the
SPIFFS loop and JSON build off the AsyncTCP critical path and sends
the response via ws.text(clientId, buffer). AsyncWebSocket's send
methods are queue-safe across tasks per its design.

A vTaskDelay(1) yield between SPIFFS reads is preserved — even though
the worker is off AsyncTCP, the SPIFFS mutex is still shared with the
static-file handler and the on-device profileLoopTask, and yielding
lets them interleave.

All other req:profiles:* types (load, save, select, favorite,
unfavorite, reorder) stay inline in handleProfileRequest — they're
single-file or metadata-only operations well below the watchdog
threshold.
GitHubOTA::checkForUpdates() does synchronous HTTPS to github.com. On
a flaky network (DNS retry / SSL handshake stall) HTTPClient can hold
for 30+ seconds. That call ran on WebUIPlugin::loop() on the Arduino
main loop; the 500 ms status broadcast and every other plugin loop
sat behind it for the duration, making the dashboard appear dead
even though AsyncTCP itself was fine.

Adds volatile bool otaCheckInProgress and a static otaCheckTask
function. WebUIPlugin::loop() no longer calls checkForUpdates()
directly — when the 5-minute interval fires, it spawns a one-shot
8 KB-stack task and returns. The task does the HTTPS, fires the
ota:update:status event, calls updateOTAStatus, clears the
in-progress flag, and vTaskDelete(NULL)s itself.

otaCheckInProgress prevents overlapping spawns when a previous check
is still hung on the network past the interval.
@cla-bot

cla-bot Bot commented May 28, 2026

Copy link
Copy Markdown

We require contributors to sign our Contributor License Agreement, and we don't have yours on file. In order for us to review and merge your code, please contact @jniebuhr (mdwasp) on Discord to get yourself added.

@coderabbitai

coderabbitai Bot commented May 28, 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d3e86d7a-2053-4f86-a255-40b303db027f

📥 Commits

Reviewing files that changed from the base of the PR and between 7e2358b and f28d6b8.

📒 Files selected for processing (1)
  • src/display/ui/default/DefaultUI.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/display/ui/default/DefaultUI.cpp

📝 Walkthrough

Walkthrough

This PR offloads profile-list generation and OTA checks to FreeRTOS worker tasks, implements a bounded lazy cache for favorited profiles in the UI with incremental event handlers, guards WebSocket buffer allocation against null, and adds frontend error tracking with retry.

Changes

Profile and WebUI improvements

Layer / File(s) Summary
WebUIPlugin async infrastructure
src/display/plugins/WebUIPlugin.h, src/display/plugins/WebUIPlugin.cpp
ProfileListJob struct and FreeRTOS queue/task handles are declared; profileListQueue and a core-0 pinned worker task are created in setup(); OTA checks are scheduled as a one-shot otaCheckTask from loop() when the interval elapses and guarded by otaCheckInProgress.
WebSocket request guards and worker implementations
src/display/plugins/WebUIPlugin.cpp
WebSocket handlers now check ws.makeBuffer() before serializing/sending for history and profile responses; req:profiles:list is enqueued to profileListQueue (drops logged on enqueue failure); added otaCheckTask, profileListWorkerTask, and buildAndSendProfileList() which yield between SPIFFS reads and only send when buffer allocation succeeds.
DefaultUI lazy favorited-profile cache
src/display/ui/default/DefaultUI.h, src/display/ui/default/DefaultUI.cpp
Adds PROFILE_CACHE_RADIUS, ensureProfileLoaded, evictProfilesOutsideWindow, and incremental handlers (onProfileSelected, onProfileFavorited, onProfileUnfavorited, onProfileSaved); loopProfiles() initializes placeholder slots and maintains a warm window; navigation preloads adjacent slots synchronously.
Frontend ProfileList error handling and retry
web/src/pages/ProfileList/index.jsx
Adds loadError state; loadProfiles clears prior errors, validates response.profiles shape, captures failures into loadError, resets profiles to [], and ensures loading is set via finally; UI shows an alert with a Retry button.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • jniebuhr/gaggimate#710: Modifies WebSocket response construction and profile-list handling in WebUIPlugin.cpp; this PR's async worker infrastructure and buffer guards build directly on those same response paths.

Poem

🐰 Profiles now lazy-load and cache so keen,
Workers off-duty keep the main loop clean,
Buffers are guarded from null's cruel poke,
Frontend retries when lists go broke,
OTA and list tasks hop light on the heap.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: reliability improvements and performance optimization for the /profiles page when handling large profile libraries, which aligns with the comprehensive refactoring across firmware (WebUIPlugin lazy loading, OTA offloading, null checks) and web (error handling).
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

Per SonarQube `javascript:S6582`. The new form is equivalent in behavior
(falls back to "Request failed" if `err` is undefined or `err.message`
is falsy) and more concise.

Remaining SQ flags on this PR are FreeRTOS-mandated patterns (`void *`
task argument signatures and `new`/`delete` for heap-allocated job
structs passed through `xQueueSend`); refactoring those would obscure
intent without meaningfully improving safety.
@cla-bot

cla-bot Bot commented May 28, 2026

Copy link
Copy Markdown

We require contributors to sign our Contributor License Agreement, and we don't have yours on file. In order for us to review and merge your code, please contact @jniebuhr (mdwasp) on Discord to get yourself added.

@FR4NKLN

FR4NKLN commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@cla-bot check

@cla-bot cla-bot Bot added the cla-signed label May 29, 2026
@cla-bot

cla-bot Bot commented May 29, 2026

Copy link
Copy Markdown

The cla-bot has been summoned, and re-checked this pull request!

plugin->buildAndSendProfileList(*job);
delete job;
job = nullptr;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

shouldn't there be some delay? even just a small one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

portMAX_DELAY blocks the task rather than polling — xQueueReceive suspends it
(Blocked state, zero CPU) until a job is posted and FreeRTOS wakes it on the
xQueueSend. So it's not a busy loop — a delay would only add latency before the
list builds. Standard blocking-consumer pattern — so no sleep needed here

Comment thread src/display/ui/default/DefaultUI.cpp Outdated
// the user navigates the carousel to this entry. Avoids a SPIFFS read
// on the event-firing task (usually AsyncTCP) for an entry the user
// may never look at.
favoritedProfileIds.emplace_back(id);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This could change the order of profiles on the screen

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed. onProfileFavorited no longer appends at the tail; it derives
the insert index from the new favorite's position in getFavoritedProfiles()
(which is profileOrder sorted) — discounting the selected profile pinned at index
0 — so the entry follows the configured order instead of jumping to the end.
Verified on-device: with a profile placed at profileOrder index 2 it inserted at
carousel index 2 rather than the tail (index 32 of 33). Pushed as 7e2358b.

FR4NKLN added a commit to FR4NKLN/gaggimate that referenced this pull request May 29, 2026
FR4NKLN added 3 commits May 31, 2026 14:41
The per-client send-queue backlog skip in the status broadcast is being
handled separately as part of a broadcast-layer refactor (extracted
broadcast method + shared-buffer textAll). Revert the status loop back
to ws.textAll(statusDoc.as<String>()) so this PR stays focused on the
profile-page reliability fixes. The makeBuffer null-checks and the
PSRAM-backed loadProfile from the same defenses change are retained.
…el index

onProfileFavorited appended the new favorite at the tail of the carousel, so
a freshly-favorited profile jumped to the end instead of slotting into its
configured profileOrder position. Derive the carousel index from the profile's
position in getFavoritedProfiles() (already profileOrder-sorted), discounting
the selected profile pinned at index 0. The Profile struct stays a lazy
placeholder loaded on demand, so there is still no SPIFFS read on the
event-firing task.
PSRAM-backing of ProfileManager::loadProfile's JsonDocument is now landed
upstream in jniebuhr#723 (GM-7). Revert our copy so this PR no longer touches
ProfileManager.cpp and merges cleanly onto current master.

@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: 2

🤖 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/display/ui/default/DefaultUI.cpp`:
- Around line 412-423: The code calls favoritedProfileIds.front() without
checking emptiness (used in the block computing selectedId and insertAt), which
can UB if onProfileUnfavorited() left favoritedProfileIds empty; fix by
early-checking favoritedProfileIds.empty() before using front() and handling
that case by rebuilding the carousel state or resetting selection (e.g., call
the routine that rebuilds the favorites carousel or clear selection and set
insertAt = 0), otherwise proceed with the existing logic that computes
selectedId, selectedBefore, sortedPos, sorted, and insertAt; ensure the
empty-case avoids using front() and inserts id correctly.
- Around line 400-424: DefaultUI::onProfileFavorited currently calls
favoritedProfileIds.front() without checking empty and the
favoritedProfileIds/favoritedProfiles vectors are mutated concurrently by
onProfileUnfavorited and loopProfiles (which runs in another FreeRTOS task) via
PluginManager::trigger, so add synchronization and a safety check: introduce a
mutex (e.g., std::mutex favoritedMutex) protecting both favoritedProfileIds and
favoritedProfiles, wrap all accesses/mutations in DefaultUI::onProfileFavorited,
DefaultUI::onProfileUnfavorited and DefaultUI::loopProfiles with a
std::lock_guard on that mutex, and before using favoritedProfileIds.front()
ensure favoritedProfileIds.empty() is false (handle empty by inserting at 0).
Ensure locks are held consistently and briefly to avoid deadlocks and that
insert/erase operations use the guarded vectors only.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0899ab9c-ab5c-4235-b204-56e73494b4c3

📥 Commits

Reviewing files that changed from the base of the PR and between b792133 and 7e2358b.

📒 Files selected for processing (1)
  • src/display/ui/default/DefaultUI.cpp

Comment thread src/display/ui/default/DefaultUI.cpp
Comment thread src/display/ui/default/DefaultUI.cpp
onProfileUnfavorited can erase the last entry, leaving favoritedProfileIds
empty. The ordering fix's front() call would then deref an empty vector. Add
favoritedProfileIds.empty() to the early guard so an empty cache defers to the
cold-start rebuild (same recovery path as the !profileLoaded case).
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants