Skip to content

Commit 15e7dd1

Browse files
authored
Merge pull request #25 from cjkas/scz/split-response
Memory-bounded streaming builder for /controller. Produces the JSON p…
2 parents f51e90b + 6365f16 commit 15e7dd1

4 files changed

Lines changed: 270 additions & 31 deletions

File tree

docs/KNOWN_ISSUES.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Known Issues
2+
3+
## Concurrent mutation during chunked `/controller` response
4+
5+
**Status:** open
6+
**Introduced:** 2026-04-19, alongside the chunked-response conversion of `/controller` (see [`ControllerChunker` in src/Web.cpp](../src/Web.cpp) and the original crash report about `cbuf::resize` aborting `async_tcp`).
7+
8+
### Summary
9+
10+
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.
11+
12+
### Why this is strictly worse than the pre-chunked code
13+
14+
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.
15+
16+
The new `ControllerChunker` reads state as the response drains. On a slow link or under backpressure, that window is hundreds of ms to seconds.
17+
18+
### Concrete failure modes
19+
20+
1. A shade is deleted mid-stream (e.g. via `/deleteShade`) — it may appear in the `shades` array but be missing from a later group's `linkedShades`, or vice versa.
21+
2. `lastRollingCode` / position fields change between the `shades` pass and a later group's `linkedShades` pass — the client sees two values for the same shade in one document.
22+
3. A group's `linkedShades` list is mutated while the chunker iterates inside `S_GROUPS` — an entry is skipped or emitted twice.
23+
24+
### Fix options (pick one later)
25+
26+
- **(a) Document and accept.** In practice users rarely mutate shades while the config UI is loading. Zero code change.
27+
- **(b) FreeRTOS mutex around `somfy` reads/writes.** Acquire for the full duration of the chunked response and in every mutating path (RF RX, MQTT, web handlers). Cleanest but wide-reaching.
28+
- **(c) Up-front snapshot.** At handler start, copy the subset of `somfy` state the response will serialize into the `ControllerChunker`. 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.
29+
30+
### Related
31+
32+
- Same exposure exists in any other endpoint converted to chunked responses next (`/discovery`, `/shades`). Resolve this issue before expanding the pattern.
33+
34+
## Silent truncation of large websocket events
35+
36+
**Status:** open
37+
**Location:** [`JsonSockEvent` in src/WResp.cpp](../src/WResp.cpp), buffer defined at [src/Sockets.cpp:45-46](../src/Sockets.cpp#L45-L46) as `g_response[MAX_SOCK_RESPONSE]` = 2048 bytes.
38+
39+
### Summary
40+
41+
Socket events are built into a fixed 2 KB static buffer. On overflow, [`JsonSockEvent::_safecat`](../src/WResp.cpp) logs an error and returns without appending — the event is sent truncated, producing malformed socket.io text that the client drops silently.
42+
43+
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).
44+
45+
### Concrete failure modes
46+
47+
1. 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.
48+
2. Any event that loops over a collection (e.g. frequency-scan results, batch emits in `Somfy.cpp` around lines 1870–1975) can exceed 2 KB depending on size, with no indication to the client beyond the ESP-side `ESP_LOGE` line.
49+
50+
### Fix options (pick one later)
51+
52+
- **(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.
53+
- **(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.
54+
- **(c) Raise `MAX_SOCK_RESPONSE`.** Cheapest, but just pushes the limit — doesn't eliminate the failure mode.
55+
56+
### Related
57+
58+
- Not the same code path as the `/controller` crash. Solve independently.
59+
- Worth grepping for `JsonSockEvent` usages that iterate collections (see references in `Somfy.cpp`, `ESPNetwork.cpp`, `GitOTA.cpp`) to identify the most at-risk events.

src/WResp.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ void JsonSockEvent::_safecat(const char *val, bool escape) {
3535
else strcat(this->buff, val);
3636
if(escape) strcat(this->buff, "\"");
3737
}
38+
void BufferedJsonFormatter::setBuffer(char *b, size_t sz) {
39+
this->buff = b;
40+
this->buffSize = sz;
41+
this->buff[0] = 0;
42+
this->_nocomma = true;
43+
this->_objects = 0;
44+
this->_arrays = 0;
45+
}
46+
size_t BufferedJsonFormatter::length() const {
47+
return strlen(this->buff);
48+
}
3849
void AsyncJsonResp::beginResponse(AsyncWebServerRequest *request, char *buff, size_t buffSize) {
3950
this->_request = request;
4051
this->buff = buff;

src/WResp.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ class JsonFormatter {
5252
void addElem(const char *name, const char *val);
5353
void addElem(const char* name, uint64_t lval);
5454
};
55+
class BufferedJsonFormatter : public JsonFormatter {
56+
public:
57+
void setBuffer(char *b, size_t sz);
58+
size_t length() const;
59+
};
5560
class AsyncJsonResp : public JsonFormatter {
5661
protected:
5762
void _safecat(const char *val, bool escape = false) override;

src/Web.cpp

Lines changed: 195 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <AsyncTCP.h>
1818
#include <ESPAsyncWebServer.h>
1919
#include <AsyncJson.h>
20+
#include <memory>
2021

2122
extern ConfigSettings settings;
2223
extern SSDPClass SSDP;
@@ -418,6 +419,194 @@ static void serializeGitRelease(GitRelease *rel, JsonFormatter &json) {
418419
json.endObject();
419420
}
420421

422+
// Memory-bounded streaming builder for /controller. Produces the JSON payload
423+
// one small unit at a time into a fixed 3KB buffer, so a chunked HTTP response
424+
// can drain it as the TCP send window allows — no growing cbuf in the async
425+
// response stream.
426+
class ControllerChunker {
427+
public:
428+
enum : uint8_t { S_HEADER, S_ROOMS, S_SHADES, S_GROUPS, S_REPEATERS, S_DONE };
429+
static constexpr uint16_t LS_OPEN = 0xFFFF;
430+
431+
BufferedJsonFormatter fmt;
432+
char unit[3072];
433+
size_t unitLen = 0;
434+
size_t consumed = 0;
435+
uint8_t section = S_HEADER;
436+
uint16_t idx = 0;
437+
uint16_t lsIdx = LS_OPEN;
438+
bool firstInArray = true;
439+
bool firstInLSArray = true;
440+
441+
size_t generate(uint8_t *out, size_t maxLen) {
442+
size_t written = 0;
443+
while(written < maxLen) {
444+
if(consumed < unitLen) {
445+
size_t n = unitLen - consumed;
446+
if(n > maxLen - written) n = maxLen - written;
447+
memcpy(out + written, unit + consumed, n);
448+
consumed += n;
449+
written += n;
450+
} else {
451+
produceNext();
452+
if(unitLen == 0) return written;
453+
}
454+
}
455+
return written;
456+
}
457+
458+
private:
459+
void resetFmt(size_t offset = 0) {
460+
unit[offset] = 0;
461+
fmt.setBuffer(unit + offset, sizeof(unit) - offset);
462+
}
463+
464+
void produceNext() {
465+
consumed = 0;
466+
unitLen = 0;
467+
switch(section) {
468+
case S_HEADER: {
469+
resetFmt();
470+
fmt.beginObject();
471+
fmt.addElem("maxRooms", (uint8_t)SOMFY_MAX_ROOMS);
472+
fmt.addElem("maxShades", (uint8_t)SOMFY_MAX_SHADES);
473+
fmt.addElem("maxGroups", (uint8_t)SOMFY_MAX_GROUPS);
474+
fmt.addElem("maxGroupedShades", (uint8_t)SOMFY_MAX_GROUPED_SHADES);
475+
fmt.addElem("maxLinkedRemotes", (uint8_t)SOMFY_MAX_LINKED_REMOTES);
476+
fmt.addElem("startingAddress", (uint32_t)somfy.startingAddress);
477+
fmt.beginObject("transceiver");
478+
fmt.beginObject("config");
479+
serializeTransceiverConfig(fmt);
480+
fmt.endObject();
481+
fmt.endObject();
482+
fmt.beginObject("version");
483+
serializeGitVersion(fmt);
484+
fmt.endObject();
485+
fmt.beginArray("rooms");
486+
unitLen = strlen(unit);
487+
section = S_ROOMS;
488+
idx = 0;
489+
firstInArray = true;
490+
return;
491+
}
492+
case S_ROOMS: {
493+
while(idx < SOMFY_MAX_ROOMS && somfy.rooms[idx].roomId == 0) idx++;
494+
if(idx >= SOMFY_MAX_ROOMS) {
495+
strcpy(unit, "],\"shades\":[");
496+
unitLen = strlen(unit);
497+
section = S_SHADES;
498+
idx = 0;
499+
firstInArray = true;
500+
return;
501+
}
502+
size_t pos = 0;
503+
if(!firstInArray) unit[pos++] = ',';
504+
resetFmt(pos);
505+
fmt.beginObject();
506+
serializeRoom(&somfy.rooms[idx], fmt);
507+
fmt.endObject();
508+
unitLen = pos + strlen(unit + pos);
509+
idx++;
510+
firstInArray = false;
511+
return;
512+
}
513+
case S_SHADES: {
514+
while(idx < SOMFY_MAX_SHADES && somfy.shades[idx].getShadeId() == 255) idx++;
515+
if(idx >= SOMFY_MAX_SHADES) {
516+
strcpy(unit, "],\"groups\":[");
517+
unitLen = strlen(unit);
518+
section = S_GROUPS;
519+
idx = 0;
520+
lsIdx = LS_OPEN;
521+
firstInArray = true;
522+
return;
523+
}
524+
size_t pos = 0;
525+
if(!firstInArray) unit[pos++] = ',';
526+
resetFmt(pos);
527+
fmt.beginObject();
528+
serializeShade(&somfy.shades[idx], fmt);
529+
fmt.endObject();
530+
unitLen = pos + strlen(unit + pos);
531+
idx++;
532+
firstInArray = false;
533+
return;
534+
}
535+
case S_GROUPS: {
536+
while(idx < SOMFY_MAX_GROUPS && somfy.groups[idx].getGroupId() == 255) idx++;
537+
if(idx >= SOMFY_MAX_GROUPS) {
538+
strcpy(unit, "],\"repeaters\":[");
539+
unitLen = strlen(unit);
540+
section = S_REPEATERS;
541+
idx = 0;
542+
firstInArray = true;
543+
return;
544+
}
545+
SomfyGroup *g = &somfy.groups[idx];
546+
if(lsIdx == LS_OPEN) {
547+
size_t pos = 0;
548+
if(!firstInArray) unit[pos++] = ',';
549+
resetFmt(pos);
550+
fmt.beginObject();
551+
serializeGroupRef(g, fmt);
552+
fmt.beginArray("linkedShades");
553+
unitLen = pos + strlen(unit + pos);
554+
lsIdx = 0;
555+
firstInArray = false;
556+
firstInLSArray = true;
557+
return;
558+
}
559+
SomfyShade *lshade = nullptr;
560+
while(lsIdx < SOMFY_MAX_GROUPED_SHADES) {
561+
uint8_t sid = g->linkedShades[lsIdx];
562+
if(sid > 0 && sid < 255) {
563+
lshade = somfy.getShadeById(sid);
564+
if(lshade) break;
565+
}
566+
lsIdx++;
567+
}
568+
if(!lshade) {
569+
strcpy(unit, "]}");
570+
unitLen = 2;
571+
idx++;
572+
lsIdx = LS_OPEN;
573+
return;
574+
}
575+
size_t pos = 0;
576+
if(!firstInLSArray) unit[pos++] = ',';
577+
resetFmt(pos);
578+
fmt.beginObject();
579+
serializeShadeRef(lshade, fmt);
580+
fmt.endObject();
581+
unitLen = pos + strlen(unit + pos);
582+
lsIdx++;
583+
firstInLSArray = false;
584+
return;
585+
}
586+
case S_REPEATERS: {
587+
while(idx < SOMFY_MAX_REPEATERS && somfy.repeaters[idx] == 0) idx++;
588+
if(idx >= SOMFY_MAX_REPEATERS) {
589+
strcpy(unit, "]}");
590+
unitLen = 2;
591+
section = S_DONE;
592+
return;
593+
}
594+
size_t pos = 0;
595+
if(!firstInArray) unit[pos++] = ',';
596+
pos += snprintf(unit + pos, sizeof(unit) - pos, "%lu", (unsigned long)somfy.repeaters[idx]);
597+
unitLen = pos;
598+
idx++;
599+
firstInArray = false;
600+
return;
601+
}
602+
case S_DONE:
603+
default:
604+
unitLen = 0;
605+
return;
606+
}
607+
}
608+
};
609+
421610
// -- Async handler implementations --
422611
void Web::handleDiscovery(AsyncWebServerRequest *request) {
423612
if(request->method() == HTTP_POST || request->method() == HTTP_GET) {
@@ -501,37 +690,12 @@ void Web::handleController(AsyncWebServerRequest *request) {
501690
if(!this->isAuthenticated(request)) return;
502691
if(request->method() == HTTP_POST || request->method() == HTTP_GET) {
503692
settings.printAvailHeap();
504-
AsyncJsonResp resp;
505-
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
506-
resp.beginObject();
507-
resp.addElem("maxRooms", (uint8_t)SOMFY_MAX_ROOMS);
508-
resp.addElem("maxShades", (uint8_t)SOMFY_MAX_SHADES);
509-
resp.addElem("maxGroups", (uint8_t)SOMFY_MAX_GROUPS);
510-
resp.addElem("maxGroupedShades", (uint8_t)SOMFY_MAX_GROUPED_SHADES);
511-
resp.addElem("maxLinkedRemotes", (uint8_t)SOMFY_MAX_LINKED_REMOTES);
512-
resp.addElem("startingAddress", (uint32_t)somfy.startingAddress);
513-
resp.beginObject("transceiver");
514-
resp.beginObject("config");
515-
serializeTransceiverConfig(resp);
516-
resp.endObject();
517-
resp.endObject();
518-
resp.beginObject("version");
519-
serializeGitVersion(resp);
520-
resp.endObject();
521-
resp.beginArray("rooms");
522-
serializeRooms(resp);
523-
resp.endArray();
524-
resp.beginArray("shades");
525-
serializeShades(resp);
526-
resp.endArray();
527-
resp.beginArray("groups");
528-
serializeGroups(resp);
529-
resp.endArray();
530-
resp.beginArray("repeaters");
531-
serializeRepeaters(resp);
532-
resp.endArray();
533-
resp.endObject();
534-
resp.endResponse();
693+
auto state = std::make_shared<ControllerChunker>();
694+
AsyncWebServerResponse *response = request->beginChunkedResponse(_encoding_json,
695+
[state](uint8_t *buffer, size_t maxLen, size_t index) -> size_t {
696+
return state->generate(buffer, maxLen);
697+
});
698+
request->send(response);
535699
}
536700
else request->send(404, _encoding_text, _response_404);
537701
}

0 commit comments

Comments
 (0)