From 6d15efdbf3756ee1b0d2feeb58945cf1fd7fb08a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 5 Oct 2026 10:36:02 -0500 Subject: [PATCH 01/10] fix(desktop): keep TextArea appends as appends once the byte bound is reached Once a TextArea held `max_text_bytes` (16 KiB by default, the Log Viewer's console), every further append replaced the pending TextAppend with a full `Text` of the bounded text, because only the device applied the byte bound. Each 200 ms Log Viewer pump then sent ~16.4 KB (five max-size frames) instead of one ~400 B append; max-size frames need a fully drained 4096-byte USB FIFO and the vendor write path polls once per tick (10 ms at 100 Hz), so writes timed out, were dropped, the "host must resync" warnings fed back into the captured log, and the desktop task stalled until the device was unplugged. - the browser now applies the same byte bound (DESKTOP record MaxTextBytes) with the firmware's exact cut rule (cut = size - max, moved to the next line start when a newline follows, then past UTF-8 continuation bytes), in both console and edit modes, so an append stays a TextAppend on the wire; only a pending append larger than the whole bound still becomes a Text - `boundTextBytes` in the pure block, codec-test vectors mirroring the host test, and a selftest case (line-start cut, mid-line cut with re-render) - host test: an append past the bound stays an append; UTF-8 continuation cut; a pending append larger than the bound becomes a Text - the example runs FreeRTOS at 1 kHz so a waiting USB write polls every 1 ms instead of 10 ms Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/desktop/README.md | 8 ++- components/desktop/example/sdkconfig.defaults | 3 + components/desktop/include/desktop.hpp | 3 +- .../desktop/include/detail/desktop_model.hpp | 23 ++++--- components/desktop/test/desktop_host_test.cpp | 35 +++++++++- components/desktop/web/desktop.html | 64 ++++++++++++++++--- .../desktop/web/test/desktop_codec_test.js | 11 +++- 7 files changed, 121 insertions(+), 26 deletions(-) diff --git a/components/desktop/README.md b/components/desktop/README.md index 443355b7da..3b7d3f6ec9 100644 --- a/components/desktop/README.md +++ b/components/desktop/README.md @@ -86,9 +86,11 @@ once. `Items` ranges accumulate (an `ItemCount` / full `set_items` discards earlier ranges), a removed widget cancels its pending changes, a window opened and closed between flushes sends nothing. A TextArea keeps its last `max_lines` - lines (the browser applies the same rule) and at most `max_text_bytes`; when - the byte bound trims, the host gets a full `Text` replacement so both sides - hold the same text. Per-window order: WINDOW_CLOSE -> + lines and at most `max_text_bytes` (advertised as `MaxTextBytes`); the + browser applies both bounds with the same rules to its copy, so an append + stays a `TextAppend` on the wire even when it trims (a log that has filled + its bound costs one append per flush, not the whole text). Per-window + order: WINDOW_CLOSE -> WINDOW_OPEN (full tree) -> WIDGET_ADD -> WIDGET_SET -> WIDGET_REMOVE; then DIALOG / DIALOG_CLOSE -> NOTIFY -> DESKTOP (when apps or settings changed). - The only frame sent from another context is `DesktopService`'s ERROR for a diff --git a/components/desktop/example/sdkconfig.defaults b/components/desktop/example/sdkconfig.defaults index 36f2498e04..4458e8575c 100644 --- a/components/desktop/example/sdkconfig.defaults +++ b/components/desktop/example/sdkconfig.defaults @@ -20,6 +20,9 @@ CONFIG_TINYUSB_CDC_TX_BUFSIZE=4096 # Task statistics for MonitorService and the Task Manager app: the FreeRTOS # trace facility + run-time stats, and the core id per task. +# 1 kHz tick: the USB write path polls the TX FIFO once per tick while a +# frame waits for room, so a 100 Hz tick turns every wait into 10 ms steps +CONFIG_FREERTOS_HZ=1000 CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y diff --git a/components/desktop/include/desktop.hpp b/components/desktop/include/desktop.hpp index 91d487087d..9b355a227e 100644 --- a/components/desktop/include/desktop.hpp +++ b/components/desktop/include/desktop.hpp @@ -179,7 +179,8 @@ class Desktop : public BaseComponent { /// queued commands are never evicted. size_t max_queued_commands{64}; /// Bound on a TextArea's retained text and on a text the host sends for - /// one widget (Text events are reassembled up to this size). + /// one widget (Text events are reassembled up to this size). Advertised to + /// the host (DESKTOP record MaxTextBytes), which applies the same bound. size_t max_text_bytes{16 * 1024}; /// The desktop task: every app callback runs on it, so size the stack for /// the apps (file I/O and fmt formatting comfortably fit 8 KiB). diff --git a/components/desktop/include/detail/desktop_model.hpp b/components/desktop/include/detail/desktop_model.hpp index 90fc97065a..f637c9edb4 100644 --- a/components/desktop/include/detail/desktop_model.hpp +++ b/components/desktop/include/detail/desktop_model.hpp @@ -825,19 +825,20 @@ class Model { return true; } - /// Append to a widget's text (TextArea: bounded by max_lines, which the host - /// applies identically, and max_text_bytes, which only the device applies: - /// when the latter trims, the host gets a full Text replacement instead of - /// the append so the two never diverge). + /// Append to a widget's text. A TextArea is bounded by max_lines and by + /// max_text_bytes (advertised as the DESKTOP record MaxTextBytes); the host + /// applies BOTH bounds with the same rules to its own copy, so an append + /// stays an append on the wire even when it trims (sending the whole bounded + /// text on every append past the bound would multiply the traffic by the + /// bound / append ratio and stall the link). Only a pending append larger + /// than the whole bound becomes a Text replacement (cheaper than the append). bool append_text(uint16_t win, uint16_t widget_id, std::string_view text) { WidgetState *s = this->widget(win, widget_id); if (!s) return false; s->text += text; - if (s->type == dp::WidgetType::TextArea && bound_text(*s)) { - dirty.set(win, widget_id, dp::Prop::text(dp::PropTag::Text, s->text)); - return true; - } + if (s->type == dp::WidgetType::TextArea) + bound_text(*s); const size_t pending = dirty.set(win, widget_id, dp::Prop::text(dp::PropTag::TextAppend, text)); if (pending > max_text_bytes) { // the host would receive more than it keeps: replace with the bounded text @@ -1164,8 +1165,10 @@ class Model { /// Keep a TextArea's text to its last max_lines lines (the same rule as the /// browser's ring: the text after the last newline counts as a line, so - /// "a\nb\n" is three lines) and to max_text_bytes bytes. Returns true when - /// the BYTE bound cut something (the host does not apply it). + /// "a\nb\n" is three lines) and to max_text_bytes bytes (the browser applies + /// this exact byte rule too: cut = size - max, moved to the start of the next + /// line when one follows, then past any UTF-8 continuation bytes). Returns + /// true when the BYTE bound cut something. bool bound_text(WidgetState &s) const { if (s.max_lines) { const size_t lines = static_cast(std::count(s.text.begin(), s.text.end(), '\n')) + 1; diff --git a/components/desktop/test/desktop_host_test.cpp b/components/desktop/test/desktop_host_test.cpp index 3382772687..027dd93b08 100644 --- a/components/desktop/test/desktop_host_test.cpp +++ b/components/desktop/test/desktop_host_test.cpp @@ -1479,10 +1479,41 @@ static void test_model_flush() { CHECK(s && s->entries.size() == 1 && s->entries[0].props.size() == 1 && s->entries[0].props[0].is(PropTag::TextAppend) && s->entries[0].props[0].as_text() == "line0\nline1\nline2\nline3\nline4\n"); + // an append that pushes the stored text past the BYTE bound stays an append + // on the wire (the host applies the same bound to its copy); the retained + // text is cut at the start of the line that straddles the bound + { + m.max_text_bytes = 24; + m.set_prop(win, ta, Prop::text(PropTag::Text, "0123456789\nabcdefghij\n")); // 22 B + m.flush(); + m.append_text(win, ta, "KLMNO\n"); // 28 B > 24: cut to the next line start + CHECK(m.widget(win, ta)->text == "abcdefghij\nKLMNO\n"); + out = m.flush(); + s = out.size() == 1 ? decode_widget_set(out[0].payload) : std::nullopt; + CHECK(s && s->entries.size() == 1 && s->entries[0].props.size() == 1 && + s->entries[0].props[0].is(PropTag::TextAppend) && + s->entries[0].props[0].as_text() == "KLMNO\n"); + // a cut with no newline after it lands mid-line, past a UTF-8 continuation byte + m.set_prop(win, ta, + Prop::text(PropTag::Text, "a\xc3\xa9" + "bcdefghijklmnopqrs")); // 21 B + m.flush(); + m.append_text(win, ta, "uvwxy"); // 26 B: cut = 2 is the continuation byte of U+00E9 + CHECK(m.widget(win, ta)->text == "bcdefghijklmnopqrsuvwxy" && + m.widget(win, ta)->text.size() == 23); + out = m.flush(); + s = out.size() == 1 ? decode_widget_set(out[0].payload) : std::nullopt; + CHECK(s && s->entries.size() == 1 && s->entries[0].props.size() == 1 && + s->entries[0].props[0].is(PropTag::TextAppend) && + s->entries[0].props[0].as_text() == "uvwxy"); + m.max_text_bytes = 64; + m.set_prop(win, ta, Prop::text(PropTag::Text, "")); + m.flush(); + } m.append_text(win, ta, std::string(100, 'x')); CHECK(m.widget(win, ta)->text.size() <= 64); - // the pending append grew past max_text_bytes: it became a Text (replace) - // of the bounded text, cancelling the append + // a pending append LARGER than max_text_bytes is replaced by a Text of the + // bounded text (never more than max_text_bytes on the wire), cancelling the append out = m.flush(); s = out.size() == 1 ? decode_widget_set(out[0].payload) : std::nullopt; CHECK(s && s->entries.size() == 1 && s->entries[0].props.size() == 1 && diff --git a/components/desktop/web/desktop.html b/components/desktop/web/desktop.html index 7d1829269e..351660756f 100644 --- a/components/desktop/web/desktop.html +++ b/components/desktop/web/desktop.html @@ -1009,6 +1009,21 @@

espp Desktop (WebUSB / Web Serial)

while (n > 0 && (b[n] & 0xC0) === 0x80) n--; return utf8Decoder.decode(b.subarray(0, n)); } + // The device's byte bound on a TextArea (DESKTOP record MaxTextBytes), with + // the firmware's exact rule so both copies stay identical: when the UTF-8 + // size exceeds `maxBytes`, cut = size - maxBytes, moved to the start of the + // next line when a newline follows the cut (and is not the last byte), then + // past any UTF-8 continuation bytes. Returns the (possibly unchanged) text. + function boundTextBytes(text, maxBytes) { + const str = String(text == null ? "" : text); + const b = utf8Encoder.encode(str); + if (!(maxBytes > 0) || b.length <= maxBytes) return str; + let cut = b.length - maxBytes; + const nl = b.indexOf(0x0A, cut); + if (nl !== -1 && nl + 1 < b.length) cut = nl + 1; + while (cut < b.length && (b[cut] & 0xC0) === 0x80) cut++; + return utf8Decoder.decode(b.subarray(cut)); + } // Split UTF-8 text into Text-event chunks that fit `maxPayload` (never // cutting a UTF-8 sequence in half); an empty text is one empty chunk. function chunkText(bytes, maxPayload) { @@ -3054,10 +3069,22 @@

