Skip to content

Commit 42d2c46

Browse files
fhirschmannclaude
andcommitted
feat: stream the HTTP-sync manifest + guard the cross-core progress message
The file-sync manifest was fetched with http.getString() and then parsed, holding the entire payload in a String while a second copy was built inside the JSON parser. On large manifests (hundreds of file entries) this doubled peak RAM on the heap-constrained ESP32. Parse the manifest straight from the WiFiClient stream instead (deserializeJson over the stream), so only the parser's working set is allocated. The sync progress message (gSyncMsg) is written by the sync task on core 1 and read by the web server on core 0. Guard it with a short spinlock and expose it via Sync_CopyMessage(dst, len) so the web UI can never read a half-written string (which showed up as a brief garbage line in the sync progress). Verified on device: a sync against a local manifest reported "1 downloaded, 0 failed, 1 total" and the file landed byte-for-byte. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d4132e8 commit 42d2c46

4 files changed

Lines changed: 67 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
101101

102102
| Change | Commit |
103103
| --- | --- |
104+
| **HTTP-sync robustness**: the file-sync manifest is now streamed straight from the network into the JSON parser (instead of buffering the whole payload in a `String` first), roughly halving peak RAM during the parse on large manifests; the cross-core sync progress message is guarded by a spinlock so the web UI can never read a half-written line | [`PENDING3`](../../commit/PENDING3) |
104105
| PWA support: web app manifest + app icon, "add to home screen" with proper icon and name | [`b4287b9`](../../commit/b4287b9) |
105106
| PWA offline fallback: a service worker serves a cyberpunk "ESPuino Offline" page (with auto-reconnect) instead of a black screen when the home-screen app is launched while the player is powered off | [`bd07a7c`](../../commit/bd07a7c) |
106107
| Full backup: export/import of all settings + RFID assignments as JSON, WiFi credentials optional | [`4c90ff4`](../../commit/4c90ff4) |

‎src/Sync.cpp‎

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <WiFiClient.h>
1616
#include <WiFiClientSecure.h>
1717
#include <memory>
18+
#include <stdarg.h>
1819

1920
// Abort a single file download if no data arrives for this long (connection still
2021
// "open" but stalled), so one bad/slow file can't hang the whole sync forever.
@@ -25,7 +26,33 @@ static constexpr uint32_t SYNC_STALL_TIMEOUT_MS = 20000;
2526
static volatile uint8_t gSyncStatus = 0;
2627
static volatile uint8_t gSyncProgress = 0; // percent (files processed / total)
2728
static volatile bool gSyncCancel = false; // cooperative cancel flag
29+
30+
// gSyncMsg is written by the sync task (core 1) and read by the web server (core 0).
31+
// A short spinlock guards the buffer so the web server can never read a half-written
32+
// string (which would otherwise show up as a brief garbage line in the sync progress UI).
2833
static char gSyncMsg[96] = "";
34+
static portMUX_TYPE gSyncMsgMux = portMUX_INITIALIZER_UNLOCKED;
35+
36+
// Thread-safe setter: format into a stack buffer first, then copy under the lock so the
37+
// critical section stays as short as possible (a single memcpy, no formatting/IO).
38+
static void syncSetMessage(const char *msg) {
39+
char tmp[sizeof(gSyncMsg)];
40+
snprintf(tmp, sizeof(tmp), "%s", msg ? msg : "");
41+
taskENTER_CRITICAL(&gSyncMsgMux);
42+
memcpy(gSyncMsg, tmp, sizeof(gSyncMsg));
43+
taskEXIT_CRITICAL(&gSyncMsgMux);
44+
}
45+
46+
static void syncSetMessagef(const char *fmt, ...) {
47+
char tmp[sizeof(gSyncMsg)];
48+
va_list ap;
49+
va_start(ap, fmt);
50+
vsnprintf(tmp, sizeof(tmp), fmt, ap);
51+
va_end(ap);
52+
taskENTER_CRITICAL(&gSyncMsgMux);
53+
memcpy(gSyncMsg, tmp, sizeof(gSyncMsg));
54+
taskEXIT_CRITICAL(&gSyncMsgMux);
55+
}
2956

3057
uint8_t Sync_GetStatus(void) {
3158
return gSyncStatus;
@@ -56,12 +83,22 @@ void Sync_Cancel(void) {
5683
}
5784
}
5885

