loop() and the async web server task shared one global Preferences. A begin() from one task failed while the other had a namespace open, and the clear() or remove() after it then hit the other task's namespace: saving the radio settings while a command committed its rolling code could erase every rolling code. Each function now opens NVS with a local Preferences. NVS serializes access internally and the destructor closes the handle, so no lock is added (see docs/adr/0001). No new tests: nothing here builds natively, and deleting the global makes any missed use a compile error. The read-then-commit races on rolling codes across tasks remain and are tracked in docs/KNOWN_ISSUES.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6.3 KiB
Known Issues
Concurrent mutation during chunked /controller response
Status: open
Introduced: 2026-04-19, alongside the chunked-response conversion of /controller (see ControllerChunker in src/Web.cpp and the original crash report about cbuf::resize aborting async_tcp).
Summary
The chunked streaming of /controller lazily reads somfy.rooms, somfy.shades, somfy.groups, and somfy.repeaters across many async_tcp callback invocations as the client's TCP send window opens. If another task mutates those arrays mid-stream, the rendered JSON can be internally inconsistent.
Why this is strictly worse than the pre-chunked code
The old handleController ran the serializers synchronously inside a single request-handler invocation, so the full JSON was built in memory before any bytes were sent. Window of exposure: a few milliseconds.
The new ControllerChunker reads state as the response drains. On a slow link or under backpressure, that window is hundreds of ms to seconds.
Concrete failure modes
- A shade is deleted mid-stream (e.g. via
/deleteShade) — it may appear in theshadesarray but be missing from a later group'slinkedShades, or vice versa. lastRollingCode/ position fields change between theshadespass and a later group'slinkedShadespass — the client sees two values for the same shade in one document.- A group's
linkedShadeslist is mutated while the chunker iterates insideS_GROUPS— an entry is skipped or emitted twice.
Fix options (pick one later)
- (a) Document and accept. In practice users rarely mutate shades while the config UI is loading. Zero code change.
- (b) FreeRTOS mutex around
somfyreads/writes. Acquire for the full duration of the chunked response and in every mutating path (RF RX, MQTT, web handlers). Cleanest but wide-reaching. - (c) Up-front snapshot. At handler start, copy the subset of
somfystate the response will serialize into theControllerChunker. Defeats part of the memory benefit — a full snapshot of shades + groups is close in size to the old growing cbuf. Could be reduced by snapshotting only minimal fields (IDs, names, rolling codes) and reading the rest live.
Related
- Same exposure exists in any other endpoint converted to chunked responses next (
/discovery,/shades). Resolve this issue before expanding the pattern.
Silent truncation of large websocket events
Status: open
Location: JsonSockEvent in src/WResp.cpp, buffer defined at src/Sockets.cpp:45-46 as g_response[MAX_SOCK_RESPONSE] = 2048 bytes.
Summary
Socket events are built into a fixed 2 KB static buffer. On overflow, JsonSockEvent::_safecat logs an error and returns without appending — the event is sent truncated, producing malformed socket.io text that the client drops silently.
Unlike the /controller HTTP crash, this path does not abort — there is no growing cbuf and no new[] on the send path. Per-client frame allocations inside AsyncWebSocket are bounded by the 2 KB buffer size and have their own overflow guard (queue drop / client disconnect).
Concrete failure modes
- A single event serializing a fully-populated shade (~1.3–1.5 KB for a shade with all
SOMFY_MAX_LINKED_REMOTES= 7 populated) gets close to the 2 KB limit. Any additional fields or long names push it over and the JSON is silently cut mid-value. - Any event that loops over a collection (e.g. frequency-scan results, batch emits in
Somfy.cpparound lines 1870–1975) can exceed 2 KB depending on size, with no indication to the client beyond the ESP-sideESP_LOGEline.
Fix options (pick one later)
- (a) Fail loud. Keep truncation but emit a sentinel/error frame so the client knows the event was lost, instead of sending a malformed one.
- (b) Split large events across frames. Use the socket.io ack/chunk pattern to send an event in multiple frames when it wouldn't fit. Requires matching client-side reassembly.
- (c) Raise
MAX_SOCK_RESPONSE. Cheapest, but just pushes the limit — doesn't eliminate the failure mode.
Related
- Not the same code path as the
/controllercrash. Solve independently. - Worth grepping for
JsonSockEventusages that iterate collections (see references inSomfy.cpp,ESPNetwork.cpp,GitOTA.cpp) to identify the most at-risk events.
Rolling codes written from two tasks
Status: open
Location: SomfyRemote::sendCommandAfter and setRollingCode in src/Somfy.cpp, the /setRollingCode handlers in src/Web.cpp, and ShadeConfigFile::readShadeRecord/readGroupRecord in src/ConfigFile.cpp.
Summary
Sending a command reads the next rolling code, queues the frame, then commits the code (peekNextRollingCode → submitTx → commitRollingCode), all on loop(). /setRollingCode and a shades restore change the same remote's lastRollingCode and its ShadeCodes NVS key from the async web server task, with no lock against that sequence. Each NVS write is safe on its own, since every function opens its own handle (see ADR-0001), but the read-then-commit sequence is not.
Concrete failure modes
/setRollingCodelands between the peek and the commit of a command. The command's commit overwrites the code just set, so the set has no effect.- A restore reads
ShadeCodeswhile a command commits a newer code. The restored shade can come back with the older code, and the motor rejects its frames until the code passes the newer one.
Fix options (pick one later)
- (a) Run these handlers on
loop(). Queue/setRollingCodeand restore through the web command queue like shade commands, soloop()is the only task that touches a remote's rolling code. Part of makingloop()the only owner of the shade model. - (b) A rolling-code lock. Hold a mutex across peek → queue → commit, and in
setRollingCodeand restore. Narrower, but it adds a lock to the command path.
Related
- Same root cause as Concurrent mutation during chunked
/controllerresponse: the web server task changes the shade model thatloop()owns.