espp Desktop (WebUSB / Web Serial)

}; wd.trim = () => { const max = wd.st.maxLines; - if (!max) return; - while (wd.st.lines.length > max) { - wd.st.lines.shift(); - if (wd.st.mode === "console" && wd.inner.firstChild) wd.inner.removeChild(wd.inner.firstChild); + if (max) { + while (wd.st.lines.length > max) { + wd.st.lines.shift(); + if (wd.st.mode === "console" && wd.inner.firstChild) wd.inner.removeChild(wd.inner.firstChild); + } + } + // the device keeps at most MaxTextBytes and applies this same rule + // to ITS copy on every append, so appends stay appends on the wire + const raw = wd.st.lines.map((l) => l.raw).join("\n"); + const bounded = boundTextBytes(raw, settings.maxTextBytes); + if (bounded !== raw) { + const kept = bounded.split("\n"); + const drop = wd.st.lines.length - kept.length; + for (let i = 0; i < drop; i++) { wd.st.lines.shift(); if (wd.st.mode === "console" && wd.inner.firstChild) wd.inner.removeChild(wd.inner.firstChild); } + if (wd.st.lines.length && wd.st.lines[0].raw !== kept[0]) { wd.st.lines[0].raw = kept[0]; if (wd.st.mode === "console") wd.renderLine(0); } + if (wd.st.mode === "edit" && wd.inner.value !== bounded) wd.inner.value = bounded; } if (wd.st.lines.length) wd.st.lines[0].startState = null; }; @@ -3073,15 +3100,15 @@

espp Desktop (WebUSB / Web Serial)

wd.st.lines.push({ raw: parts[i], startState }); if (wd.st.mode === "console") { const node = document.createElement("div"); node.className = "line"; wd.inner.appendChild(node); wd.renderLine(wd.st.lines.length - 1); } } - wd.trim(); if (wd.st.mode === "edit") { wd.inner.value += text; wd.st.dirty = false; } - else if (stick) wd.inner.scrollTop = wd.inner.scrollHeight; + wd.trim(); // the line bound, then the device's byte bound (edit mode: inner.value follows) + if (wd.st.mode === "console" && stick) wd.inner.scrollTop = wd.inner.scrollHeight; }; wd.setText = (text) => { wd.st.lines = String(text).split("\n").map((raw) => ({ raw, startState: null })); - wd.trim(); - if (wd.st.mode === "edit") { wd.inner.value = text; wd.st.dirty = false; } - else wd.renderAll(); + if (wd.st.mode === "edit") { wd.inner.value = String(text); wd.st.dirty = false; } + wd.trim(); // line bound, then the device's byte bound (edit mode: inner.value follows) + if (wd.st.mode === "console") wd.renderAll(); }; wd.dispose = () => idle.cancel(); wd.setMode("edit"); @@ -3626,6 +3653,25 @@

espp Desktop (WebUSB / Web Serial)

ta.value = "z".repeat(settings.maxTextBytes + 10); ta.dispatchEvent(new Event("input", { bubbles: true })); assert(ta.value.length === settings.maxTextBytes && ta.classList.contains("overflow"), "TextArea edit capped at MaxTextBytes"); w.widgets.get(4).idle.flush(); await txQueue; await tick(0); // drain the capped edit's Text chunks + // 8b. a console TextArea applies the device's byte bound (MaxTextBytes) on + // appends with the firmware's cut rule, so appends can stay appends + { + feedHex(DT.WIDGET_ADD, "0100" + "0100" + "6000" + "0000" + "08" + "01" + "01" + "02" + "10" + "0200" + "0100" + "11" + "0200" + "0000"); // ReadOnly console, MaxLines 0 + const cw = w.widgets.get(0x60); + assert(cw && cw.st.mode === "console", "console textarea added"); + const saved = settings.maxTextBytes; settings.maxTextBytes = 24; + const app = (str) => { const b = utf8Encoder.encode(str); feedHex(DT.WIDGET_SET, "0100" + "01" + "6000" + "01" + "02" + hexOf(new Uint8Array([b.length & 0xFF, b.length >> 8])) + hexOf(b)); }; + app("0123456789\n"); app("abcdefghij\n"); app("KLMNO\n"); + const rawOf = () => cw.st.lines.map((l) => l.raw).join("\n"); + assert(rawOf() === "abcdefghij\nKLMNO\n" && cw.inner.children.length === 3, "byte bound cut at the next line start: " + JSON.stringify(rawOf())); + app("\u00e9\u00e9\u00e9\u00e9\u00e9"); // 10 B, pushes to 27 B: cut moves to the next line start again + assert(rawOf() === "KLMNO\n\u00e9\u00e9\u00e9\u00e9\u00e9" && cw.inner.children.length === 2, "byte bound keeps the device's exact tail: " + JSON.stringify(rawOf())); + app("abcdefghijklmnopqrstuvwxyz"); // 26 B on the last line: no newline after the cut, so it lands mid-line + assert(rawOf() === "cdefghijklmnopqrstuvwxyz" && cw.inner.children.length === 1, "mid-line cut drops the earlier lines and shortens the first: " + JSON.stringify(rawOf())); + assert(cw.inner.children[0].textContent === "cdefghijklmnopqrstuvwxyz", "first line re-rendered after a mid-line cut"); + settings.maxTextBytes = saved; + feedHex(DT.WIDGET_REMOVE, "0100" + "0100" + "6000"); + } // 9. a List added on the fly: Select / Activate feedHex(DT.WIDGET_ADD, "0100" + "0100" + "0700" + "0100" + "09" + "00" + "00" + "01" + "0c" + "0a00" + "0000" + "0200" + "0100" + "61" + "0100" + "62"); const list = w.widgets.get(7); diff --git a/components/desktop/web/test/desktop_codec_test.js b/components/desktop/web/test/desktop_codec_test.js index 18255585c9..008efa5411 100644 --- a/components/desktop/web/test/desktop_codec_test.js +++ b/components/desktop/web/test/desktop_codec_test.js @@ -22,7 +22,7 @@ const end = html.indexOf("// ==== DESKTOP:END-PURE ===="); assert(begin > 0 && end > begin, "pure-block markers not found in desktop.html"); const pure = html.slice(begin, end); const P = new Function(pure + ` - return { pickAutoConnectCandidate, sortedOrder, compareCells, cellSortKey, DT, DT_NAME, PROP, WT, WIN, WEV, WGEV, KEY, MOD, LAYOUT, TA_FLAG, MIN_WINDOW, DESKTOP_MAX_PAYLOAD_CAP, COLOR_DEFAULT, DESKTOP_HAS_SNAPSHOT, DESKTOP_WINDOW_LIST_COMPLETE, MAX_TEXT_BYTES_DEFAULT, SUBMIT_EVENT_HEADER, DIALOG_RESULT_HEADER, utf8ByteLength, truncateUtf8, + return { boundTextBytes, pickAutoConnectCandidate, sortedOrder, compareCells, cellSortKey, DT, DT_NAME, PROP, WT, WIN, WEV, WGEV, KEY, MOD, LAYOUT, TA_FLAG, MIN_WINDOW, DESKTOP_MAX_PAYLOAD_CAP, COLOR_DEFAULT, DESKTOP_HAS_SNAPSHOT, DESKTOP_WINDOW_LIST_COMPLETE, MAX_TEXT_BYTES_DEFAULT, SUBMIT_EVENT_HEADER, DIALOG_RESULT_HEADER, utf8ByteLength, truncateUtf8, ByteReader, ByteWriter, hexOf, bytesOfHex, propKind, readRec, decodeProp, propBytes, desktopSettings, decodeMessage, encodeMessage, decodeWidgetSet, decodeDesktop, chunkText, keyCodeFor, clampGeometry, geometryEquals, resizeGeometry, placeWindow, createGeometryStore, geometryKey, @@ -332,6 +332,15 @@ test("registry metadata and wiring constants", () => { assert.strictEqual(P.pickAutoConnectCandidate([other], [port(0x2341), { getInfo: () => { throw new Error("x"); } }], 0x1209), null); assert.strictEqual(P.pickAutoConnectCandidate(null, undefined, 0x1209), null); } + // the device's byte bound on a TextArea: the firmware's exact cut rule (desktop_model.hpp bound_text) + { + assert.strictEqual(P.boundTextBytes("abc", 24), "abc"); + assert.strictEqual(P.boundTextBytes("0123456789\nabcdefghij\nKLMNO\n", 24), "abcdefghij\nKLMNO\n", "cut moves to the next line start"); + assert.strictEqual(P.boundTextBytes("ab\u00e9cdefghijklmnopqrstuvwxyz", 24), "cdefghijklmnopqrstuvwxyz", "no newline: mid-line cut past the UTF-8 continuation byte"); + assert.strictEqual(P.boundTextBytes("0123456789\n", 4), "789\n", "a newline that is the LAST byte does not move the cut"); + assert.strictEqual(P.boundTextBytes("abc", 0), "abc", "a zero bound is ignored"); + assert.strictEqual(P.boundTextBytes(null, 8), ""); + } assert.strictEqual(P.MIN_WINDOW.w, 160); }); test("the pure block touches no DOM, window, storage or timers", () => { From 26fe30b83997678eea371bd4acbd49c6637b7e52 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 5 Oct 2026 10:42:46 -0500 Subject: [PATCH 02/10] fix(desktop): keep ANSI state across a mid-line trim; edit mode follows MaxLines trims Review round 1: - a mid-line byte cut now renders the kept suffix from the ANSI state the removed prefix left behind (ansiSplit over the prefix), and the first line's startState is no longer reset to null after a trim: surviving console nodes were rendered from the state the removed text really produced, so they stay valid without a full re-render - edit mode: the