59-
const char *Sync_GetMessage(void) {
60-
return gSyncMsg;
86+
void Sync_CopyMessage(char *dst, size_t dstLen) {
87+
if (!dst || dstLen == 0) {
88+
return;
89+
}
90+
taskENTER_CRITICAL(&gSyncMsgMux);
91+
size_t n = strnlen(gSyncMsg, sizeof(gSyncMsg) - 1);
92+
if (n >= dstLen) {
93+
n = dstLen - 1;
94+
}
95+
memcpy(dst, gSyncMsg, n);
96+
taskEXIT_CRITICAL(&gSyncMsgMux);
97+
dst[n] = '\0';
6198
}
6299

63100
static void syncFail(const char *msg) {
64-
snprintf(gSyncMsg, sizeof(gSyncMsg), "%s", msg);
101+
syncSetMessage(msg);
65102
Log_Printf(LOGLEVEL_ERROR, "Sync failed: %s", msg);
66103
gSyncStatus = 3;
67104
}
@@ -185,7 +222,7 @@ static bool syncDownloadFile(const String &url, const String &user, const String
185222

186223
static void syncTask(void *parameter) {
187224
gSyncProgress = 0;
188-
gSyncMsg[0] = '\0';
225+
syncSetMessage("");
189226

190227
if (!Wlan_IsConnected()) {
191228
syncFail("no WiFi connection");
@@ -202,8 +239,11 @@ static void syncTask(void *parameter) {
202239
const String user = gPrefsSettings.getString("syncUser", "");
203240
const String pass = gPrefsSettings.getString("syncPwd", "");
204241

205-
// fetch the manifest
206-
String manifestRaw;
242+
// fetch + parse the manifest. The JSON is streamed straight from the network into
243+
// the parser (deserializeJson over the WiFiClient stream) instead of first buffering
244+
// the whole payload in a String — this roughly halves peak RAM during the parse, which
245+
// matters for large manifests (hundreds of file entries) on the heap-constrained ESP32.
246+
JsonDocument doc;
207247
{
208248
std::unique_ptr<WiFiClient> client = syncMakeClient(manifestUrl);
209249
HTTPClient http;
@@ -222,16 +262,15 @@ static void syncTask(void *parameter) {
222262
vTaskDelete(NULL);
223263
return;
224264
}
225-
manifestRaw = http.getString();
265+
const DeserializationError err = deserializeJson(doc, *http.getStreamPtr());
226266
http.end();
267+
if (err) {
268+
syncFail("manifest parse error");
269+
vTaskDelete(NULL);
270+
return;
271+
}
227272
}
228273

229-
JsonDocument doc;
230-
if (deserializeJson(doc, manifestRaw) != DeserializationError::Ok) {
231-
syncFail("manifest parse error");
232-
vTaskDelete(NULL);
233-
return;
234-
}
235274
JsonArray files = doc["files"].as<JsonArray>();
236275
if (files.isNull()) {
237276
syncFail("manifest has no \"files\" array");
@@ -299,7 +338,7 @@ static void syncTask(void *parameter) {
299338

300339
if (needDownload) {
301340
// expose the file currently being downloaded so the web UI can show it
302-
snprintf(gSyncMsg, sizeof(gSyncMsg), "%s", path.c_str());
341+
syncSetMessage(path.c_str());
303342
const String fileUrl = baseUrl + syncUrlEncodePath(path);
304343
if (syncDownloadFile(fileUrl, user, pass, localPath)) {
305344
downloaded++;
@@ -323,8 +362,10 @@ static void syncTask(void *parameter) {
323362
AudioPlayer_SetTrackControl(PAUSEPLAY);
324363
}
325364

326-
snprintf(gSyncMsg, sizeof(gSyncMsg), "%u downloaded, %u failed, %u total", (unsigned) downloaded, (unsigned) failed, (unsigned) total);
327-
Log_Printf(LOGLEVEL_NOTICE, "Sync %s: %s", cancelled ? "stopped" : "finished", gSyncMsg);
365+
syncSetMessagef("%u downloaded, %u failed, %u total", (unsigned) downloaded, (unsigned) failed, (unsigned) total);
366+
char summary[sizeof(gSyncMsg)];
367+
Sync_CopyMessage(summary, sizeof(summary));
368+
Log_Printf(LOGLEVEL_NOTICE, "Sync %s: %s", cancelled ? "stopped" : "finished", summary);
328369
gSyncStatus = cancelled ? 4 : ((failed > 0) ? 3 : 2);
329370
vTaskDelete(NULL);
330371
}
@@ -336,6 +377,6 @@ void Sync_Trigger(void) {
336377
gSyncCancel = false;
337378
gSyncStatus = 1;
338379
gSyncProgress = 0;
339-
gSyncMsg[0] = '\0';
380+
syncSetMessage("");
340381
xTaskCreatePinnedToCore(syncTask, "httpSync", 16384, NULL, 1, NULL, 1);
341382
}

‎src/Sync.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,8 @@ void Sync_Cancel(void);
1515
uint8_t Sync_GetStatus(void); // 0 = idle, 1 = syncing, 2 = done, 3 = failed
1616
uint8_t Sync_GetProgress(void); // percent (files processed / total)
1717
const char *Sync_GetStatusText(void); // "idle" / "syncing" / "done" / "failed"
18-
const char *Sync_GetMessage(void); // short human-readable result/error message
18+
19+
// Copies the short human-readable result/error message into the caller-provided buffer.
20+
// Thread-safe: the message is written by the sync task on core 1 and read by the web
21+
// server on core 0, so it is guarded by a spinlock to avoid reading a half-written string.
22+
void Sync_CopyMessage(char *dst, size_t dstLen);

‎src/Web.cpp‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -894,8 +894,10 @@ void webserverStart(void) {
894894
});
895895
// progress/result of the HTTP sync, polled by the web interface
896896
wServer.on("/sync", HTTP_GET, [](AsyncWebServerRequest *request) {
897+
char msg[96];
898+
Sync_CopyMessage(msg, sizeof(msg));
897899
char buf[160];
898-
snprintf(buf, sizeof(buf), "{\"status\":%u,\"progress\":%u,\"message\":\"%s\"}", Sync_GetStatus(), Sync_GetProgress(), Sync_GetMessage());
900+
snprintf(buf, sizeof(buf), "{\"status\":%u,\"progress\":%u,\"message\":\"%s\"}", Sync_GetStatus(), Sync_GetProgress(), msg);
899901
request->send(200, "application/json", buf);
900902
});
901903

0 commit comments

Comments
 (0)