diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index 2c7af47c..75601cf3 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -22,6 +22,16 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul ## Unreleased (`next-iteration`) +### PreviewDriver's `fps` becomes `targetFps`, and now trades resolution (2026-08-25) + +The control is renamed and its meaning changed, so the rename is the point rather than cosmetic. + +**Before:** `fps` was a ceiling. The driver never exceeded it, but a link that could not sustain the rate simply delivered fewer frames and the control did nothing about it. + +**Now:** `targetFps` is the rate you *want*. The driver still never exceeds it, and when the link cannot keep up it **trades preview resolution** to get closer, lower it for full detail at a slower rate, raise it for a smoother but coarser preview. That makes the slider the place where you choose between detail and smoothness, which is what users were reaching for. + +**Action: none required.** The preview is a view, not output. A device that had a non-default `fps` saved falls back to the default 24 on first boot with this firmware, because the persisted key changed; set `targetFps` if you had tuned it. Mixed versions degrade soft: an old UI against new firmware sends no detail request and gets full detail (capped by memory); a new UI against old firmware sends an uplink message the device ignores. + ### A module declares every control with `addControl` (2026-08-24) `addUint8`, `addUint16`, `addInt16`, `addInt32` and `addBool` are replaced by one overloaded diff --git a/docs/architecture.md b/docs/architecture.md index 90bd161e..f12d4e8f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -342,7 +342,11 @@ Modules in the light pipeline can be added, replaced, or removed dynamically at - *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path: each consumer holds a `const`-pointer and reads it per frame. The pointers are **(re)bound on every rebuild**, not just at boot: `Drivers::prepare()` re-resolves the active `Layer` (`Effects::activeLayer()`) and calls `passBufferToDrivers()`, which re-runs `setSourceBuffer()`/`setLayer()` on each child (clearing them to `nullptr` when there is no active Layer). So a held pointer is valid only until the next rebuild — which is exactly why the consumers re-read it each frame and tolerate a null (the [robustness rule](#robustness)): a Layer add/delete/replace re-binds or clears it live, no dangling reference. - *Push to a core sink:* `PreviewDriver` owns the preview wire format (a one-time coordinate table + per-frame RGB point list) and pushes the bytes to a `BinaryBroadcaster` (the core HTTP server). The server broadcasts them over WebSocket without knowing they're a preview: the format and the light types stay entirely in the driver. See [PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md). -**Graceful degradation under transport backpressure.** The preview is the transport-side sibling of the memory-side [§ Degradation cascade](#degradation-cascade): when the browser can't keep up with a full-resolution frame (128² = ~49 KB), the producer sheds quality rather than stall the loop, in video-streaming order, frame rate then resolution. The frame streams from the driver buffer with no intermediate copy, a resumable memory-adaptive chunk per tick, and the next frame starts only once the previous drained, so the effective frame rate self-limits to what the link sustains. Only when a single frame can't drain promptly does it downsample via a spatial lattice (the adaptive-bitrate idea behind HLS/DASH, on a binary WebSocket). Each delivered frame is whole (a WebSocket message is atomic), the render loop is charged a bounded slice per tick, and a client blocked past the spin budget is closed and reconnects (a blip, not a freeze). The mechanism is payload-agnostic and lives in [PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md) + `HttpServerModule`, so other bulky streams can ride the same transport. +**Two WebSocket channels, by traffic class.** `/ws` carries the control plane (JSON state and patches); `/wsp` carries lossy binary streams (the preview). They are separate TCP connections on purpose: preview frames are large and droppable while state messages are small and latency-sensitive, and sharing one connection makes the small ones queue behind the big ones, head-of-line blocking, which surfaced as a flickering connection indicator and an unresponsive UI on large layouts. Separate connections is the standard remedy for that mixed-criticality case. + +`BinaryBroadcaster` stays domain-neutral through this: the core still only takes bytes and broadcasts them, with no knowledge that they are a preview. One query serves the producer, `subscriberCount()` for the status line; the work gate is the pull model itself (no standing request, no work). Inbound client frames are unmasked by the transport (framing is its job) and the payload bytes are handed opaquely to the registered `ClientMessageSink`; only the producer knows a `[0x51][stride][fps]` standing request or a `[0x52][stride]` table request from any other bytes. The two channels have separate caps (`MAX_WS_CLIENTS` 8, `MAX_PREVIEW_CLIENTS` 4) because both draw on one `CONFIG_LWIP_MAX_SOCKETS` budget of 16, shared with HTTP, mDNS, Art-Net, MQTT and OTA. + +**Graceful degradation under transport backpressure.** The preview is a PULL channel: a client posts a standing `[0x51][stride][fps]` request plus one-shot `[0x52]` table requests, and the device serves the most conservative standing request, building nothing at all when none stands. Every `/wsp` message rides ONE resumable per-client-cursor drain: each socket takes bytes at its own TCP pace on the transport tick, a frame offered while the slot still drains is dropped at the source, and a client is closed only on a real error or FIN, never for slowness. Congestion therefore costs preview frames, never LED time and never a disconnect. Each frame header reports the drops since the last delivered one, and the browser's controller (one pure function in `preview-adapt.js`, unit-tested) reads only that signal: persistent drops coarsen the lattice, drop-free windows refine it a rung at a time, and a refine that brings drops back is taken back with exponentially growing patience (the abandon-fast retry-slowly rule of adaptive-bitrate players). Geometry is cached client-side per (epoch, stride), so a stride change to a known rung costs no table traffic. There is no display cap: the bounds are device memory and the index type, and everything else degrades where it actually binds. The channel machinery is core and domain-neutral (opaque request bytes forwarded to a registered producer sink); `PreviewDriver` is one producer, so other bulky streams can ride the same transport. **Naming convention.** Capital `Layouts`, `Effects`, `Drivers` are class names (always capitalised when referring to the class). Lowercase "layouts", "layers", "drivers" is the English plural, used freely when context makes it clear. Singular "layout", "layer", "driver" is an individual instance. diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index cd0ce73f..5911790f 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -426,7 +426,7 @@ module by a path rather than a bare name. ### HTTP file serving blocks the render tick (backlog) -`HttpServerModule::handleConnection()` serves large embedded files (`app.js`, `style.css`) with the blocking `TcpConnection::write` — a page load can briefly stall `loop20ms`. One-shot per load (lower priority than the per-tick preview issue, which is fixed). Fix: serve large HTTP responses with `writeChunks` (the same non-blocking path used for preview frames). +`HttpServerModule::handleConnection()` serves large embedded files (`app.js`, `style.css`) with the blocking `TcpConnection::write` — a page load can briefly stall `loop20ms`. One-shot per load (lower priority than the per-tick preview issue, which is fixed). Fix: serve large HTTP responses through a resumable per-client cursor drained on tick20ms (the shape the preview and full-state sends use). ### Generic control + state topics over MQTT — the automation escape hatch (backlog) @@ -510,8 +510,9 @@ work moved to a worker or made resumable, so each is a design change rather than Confirmed by external review (CodeRabbit, PR #56). **`tick()` — every frame, the sharp ones:** -- `PreviewDriver::tick` — `sendFrame()` writes a socket synchronously; `buildAndSendCoordTable()` - resizes `keptIdx_`. Currently suppressed at the site with the reason. +- `PreviewDriver::tick` — the socket half is FIXED (the pull-model transport only ARMS a message; + every socket byte moves on the transport tick). What remains: `buildCoordTable()` resizes + `keptIdx_` and the staging buffer on a rebuild/adopt tick. Suppressed at the site with the reason. - `ParallelLedDriver::tick` — `tickSync()`/`tickRing()` reach `busWaitIfBusy()`, which spins for the DMA peripheral. Deliberate (the driver owns the bus for the frame) but blocking. Suppressed. - `Drivers::tick` — joins/stops the render-split worker synchronously on the timed-out recovery @@ -839,13 +840,6 @@ They are **not** CI failures (CI is Debug) and each one inspected so far is a fa Not done with the multi-destination/tab-UI merge because 17 warnings across four core files is its own change, not a tail on someone else's. -## Preview stream: tail byte-phase desync under backpressure - -**Symptom (board B, 2026-07-14):** on a large grid the preview's bottom region (the frame tail) shows per-pixel noise; with a SOLID effect it flickers pure R / pure G / pure B at max brightness. Pure primaries from solid content = the frame bytes read at an offset that is not a multiple of 3 — tearing alone cannot discolor identical frames, so the resumable sender's offset accounting slips. Survives a browser refresh; occurs "occasionally" (per-frame), worst when the device is network-starved. - -**Suspect:** `HttpServerModule::sendBufferedFrame` (the zero-copy resumable send whose body is the live producer buffer, draining across transport ticks) — the resume-after-partial-socket-write path. Verify by logging the resume offset vs bytes actually written on EWOULDBLOCK; the fix is offset accounting, plus a resync rule (a client that missed bytes gets a fresh frame header, not a phase-shifted tail — the *robust to any input* bar). - -**Not** the shift-register transport bug and **not** buffer corruption: the composite buffer is proven correct (Solid writes every light; the preview alone garbles). ## MoonLive core/platform layering + JIT sdkconfig scoping (CodeRabbit #29, 4 findings) diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 0ab38f9d..113d5bb8 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -228,16 +228,6 @@ The migrated 1D PacMan and 1D Ant effects were removed — a chase rendered on a Only **NoiseEffect**, **PlasmaEffect** and **RipplesEffect** have z-aware math. The other honest-D2 effects use `Layer::extrude` to duplicate the z=0 plane, so every z-slice is identical on 3D layers. Candidates for genuine D3 promotion: Metaballs/GlowParticles (add z to blob coordinates), Plasma palette/Spiral (add z-driven phase term), Fire (z-drift heat grid), Rings/LavaLamp/Checkerboard/Particles (add z to each element). Prioritise after seeing real 3D installations; each promoted effect also needs its `dynamicBytes` budget for the full 3D buffer. -### Full-density interpolated preview for large layouts (backlog) - -The preview index-downsamples a large layout to fit the WS send budget (e.g. 128×128 = 16384 lights → ~1639 sent at stride 10), so the UI shows a sparse sample, not every light. To show **all** lights at their real positions with **interpolated** colors for the unsent ones: - -- Decouple the `0x03` coordinate-table density from the per-frame `0x02` stride. Positions are static and sent once, so the table can carry **all** light coordinates (16384 × 3 = ~48 KB one-time — acceptable off the per-frame path, possibly chunked) while the per-frame RGB stays strided to protect ArtNet/the link. -- The browser holds the full position set and, per frame, interpolates each unsent light's color from its nearest sent neighbours (the sent indices are known from the stride). True positions, guessed colors — better than the removed dense-box block-replicate because positions are exact. -- Open questions: 48 KB one-time table vs `MAX_WRITE_CHUNKS` / send-buffer (needs chunked send or a raised cap, with the same partial-write care as `writeChunks`' drain); interpolation cost on a 16384-point cloud each frame in JS; whether nearest-neighbour or weighted is worth it. - -Not simple — own planning pass. Until then the preview is a faithful strided *sample* (correct shape/color/motion, not per-pixel). A cheap interim (point-size scaled by stride to fatten samples into their cells) was tried and reverted as not what's wanted — it filled the volume but didn't add real points. - ### Self-describing preview frame header (mid term) The preview wire format is a private opcode protocol: `0x02` per-frame channels, `0x03` coordinate table, each a hand-rolled byte layout, and the color payload is **always RGB** regardless of the buffer's `channelsPerLight`. Every new data kind (RGBW display, beam direction, …) means inventing another opcode and another fixed layout by hand. The minimal fix that stops that sprawl: a small **typed header** — `[type][format][count][stride]` where `format` enumerates `{RGB, RGBW, …}` — so one message kind carries any per-light channel layout and the browser shader reads `format` to interpret the payload. Do it concrete-first, when RGBW *display* (below) is actually wanted, not speculatively. Prereq for both items below. @@ -256,16 +246,6 @@ Today a "light" is a point at a static coordinate with a color. A **moving head* Today each layout child describes one light type (all LED strips, or all par lights), and the current model is one Layouts container per light type. Whether a single Layouts should hold mixed types (LED strips + par lights together), and how the per-channel layout would reconcile across them, isn't designed. Deferred until a concrete need forces it; it's adjacent to the fixture model above (a real fixture/attribute model may reframe how mixed types are expressed). (Moved from architecture.md § What we leave undesigned; a deferred design decision, not a settled 🚧 one.) -### Extract the resumable backpressure transport as a domain-neutral channel (long term) - -The preview's transport — resumable cross-tick send from a stable buffer + newest-wins backpressure drop + adaptive graceful degradation (see [architecture.md § graceful degradation under transport backpressure](../architecture.md)) — is **payload-agnostic**: any bulky throttled stream (a future MJPEG/video preview, fixture-state streams, fleet telemetry) could ride it. The *payload* model (count/stride/RGB) is light-specific; the *byte-pump* is not. When a second consumer for this transport appears, promote the pump into a domain-neutral core primitive (a `ThrottledChannel`-style sink) that PreviewDriver becomes *a* producer on, rather than owning the protocol. Concrete-first: extract on the second use, not before — until then the seam stays inside HttpServerModule/PreviewDriver. - -### PreviewDriver `resumableFrames` default OFF: fix the tearing, then un-skip the dynamicBytes test - -`resumableFrames` (the downsampled-frame transport A/B) now defaults **OFF** because the resumable path visibly **tears the preview**: it shares the single-occupancy WS send slot with the ~1 Hz full-state push and the next preview frame, so a preempted mid-drain frame reaches the browser spliced (top rows new, the rest stale — PO saw it on the wall). OFF uses the proven-correct synchronous transport. Two follow-ups, both small: - -1. **The tear itself (the reason to eventually want ON again).** Give the preview send its OWN send slot instead of sharing `previewSend_` with the state push, OR make a preempted drain drop cleanly (versioned frame → the browser discards a spliced one) rather than splicing. Only then is ON safe to default. The off-thread send exists to avoid the ~17 ms render hitch at very large grids with the preview open, so this matters mainly for big walls; until fixed, synchronous is correct. -2. **The skipped test.** `unit_PreviewDriver.cpp`'s "reports its resumable-path buffers in dynamicBytes" is `doctest::skip()`'d: it was written when `resumableFrames` defaulted ON and its first assertion relied on the rig constructor's `applyState()` allocating the staging buffer via that default. With the flag OFF, toggling it ON post-construction + `prepare()` did not re-allocate the buffers in the test rig the way the constructor path did (the "ON" reads dropped to 0). The *accounting* (`driverHeapBytes` sums `stageCap_` + `keptIdxCap_`) is unchanged and correct; only the test's default assumption broke. Un-skip by rebuilding the rig so it can deterministically allocate the resumable-path buffers with the flag OFF-by-default — likely wiring the flag ON into the rig BEFORE its first `drivers.applyState()` so the same acquire path production uses runs (the post-construction toggle route resisted several attempts; the constructor-time route is the one to nail). Small test-only work. ## LCD / DMA driver work diff --git a/docs/history/lessons.md b/docs/history/lessons.md index c38a1339..f19808e7 100644 --- a/docs/history/lessons.md +++ b/docs/history/lessons.md @@ -604,3 +604,19 @@ evidence which *looks* most authoritative here is the evidence that lies. emitted x86-64 instructions for real, so the tests that only exist on that host actually execute. Worth doing on any change to a backend, an encoder, or the register allocator — it is minutes, and it is the difference between finding these locally and finding them in CI. + +## A lossy channel never closes a client for slowness (2026-08-25) + +The preview transport spent a bench day being patched before the pattern surfaced: every +symptom (reconnect storms, blank refreshes, renderWait spikes charged to the LEDs) traced to a +synchronous send path that treated a slow socket as a fault, spinning up to a stall budget on +the output core and then **closing the client**. That converts ordinary congestion into +disconnects, disconnects into re-primed state, and re-primed state into more congestion, a +feedback loop. + +The rule that replaced it, and holds generally: **on a lossy stream, drop at the source and let +TCP pace the rest.** Write only what the socket takes now (`writeSome`, never a wait), skip the +frame when the previous one has not drained, report the skip to the receiver (its one honest +congestion signal), and close only on a real error or FIN. The receiver steers quality from the +drop reports. Every give-up budget that remains must bound *lack of progress*, never elapsed +total, or slow-but-healthy transfers get truncated. diff --git a/docs/history/plans/Plan-20260825 - A lossy channel for the preview (shipped).md b/docs/history/plans/Plan-20260825 - A lossy channel for the preview (shipped).md new file mode 100644 index 00000000..a2413760 --- /dev/null +++ b/docs/history/plans/Plan-20260825 - A lossy channel for the preview (shipped).md @@ -0,0 +1,130 @@ +# Plan, A lossy channel for the preview stream + +(PR #81. The device-side adaptation this plan sketched was later replaced, see the Lean preview transport plan.) + +## The problem, measured + +On a 768×384 layout with the preview open, over 10 s on the single `/ws` socket: + +| | msgs/s | KB/s | share | +|---|---|---|---| +| Preview colour frames (`0x02`) | 22.6 | **244.8** | **98.5%** | +| State pushes (JSON) | 2.2 | 3.8 | 1.5% | +| Coordinate table (`0x03`) | 0.1 | 1.1 |, | + +Three symptoms follow, all reported by users and all explained by that ratio: + +1. **The connection indicator flickers.** State pushes queue *behind* 10.8 KB preview frames on one TCP connection, textbook head-of-line blocking. The browser sees delayed messages and reports the link as troubled. +2. **Fields visibly refill with unchanged values.** Every state push runs `updateValues()`; a full state additionally runs `renderCards()`. At 2.2/s that is a DOM write over every visible control twice a second. +3. **The UI stops responding on a large layout.** The device is fine (`/api/state` answers in 8-12 ms under this load); the *browser* is decoding 22 frames/s and drawing them on the same main thread that handles clicks. + +A user reported the compounding case: with a big grid, the add-module dropdown vanished before it could be clicked, and **disabling the preview did not help**, because the driver broadcasts to every connected client whether or not anyone is watching. + +## The decision: a separate channel, and a higher cap + +Two candidate directions, and the measurement picks between them. + +**Rejected: cap the preview harder.** It treats the symptom by giving up the thing the product owner explicitly wants, the highest-resolution preview the hardware can sustain. Our cap is already a flat `kDisplayCap = 4096` "for ANY board", ignoring that a PSRAM board has the memory and the link for far more. + +**Chosen: separate the two traffic classes, then raise the cap.** The preview is a **lossy** stream (a dropped frame is invisible; the next one is 44 ms away) sharing one **lossless** ordered connection with the control plane, where every message matters and latency is user-visible. That mixed-criticality pairing is the recognised cause of the head-of-line blocking above. + +This is the industry-standard remedy, not a bespoke choice. *High Performance Browser Networking* (O'Reilly, ch. 17) names this exact case, "multiple classes of messages: high-priority updates, such as control traffic, and low-priority updates, such as background transfers", and gives two documented answers: **separate connections**, or an application-level priority queue driven by `bufferedAmount`. Concurrent WebSockets are the standard mitigation for WebSocket head-of-line blocking. (WebTransport over HTTP/3 would give unreliable datagrams natively and is the eventual destination, but as of 2026 no shipped ESP32 stack offers it.) + +**Prior art, and it is our own.** WLED streams its live preview only to a client that asked for it (`wsLiveClientId`, set by `{"lv":true}`), never sends while that client's queue is non-empty (`queueLength() > 0`, retry in 20 ms), and caps at 256/1024 lights. **WLED-MM raised that cap to 4096, and 8192 on PSRAM boards** ("better preview on PSRAM boards"), while keeping both the opt-in and the queue check. That is the shape to carry forward: the opt-in and the backpressure are what make a high cap affordable. + +**On the architecture boundary.** `BinaryBroadcaster` is a domain-neutral sink and stays one: the core still only takes bytes and broadcasts them, with no knowledge that they are a preview. What changes is *which* sink the driver pushes to, a producer's choice, the same shape as any capability flag. The core learns "there are two channels", never "one of them is a preview". + +## Design + +### 1. A second WebSocket path, `/wsp` + +`HttpServerModule` gains a second upgrade route beside `/ws`, with its own client list and its own `BinaryBroadcaster` implementation. `/ws` keeps the control plane (JSON state, patches); `/wsp` carries binary preview frames only. + +Two independent TCP connections means a preview frame in flight cannot delay a state push, which is the whole point. Cost: one extra socket per previewing client, bounded by the existing client cap. + +**The socket budget, measured before designing against it.** `MAX_WS_CLIENTS` is **8**, inside a `CONFIG_LWIP_MAX_SOCKETS` of **16** shared with HTTP, mDNS, Art-Net, MQTT and OTA. A preview socket per WS client would need 16 WS sockets at the cap and leave nothing for anything else. + +So the preview channel gets its **own, lower cap, 2 to 4**, refused beyond that rather than competing for the control plane's slots. That matches the use: a preview is something one or two people watch, while the control plane is what wants 8. A client refused a preview socket keeps its `/ws` connection and simply shows no preview (or falls back to the shared path, whichever the UI step below settles). + +Raising `CONFIG_LWIP_MAX_SOCKETS` is a **fallback, not part of this plan**. At ~40-60 bytes of static RAM per socket, going to 24 costs about 400 bytes, but the IDF's own guidance is that lwIP "is not designed for many simultaneous connections" and that the socket count alone is not the whole story, the TCP PCB counts (`LWIP_MAX_ACTIVE_TCP`, `LWIP_MAX_LISTENING_TCP`, both 16 today) must move with it. Do it on measured evidence of exhaustion, never preemptively. (For scale: MoonLight allowed 100+ clients, which is far outside what lwIP is built for; 16 is the shipped default and [raising it even to 32](https://github.com/espressif/esp-idf/issues/14454) is an open upstream debate.) + +### 2. Opt-in, so nothing streams when nobody watches + +The preview streams **only** to clients connected on `/wsp`. Close the preview pane and the browser closes that socket; no client → the driver's `tick()` returns before building a frame. This is WLED's `wsLiveClientId` in a cleaner form: connection presence *is* the subscription, so there is no `{"lv":true}` message to lose track of. + +This alone fixes the reported case, the user who turned the preview off and saw no improvement. + +### 3. Backpressure: never queue behind yourself, ALREADY IMPLEMENTED + +Verified during step 3 (2026-08-25): `PreviewDriver::tick()` already gates on +`broadcaster_->bufferedSendIdle()` and skips the slot when the previous frame is still draining, +incrementing `framesWaiting_` as the "link is behind" signal that drives the adaptive resolution. +That is WLED's `queueLength() > 0` check with a richer signal, and it needs no change. + +Proven on the bench with a deliberately slow reader: a client draining at 2 KB/s received 2 KB/s +rather than the 241 KB/s the channel was producing, the server matched the reader instead of +queueing, and `/api/state` stayed responsive throughout. + +This step is recorded rather than removed: it was in the plan because the design was written from +`maxPreviewPoints()` without reading the tick path, and the next person deserves to know the check +exists rather than adding a second one. + +### 3b. (original text, for reference) + +Before building a frame, ask the channel whether the previous one has drained. `BinaryBroadcaster` grows one query, `sendQueueDepth()` or a `bool busy()`, and the driver skips the slot when it is non-zero, exactly as WLED does. A lossy stream must *drop*, not queue: a queued frame is stale by the time it arrives and delays the next one. + +We already have the raw material: `framesWaiting_` counts slots skipped because the previous frame is still draining, and `slowStreak_`/`cleanStreak_` drive the adaptive factor. This makes the signal explicit rather than inferred. + +### 4. Measure throughput, and raise the cap to what the link sustains + +Replace the flat `kDisplayCap = 4096` with a **measured** ceiling: + +- Track bytes actually sent and the drain time per frame → an observed KB/s for this client's link. +- Raise the point cap while frames drain promptly, lower it when they do not, the existing `cleanStreak_`/`slowStreak_` machinery, applied to the ceiling rather than only to the divisor. +- Keep an absolute ceiling from **memory** (`maxAllocBlock()`, as today) and a floor (1024) so a tight board still previews. + +Target the WLED-MM numbers as the starting envelope: **4096 baseline, 8192 on a PSRAM board**, and let the measurement go higher when a gigabit-linked desktop or S31 sustains it. The principle the product owner set: *if the infrastructure can deal with it, do not limit it.* + +### 5. What we do NOT do + +- **No UDP / WebTransport.** Genuinely the right transport for lossy pixel data, but no browser-reachable path exists on ESP32 today. Revisit when WebTransport ships. +- **No compression.** The frames are already downsampled; compressing costs CPU on the render core to save a link that a separate channel has already unblocked. +- **No change to the wire format.** `0x02` / `0x03` are unchanged; only which socket carries them. + +## Status, implemented 2026-08-25 + +| Step | Result | +|---|---| +| 1. `/wsp` route + cap | **done.** `MAX_PREVIEW_CLIENTS = 4`, sized from observed use (1 browser typical, 2 common, 4 ceiling). Verified: 4 stored, the 5th refused, control plane untouched. | +| 2. Driver on `/wsp`, subscriber gate | **done.** Verified: no subscriber → 0 frames built; `/ws` carries no binary; `/wsp` carries no JSON. | +| 3. Backpressure | **already existed**, `bufferedSendIdle()` gating. Verified: a 2 KB/s reader received 2 KB/s rather than a 241 KB/s backlog. | +| 4. Measured cap | **done.** Ceiling 4096 → 16384 as frames drain promptly. On a 768×384 wall: stride **9 → 5**, points **3,698 → 11,858** (3.2× detail), stable (0 stride changes in 16 s), `/api/state` 9-31 ms under load. | +| 5. UI on `/wsp` | **done.** Verified end to end: dismissing the pane closed the socket within 2 s and re-opening restored it. | +| 6. Docs | **done.** Driver card + architecture § two channels. | + +**Carried forward, unproven:** step 4 exposed a real gap, the struggle signal was only evaluated on slots where a frame *completed*, so a link bad enough that nothing completes produced no adaptation at all and the driver would sit forever at a resolution the link could not carry. `stuckWaiting` now treats a long unfinished wait as struggle on the same threshold. **That path has never been observed running**: the test mock clears its busy flag on every idle poll and so cannot hold a channel stalled, and a healthy link never triggers it. Weak WiFi is where it fires; watch for it there. + +**Also watch:** bandwidth rose 202 → 738 KB/s on a local link, which is the resolution working as intended. The number to check is what it settles to over WiFi and 100 Mbit Ethernet. + +## Steps + +1. **`/wsp` route + a second broadcaster, with its own cap.** `HttpServerModule` hosts the path, tracks its clients against a preview-specific `MAX_PREVIEW_CLIENTS` (2-4, deliberately below `MAX_WS_CLIENTS`), and implements `BinaryBroadcaster` for it. *Tests:* a client on `/wsp` receives binary and no JSON; a client on `/ws` receives JSON and no preview frames; disconnecting one does not disturb the other; **an upgrade past the preview cap is refused cleanly and leaves the control plane's slots untouched**. +2. **Point the driver at it, and gate on subscribers.** `PreviewDriver` takes the preview broadcaster; `tick()` returns early with no clients. *Tests:* no clients → no frames built (pin it by counting, so the *work* is skipped, not just the send). +3. **`sendQueueDepth()` + skip-when-busy.** *Tests:* a busy channel skips the slot and does not queue; the frame after a drain goes out. +4. **Measured cap.** Throughput tracking, cap adaptation, PSRAM-aware ceiling. *Tests:* the cap rises on clean streaks and falls on slow ones, bounded by memory and the floor. +5. **UI:** `preview3d.js` opens `/wsp` when the preview pane is visible and closes it when hidden. *Test:* the JS suite pins that the preview socket is opened lazily and closed on hide. +6. **Docs:** the PreviewDriver card + architecture § the two channels and why. + +## Risks + +1. **Socket exhaustion.** 8 WS clients each with a preview socket = 16, which alone consumes the entire `CONFIG_LWIP_MAX_SOCKETS=16` budget and starves HTTP, mDNS and Art-Net. Mitigated by the separate lower preview cap (2-4) plus opening the socket only while the pane is visible, which puts a typical device at 2-3 total. **Verify by opening several browsers at once and watching for upgrade refusals and for Art-Net/mDNS failures**, which is where exhaustion would show first. +2. **The cap becoming a runaway.** A measured ceiling that only ever rises would rediscover the current problem at a higher resolution. The memory ceiling and the slow-streak backoff must both remain hard bounds. +3. **Two channels, two disconnect paths.** The UI must survive the preview socket dropping while the control socket lives, and vice versa. Pin both. +4. **The DOM-repaint symptom is NOT fixed by this plan** (symptom 2 above). Separating the channels stops preview traffic *delaying* state, but a 2.2/s state push still repaints unchanged controls. That is its own item; do not let this plan claim it. + +## Verification + +- The measurement that opened this plan, repeated: preview KB/s on `/wsp`, control-plane latency on `/ws`, with the preview at maximum resolution. +- `ctest`, the JS suite, scenarios, spec check. +- On the bench: a large layout on an S3 and on the S31, with the preview open, driving the UI at the same time, the case users reported. **The product owner's eyes: the dropdown must stay put and the indicator must stop flickering.** +- A desktop 1024×1024 run to find where the measured cap settles when the link is not the constraint. diff --git a/docs/history/plans/Plan-20260825 - Client-driven preview adaptation (superseded).md b/docs/history/plans/Plan-20260825 - Client-driven preview adaptation (superseded).md new file mode 100644 index 00000000..864835aa --- /dev/null +++ b/docs/history/plans/Plan-20260825 - Client-driven preview adaptation (superseded).md @@ -0,0 +1,154 @@ +# Plan, Client-driven preview adaptation + +(Superseded by the Lean preview transport plan: the fps-band controller and the push-model transport it steered were replaced by the pull model with the drops signal.) + +## Context: a day of device-side guessing + +The preview got its own lossy channel (`/wsp`), a subscriber gate, backpressure and a +per-message frame drop, all sound, all staying. On top of that, the device grew an +adaptation stack that guesses link quality from how fast its socket drains: first +frame-counted streaks (oscillated, 49 rebuilds in 25 s on WiFi), then a wall-clock +window controller with three bands, a starvation detector, a failed-stride memory with +decay, and an adaptive point ceiling with earn/give-back. + +Each fix compensated for the last, and the result still cycles: the ceiling has no +failure memory, so a link that carries 8,192 points but not 16,384 re-earns the higher +ceiling every ~10 good seconds, fails, gives it back, and repeats, bench-confirmed as +"runs okay a few seconds, throttles, sometimes recovers", on a single client with no +network contention. **This is a regression against the pre-controller state**, which the +product owner had judged good. + +The structural error, named by the product owner: **the device is the wrong party to +measure**. It sees only its socket. The browser knows everything that matters, bytes +received, frames rendered, render cost, the `targetFps` the user chose, and whether the +tab is even visible. + +## The decision: the receiver adapts, the device serves + +This is the industry-standard shape for exactly this problem. Adaptive video streaming +(HLS/DASH) settled it years ago: the **receiver** measures its own throughput and +requests a quality level; the server serves what is asked and guesses nothing. +Client-side adaptation is the standard *because* the receiver is the only party that can +measure end-to-end. + +Applied here: + +- The **browser** runs the one controller, in one place, in debuggable JavaScript: it + measures its achieved frame rate against `targetFps` and requests a stride over the + `/wsp` socket it already holds. +- The **device** serves the requested stride and keeps only true floors: the fps send + gate, drop-frame-when-buffer-full (the probe drop), the memory cap, and a **fixed** + display cap. It deletes the entire guessing apparatus. + +What this buys beyond the deletion: no coupled oscillations (each client tunes only +itself), a slow laptop finally gets relief even on a fast link (the robwomp gap, render +cost is now part of the measurement), tab visibility integrates naturally, and adaptation +policy ships with the served UI instead of requiring firmware flashes to tune. + +## Design + +### 1. The uplink message (client → device, on `/wsp`) + +The channel is currently downstream-only. The client gains one message: + + [0x51][stride u8] "serve me every stride-th light" + +Browser WS frames are always masked (RFC 6455); the device unmasks exactly as +`pollWledStateFromWebSockets` already does for the WLED shim. Parsed in the existing +per-tick client poll, off the hot path. Unknown opcodes stay ignored. + +**Multiple clients, v1 rule: the device serves the COARSEST requested stride to all.** +One lattice, one coordinate table, one frame per slot, no per-client render work. A slow +viewer coarsens the shared preview; accepted for v1 (the realistic case is one viewer, +occasionally two) and recorded here as the known trade. Recomputed when a request arrives +and when a client disconnects. + +### 2. The client controller (`preview3d.js`) + +A pure function, so it is unit-testable in `test/js` without a DOM: + + nextStrideState(state, achievedFps, targetFps) + achieved < 20% → coarser immediately (true starvation) + achieved < 60% for 2 windows (4 s) → coarser (double, cap 64); remember current as failed. + Two windows, so a single GC pause never costs a + visible rebuild (bench: one bad window coarsened) + achieved ≥ 80% for 3 windows (6 s) → finer (halve), unless halving lands on failedStride + (skip once, then clear, the decay rule, kept from + the device version because it worked). 80, NOT 95: + sender-slot quantisation and jitter keep a perfect + link under ~95%, and a bar the link cannot clear + strands the preview coarse until a refresh (bench) + otherwise → hold + +Driven every 2 s from the frames the client already counts to render. `targetFps` is read +from the state the UI already has. The status line ("preview 1/N · X fps") keeps working, +the client now *owns* those numbers instead of decoding them. + +### 3. Tab visibility (product-owner ask): a hidden tab costs the device nothing + +`document.visibilitychange` drives BOTH sockets, on two clocks: + +- **`/wsp` closes immediately** on hide, it is the expensive stream, and the driver then + builds no frames at all. +- **`/ws` closes after a ~10 s grace**, the 1 s state pushes (and their per-second tree + serialization) stop too, so the device is left doing nothing but rendering effects. The + grace means a quick alt-tab does not pay a full-state resync on every switch; the push + loop already short-circuits with no clients, so no firmware change is needed for the + saving itself. + +On return: reopen `/ws` first (the full-state resync repaints the UI), then `/wsp` with +the last requested stride. This also eliminates the worst client shape on BOTH channels, +a backgrounded tab whose throttled JS drains at a trickle while staying subscribed. + +### 4. The device simplification (the point) + +**Deleted from `PreviewDriver`:** the window controller (`winStartMs_`, `winFrames_`, +`winSlots_`, `goodWindows_`, `kWindowMs`, `kMinSlots`, `kRefineWindows`, the three bands, +the starvation branch), the adaptive ceiling (`displayCap_` earn/give-back), the failure +memory (`failedStride_`, `lastTarget_`), roughly 150 lines and 8 members, plus their +unit tests. `downscale_` becomes the requested stride (link factor), still combined with +the memory/display cap exactly as today. + +**Kept:** the fps gate (`targetFps` remains the ceiling the device never exceeds), the +lattice + closed-form dense counting + `keptIdx_` cache, `coordPending_` retry, the probe +drop and time-budget send in `HttpServerModule`, the reap, `MAX_PREVIEW_CLIENTS`. + +**Added:** `clients: N` and the served stride in the driver status, the observability the +bench work had to reconstruct with four-probe tricks. + +## Steps + +1. **Device: uplink parse + coarsest-of-clients + status.** Unit tests: a masked 0x51 + frame sets the stride; two clients → coarsest wins; disconnect recomputes; unknown + opcode ignored; status carries `clients`/stride. +2. **Device: delete the controller.** Remove the members, bands and their tests; pin the + new contract with one test: the stride never changes without a request or a rebuild. +3. **Client: `nextStride` + the 2 s loop + request sender.** `test/js` pins the bands, + the failed-stride skip-once-then-clear, and that hidden tabs send nothing. +4. **Client: visibilitychange → both sockets.** Preview closes on hide, control after the + ~10 s grace; return reopens control-then-preview. JS tests pin the wiring, the grace + (a quick hide/show never drops `/ws`), and that a hidden tab sends nothing. +5. **Docs**: driver card (targetFps wording: the client aims for it), architecture § the + channel gains its uplink sentence, MIGRATING note (behavioral, no key changes). +6. **Bench, the regression bar:** all four boards + desktop at 128×128 and 64×64. Success + = settles within ~6 s of a slider move, no cycling over 5 minutes, tab-hide drops + device work to zero (preview instantly, state pushes after the grace, verify with the + connection count and the render fps rising), S3/WiFi shows the coarse-but-smooth trade at high targets and + full-detail-slow at low targets. + +## Risks + +1. **A hostile/buggy uplink**, parsed bytes from the network: bounds-check stride to + [1, 64]; anything else is ignored. The parser is ~10 lines beside an existing one. +2. **Coarsest-of-clients** lets one throttled viewer degrade the shared preview, v1 + trade, documented; per-client lattices are the later fix if it ever bites. +3. **The client controller can be wrong too**, but it is one pure function with unit + tests, hot-reloadable with the page, and it measures the true end-to-end quantity. +4. **Old UI against new firmware** (or reverse): a client that never sends 0x51 gets + stride 1 capped by memory/display caps, the pre-adaptation behavior; a new client + against old firmware sends an opcode the device ignores. Both degrade soft. + +## Verification + +`ctest` + `test/js` + scenarios + spec check; the bench matrix in step 6; and the product +owner's eyes on the S3/WiFi case that has been today's truth-teller. diff --git a/docs/history/plans/Plan-20260825 - Lean preview transport (shipped).md b/docs/history/plans/Plan-20260825 - Lean preview transport (shipped).md new file mode 100644 index 00000000..c1da84c1 --- /dev/null +++ b/docs/history/plans/Plan-20260825 - Lean preview transport (shipped).md @@ -0,0 +1,150 @@ +# Plan, Lean preview transport + +(PR #81: all four steps implemented, gates green, bench acceptance on four boards + desktop.) + +## The evidence that forces this + +One day of bench time produced three symptoms with one root cause: + +1. A reconnect storm on `/wsp` (closes at the browser's ~1.3 s retry cadence, clients dying with + zero frames received). +2. Blank previews after a refresh (zombie slots at the 4-client cap, admission refused). +3. `renderWait` spiking 11 us to ~180,000 us on the Drivers card: WiFi congestion charged to the + LEDs themselves. + +The root cause is the **synchronous send path**: coord tables and downsampled frames stream +all-or-nothing with a 150 ms stall budget, and a stall **closes the client**. Congestion becomes +disconnects; disconnects reset the client's adaptation and re-trigger table streams; the output +core is held for the whole budget. The resumable per-client-cursor path (used by full-res frames) +has none of these problems. We built the right mechanism and then routed half the traffic around +it. + +## Design: one path, one signal, cached geometry + +### 1. One resumable path for every `/wsp` message + +Everything (coord tables, color frames at any stride) is queued as header + stable body and +drained per client by `writeSome` on tick20ms: each socket takes bytes at its own TCP pace, the +render/output path never waits on a socket, and a client is closed **only on a real error or FIN, +never for slowness**. TCP's own flow control is the bandwidth adaptation; the device's job is +only to drop frames at the source (drop-new: a frame offered while the slot drains is skipped). + +Deleted outright: `sendAllOrClose`, the begin/push/end fan-out with its per-client skip mask, +`kDirectSendBudgetMs`, close-on-slow, the coord-stream rate limit, and the reap/admission lease +contention that close-churn made critical. The sender lease shrinks to protecting the arm/drain +handoff of one slot. + +Cost: the coord table and downsampled frames need a stable body. The downsampled path already +gathers into a staging buffer; the coord table gets a small owned buffer (3 bytes per kept point, +at most ~48 KB on the biggest config, allocated at rebuild, freed after drain). + +### 2. Pull, not push: the device is a dumb data producer + +The client steers everything through two requests; the device holds no delivery policy at all, +only its caps and the drain. + +- `[0x51][stride][fps]`, a **standing frame request**: serve me frames at this stride and rate. + Across clients the most conservative request wins (coarsest stride, lowest rate), bounded by + the memory/display caps and the `targetFps` control (the ceiling and the default for a client + that sends only a stride). **No standing request means the device builds nothing at all**, + subsuming today's nobody-watching gate. +- `[0x52][stride]`, a one-shot **table request**: send me the coordinate table for this stride, + paced through the drain like everything else. The device never volunteers a table, so the + entire client-generation machinery (the bump, the re-stream on connect, the reconnect storm it + fed) is deleted; a fresh or reconnecting client simply asks. + +The 0x03 table and 0x02 frames carry a 1-byte **geometry epoch** (bumped on every rebuild). The +client caches tables keyed (epoch, stride); a frame whose (epoch, stride) hits the cache renders +immediately, a miss triggers one `[0x52]` request. A stride change to a cached rung costs zero +table traffic. + +One format for every layout: the point list. A dense-grid shortcut (a closed-form descriptor) +was considered and rejected as a special-case fork, the same call made earlier against special +handling of identity mappings; the table is one-shot and cached, so its size never touches the +steady state. + +Client memory: the full ladder (strides 1..64) sums to about 1.33x the stride-1 table. Worst case +today (16384 points) that is ~65 KB on the wire and ~260 KB as Float32Arrays in the browser. +Browsers handle hundreds of MB; this is a non-issue. + +The channel then carries almost nothing but frames: the lean throughput channel. The UI ships +embedded in the same firmware image, so client and device always match; no skew case exists. + +### The boundary rule: the channel is core, the producer is domain + +Core owns the whole **preview channel**, written once for any domain (a non-light app built on +the core gets its preview transport for free): the `/wsp` lifecycle, per-client OPAQUE standing +request bytes, one-shot request forwarding, the resumable paced drain, drop-new with the drops +counter. Core never interprets a request or a frame. + +A domain owns the **producer**: light's `PreviewDriver` interprets `[stride][fps]`, aggregates +most-conservative across clients, and builds tables and frames. The channel machinery already +lives in `HttpServerModule` (core); this step formalizes the interface rather than moving code. + +### 3. Adaptation: a sender-side congestion signal, not probing + +The device knows precisely when the link is behind: it **drops a frame at the arm** because the +previous one has not drained. That is a direct backpressure signal, so put it on the wire: the +0x02 header gains a 1-byte **drops-since-last-frame** counter. + +The client controller becomes two rules (replacing the bands, the coarsen-must-pay audit, the +source-limit hold and the failed-stride memory, all of which existed to compensate for a blind +fps-only measurement): + +- **Coarsen** when drops stay nonzero over a window: the device is discarding frames, so the + frames are too big for the link at this rate. Effective fps stays near target and the picture + coarsens, the trade `targetFps` advertises. +- **Refine** cautiously when a window is clean (zero drops) and the achieved rate is near target: + step one rung finer. If drops reappear, step back and **back off exponentially** on further + refine attempts (the abandon-fast, retry-slowly rule ABR players use against oscillation). + A renderer-limited device (a heavy effect at 6 fps) reports zero drops at full detail, so the + client correctly never coarsens: the case the audit machinery existed for, now free. + +Closed-form seeding: frame size per rung is known (`count(stride) * 3 + 7`), so on connect the +client picks the finest rung whose `size * targetFps` fits the last measured byte throughput +(EWMA), with a 0.8 safety factor, instead of starting blind at stride 1 and stalling the link on +the first frame after every refresh. + +Self-repair: the client re-sends its standing request after ~2 s without a frame. A device +reboot (standing requests lost) or a dropped uplink heals itself; a viewer never sits in front +of a silently dead preview. + +**The invariant, which is also the acceptance bar:** at the chosen `targetFps`, degradation +sheds SIZE first (the stride ladder, non-oscillating via backoff); when even the coarsest stride +cannot hold the rate, the RATE sags through drop-new. Never a stall, never a disconnect for +slowness, never an absent preview while a viewer stands. Higher target = smaller and smoother; +lower target = finer and slower; the balance always holds. + +### 4. What stays + +The `/ws` state slot (StateSend), coarsest-of-clients (now most-conservative-of-clients), the +tab hibernation, the memory/display point caps. `targetFps` stays as the device-side ceiling +control with its range tightened to 1-25 (default 24): the 20 ms drain cadence tops out near +50 fps anyway, and 25 is more than a preview needs, so the range now promises only what the +transport comfortably delivers. + +## Steps + +1. **Transport**: route the coord table and downsampled frames through the resumable slot (owned + staging bodies); delete the synchronous path and its budget/skip/close machinery; close only + on error/FIN. The lease shrinks accordingly. *Test*: a wedged mock client never blocks a tick + and is never closed for slowness; unit drain tests cover multi-message sequencing. +2. **Wire**: the `[0x51][stride][fps]` standing request and `[0x52][stride]` table request; + epoch byte in 0x03/0x02, drops counter in 0x02; the device answers requests and volunteers + nothing (client-generation watching deleted). *Test*: header layout pinned; no request means + no frames; a stride flip with a cached table sends no 0x03; a table request is answered once; + every layout answers with the one point-list format. +3. **Client**: per-(epoch, stride) table cache; the two-rule controller with exponential refine + backoff and throughput seeding; delete the old band machinery from `preview-adapt.js` (the + pure-function + test shape stays). *Test*: drops coarsen, clean windows refine, a failed + refine backs off 2x each retry, renderer-limited holds full detail, dead link settles without + flapping. +4. **Bench, the acceptance bar**: on the S3/WiFi with two viewers: `renderWait` flat (< 10 ms + always), zero `/wsp` closes over 10 minutes of steady viewing, refresh shows the preview + within a second every time, stride settles without oscillation at target 60 and returns to + full detail at target 5. Then the other three boards + desktop. + +## Verification + +`ctest` + `test/js` + scenarios + spec check; the step-4 bench matrix; the product owner's eyes +on the S3/WiFi case, which has been the truth-teller all day. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 02ce0c68..eda3da80 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,13 +1,13 @@ { - "commit": "6b9d520d", + "commit": "09cfafa4", "flash": { - "esp32s3-n16r8": 1821104, - "desktop": 1247704, - "esp32": 1764416, - "esp32p4rev1-eth": 1653696, + "esp32s3-n16r8": 1826976, + "desktop": 1251256, + "esp32": 1784368, + "esp32p4rev1-eth": 1675216, "esp32p4rev1-eth-wifi": 1933472, "esp32s3-n8r8": 1753232, - "esp32s31": 2079904, + "esp32s31": 2105072, "esp32-16mb": 1714608, "esp32-eth": 1324816, "esp32-wrover": 1765504, @@ -16,8 +16,8 @@ }, "perf": { "desktop": { - "tick_us": 131, - "fps": 7633 + "tick_us": 237, + "fps": 4219 }, "esp32": { "tick_us": 2151, @@ -25,54 +25,54 @@ } }, "loc": { - "core": 20194, - "light": 25878, - "platform": 15161, - "ui": 7047, - "test": 46933, - "moondeck": 21848 + "core": 20319, + "light": 26068, + "platform": 15453, + "ui": 7378, + "test": 47189, + "moondeck": 21856 }, "comments": { "core": { - "lines": 7988, + "lines": 8039, "ratio": 0.428 }, "light": { - "lines": 10275, - "ratio": 0.438 + "lines": 10319, + "ratio": 0.437 }, "platform": { - "lines": 5389, - "ratio": 0.39 + "lines": 5467, + "ratio": 0.388 }, "ui": { - "lines": 1874, - "ratio": 0.282 + "lines": 1979, + "ratio": 0.285 }, "test": { - "lines": 8694, - "ratio": 0.213 + "lines": 8698, + "ratio": 0.212 }, "moondeck": { - "lines": 3530, + "lines": 3532, "ratio": 0.185 } }, "tests": { - "cases": 1571, + "cases": 1579, "scenarios": 23 }, "docs": { - "md_files": 192, - "md_lines": 28136, - "plans_files": 98, - "backlog_lines": 4451, + "md_files": 196, + "md_lines": 28919, + "plans_files": 102, + "backlog_lines": 4526, "lessons_lines": 606, "claude_md_lines": 136 }, "complexity": { - "functions": 2745, - "over_threshold": 168, + "functions": 2769, + "over_threshold": 172, "worst_ccn": 108 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index c9e2e05a..806660a7 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `6b9d520d`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `09cfafa4`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,60 +8,60 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 1,218 KB (+16 KB) ⚠ | -| esp32 | 1,723 KB | +| desktop | 1,222 KB (−0 KB) ✓ | +| esp32 | 1,743 KB (−1 KB) ✓ | | esp32-16mb | 1,674 KB | | esp32-eth | 1,294 KB | | esp32-wrover | 1,724 KB | -| esp32p4rev1-eth | 1,615 KB | +| esp32p4rev1-eth | 1,636 KB (−1 KB) ✓ | | esp32p4rev1-eth-wifi | 1,888 KB | | esp32p4rev3-eth | 1,605 KB | -| esp32s3-n16r8 | 1,778 KB (+0 KB) ⚠ | +| esp32s3-n16r8 | 1,784 KB (−1 KB) ✓ | | esp32s3-n8r8 | 1,712 KB | -| esp32s31 | 2,031 KB | +| esp32s31 | 2,056 KB (−1 KB) ✓ | | qemu | 1,287 KB | ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 131 µs (−128 µs) ✓ | 7,633 (+3,772) ✓ | +| desktop | 237 µs (+6 µs) ⚠ | 4,219 (−110) ⚠ | | esp32 | 2,151 µs | 464 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 20,194 (+77) ⚠ | 7,988 | 42.8 % (+0.2 %) ⚠ | -| light | 25,878 (+5) ⚠ | 10,275 | 43.8 % | -| platform | 15,161 (+33) ⚠ | 5,389 | 39.0 % | -| ui | 7,047 | 1,874 | 28.2 % | -| test | 46,933 (+141) ⚠ | 8,694 | 21.3 % (+0.1 %) ⚠ | -| moondeck | 21,848 (+1) ⚠ | 3,530 | 18.5 % | +| core | 20,319 (−112) ✓ | 8,039 | 42.8 % | +| light | 26,068 (+50) ⚠ | 10,319 | 43.7 % (−0.1 %) ✓ | +| platform | 15,453 | 5,467 | 38.8 % | +| ui | 7,378 (+18) ⚠ | 1,979 | 28.5 % (−0.1 %) ✓ | +| test | 47,189 (+63) ⚠ | 8,698 | 21.2 % | +| moondeck | 21,856 (+7) ⚠ | 3,532 | 18.5 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,571 (+6) ✓ | +| unit cases | 1,579 (+2) ✓ | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,745 | -| over threshold | 168 | +| functions | 2,769 (+2) ✓ | +| over threshold | 172 (+1) ⚠ | | worst CCN | 108 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 192 | -| markdown lines | 28,136 (+47) ⚠ | -| plan files | 98 | -| backlog lines | 4,451 | -| lessons lines | 606 (+14) ⚠ | +| markdown files | 196 (+1) ⚠ | +| markdown lines | 28,919 (+148) ⚠ | +| plan files | 102 (+1) ⚠ | +| backlog lines | 4,526 | +| lessons lines | 606 | | CLAUDE.md lines | 136 | diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index 4b963737..76f934fd 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -142,9 +142,11 @@ Detail: [technical](moxygen/HueDriver.md) PreviewDriver controls -Streams a true-shape 3D preview to the web UI over WebSocket as a **point list** — only the real lights at their real positions, so a sphere/ring/arbitrary map shows in its true shape. The one boot-wired driver. +Streams a true-shape 3D preview to the web UI as a **point list**, only the real lights at their real positions, so a sphere/ring/arbitrary map shows in its true shape. The one boot-wired driver. -- `fps` — preview stream rate (default 24, 1–60; independent of the render loop). +It streams on its **own WebSocket channel** (`/wsp`), so a large frame never delays the control plane, and it runs only while a viewer requests it: dismissing the preview or leaving the tab stops the work at the source entirely. The device reports dropped frames in each frame it sends; your browser trades detail for rate on that signal, so a fast connection previews finer than a slow one. See [§ Preview, details](#preview-details). + +- `targetFps`, the frame rate the preview aims for (default 24, 1–60). The device never sends faster; when the connection cannot keep up, the browser trades detail to get closer: **lower it for full detail at a slower rate, raise it for a smoother but coarser preview**. Origin: projectMM, on [MoonLight](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Layers/PhysicalLayer.h)'s PhysicalLayer model @@ -156,29 +158,53 @@ Detail: [technical](moxygen/PreviewDriver.md) ### NDI 🖥️ · video out -Publishes the layer as an **NDI video source**, so OBS, Resolume, TouchDesigner, MadMapper or any other NDI receiver can pick projectMM up by name — on this machine or another one on the network. Where the Preview driver draws the lights for a person, this hands the same frame to a production tool as video. - -The grid's `physicalWidth` × `physicalHeight` becomes the frame; each light is one pixel, with the driver's own output correction applied so a receiver sees what the wall sees. - -**Desktop only.** The NDI runtime is a desktop library with no microcontroller build, so the driver is offered on macOS, Windows and Linux and not on an ESP32. +Publishes the layer as an **NDI video source**, so OBS, Resolume, TouchDesigner or any other NDI receiver picks projectMM up by name, on this machine or another on the network. Where the Preview driver draws the lights for a person, this hands the same frame to a production tool as video. -**You install the runtime; projectMM never ships it.** projectMM is GPL-3.0 and the NDI runtime is proprietary, so it is loaded on demand and never bundled — the same arrangement as Npcap for the [Panel Card](#panelcard) driver. Without it the driver simply reports `NDI runtime not installed`; nothing else changes. +The grid's `physicalWidth` × `physicalHeight` becomes the frame, one light per pixel, with the driver's own output correction applied so a receiver sees what the wall sees. -- **macOS** — install [NDI Tools](https://ndi.video/tools/) (free). It ships the runtime inside its app bundles rather than system-wide, which projectMM knows to look for. A Resolume install also carries one. -- **Windows** — the [NDI Tools](https://ndi.video/tools/) or SDK installer puts `Processing.NDI.Lib.x64.dll` on the PATH. -- **Linux** — install the NDI SDK; projectMM looks for `libndi.so.5`, `libndi.so.6` and `libndi.so`. - -To watch the output you need a receiver: **NDI Video Monitor** (part of NDI Tools) is the simplest, and OBS gains an "NDI Source" via the [DistroAV](https://github.com/DistroAV/DistroAV) plugin. +**Desktop only**, and **you install the NDI runtime yourself**, projectMM never ships it. Without it the driver reports `NDI runtime not installed` and nothing else changes. See [§ NDI, details](#ndi-details). - `sourceName` — the name a receiver lists. Blank uses the device's own name. - `fps` — frame-rate ceiling (default 30, 1–120). The driver sends no faster than this and declares the rate in every frame. -Status tells you where you are: `NDI runtime not installed` (install it), `could not create the NDI source` (the runtime is there but refused), or `sending x at fps` when it is live. - Origin: projectMM, against NewTek/Vizrt's documented NDI C API Detail: [technical](moxygen/NdiDriver.md) + + +## Preview, details + +**Close the preview when you do not need it.** The device renders preview frames only while the preview pane is open. Dismissing it stops that work entirely, which frees the device for rendering and keeps the UI responsive on a large layout, worth doing while you are editing effects on a big wall. + +**The preview thins itself out.** When the connection cannot carry full detail, the preview shows a regular sample of the lights rather than all, the status reads `preview 1/4` and so on. The device reports every frame it had to drop, and your browser reacts: persistent drops trade detail for rate, drop-free stretches earn the detail back one step at a time, and a step that brings the drops back is taken back with growing patience, so a borderline connection settles instead of flickering between sizes. A slow *effect* drops nothing, so it never costs preview detail. A fast connection previews everything, with nothing to configure. + +**If the preview looks choppy**, it is the connection rather than the device: frames are dropped rather than queued, so the wall itself is never held up by the preview. Lower `targetFps` if you would rather keep full detail at a slower rate. + + + +## NDI, details + +**You install the NDI runtime yourself**, projectMM cannot ship it. Until you do, the driver reports `NDI runtime not installed` and everything else works normally. + +| OS | Where it comes from | +|---|---| +| macOS | [NDI Tools](https://ndi.video/tools/) (free). It puts the runtime inside its app bundles rather than system-wide, which projectMM knows to look for; a Resolume install also carries one. | +| Windows | The [NDI Tools](https://ndi.video/tools/) or SDK installer puts `Processing.NDI.Lib.x64.dll` on the PATH. | +| Linux | The NDI SDK. | + +**To see the output** you need a receiver. **NDI Video Monitor** (part of NDI Tools) is the simplest; OBS gains an "NDI Source" via the [DistroAV](https://github.com/DistroAV/DistroAV) plugin. projectMM appears by the name in `sourceName`, or the device's own name when that is blank. + +**Desktop only.** No NDI runtime exists for the ESP32 chips, so the driver is not offered there. An ESP32 reaches the same tools over Art-Net, sACN or DDP instead, send with the [Network Send](#networksend) driver, receive with the NetworkReceive effect. + +**Status line** + +| It says | It means | +|---|---| +| `NDI runtime not installed` | Install it, per the table above | +| `could not create the NDI source` | The runtime is there but refused, usually a name clash with another source | +| `sending x at fps` | Live; look for it in your receiver | + ## LED driver — details **Which driver?** @@ -196,7 +222,7 @@ RMT is its own driver; the rest are `peripheral` choices on the one **Parallel L **RMT vs the three parallel peripherals.** All drive WS2812B-class strips with the same `pins` / `ledsPerPin` / `loopback*` controls and the same wire contract; they differ in parallelism, chip, and — for the two i80-bus peripherals (**i80** and **MoonI80**) — in who programs the DMA. -**Lane, pin, strand.** A **lane** is one bus data line; a **strand** is one chain of LEDs. The i80 **bus** is 8 or 16 lanes wide (a hardware fact — `lcd_ll_set_data_wire_width` takes nothing else), but you configure only the **pins** that drive something, at any count from 1: the driver rounds the bus up around them and parks the spare lanes on a pin the peripheral already drives, where nothing reads them. +**Lane, pin, strand.** A **lane** is one bus data line; a **strand** is one chain of LEDs. The i80 **bus** is 8 or 16 lanes wide (a hardware fact, not a setting), but you configure only the **pins** that drive something, at any count from 1: the driver rounds the bus up around them and parks the spare lanes on a pin the peripheral already drives, where nothing reads them. - **Direct:** one pin = one lane = one strand. 1–16 strands. - **Through an expander:** each data pin feeds one '595 and fans out to 8 strands, so **1–8 data pins → up to 64 strands** (the driver's ceiling). The **latch** also costs a lane — the peripheral has only one clock output, so it has to ride a data line — but the strand ceiling binds first. hpwit's board populates 6 pins → **48 strands**. diff --git a/docs/performance.md b/docs/performance.md index 6eb7bfc5..ba55e946 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -386,6 +386,8 @@ The `multicore` control on the Drivers container runs **every driver's per-frame **Calling the network stack from core 1 costs ~100 µs/frame, and it does not matter.** lwIP is pinned to core 0 (`CONFIG_ESP_WIFI_TASK_PINNED_TO_CORE_0`), so a driver that writes a socket still hands its bytes to the network task there — only the *CPU half* (packet / frame building) offloads. The cross-core lock and cache bouncing show up as Preview 49 → 91 µs and HttpServer 348 → 409 µs. That ~100 µs is set against the ~13,000 µs of output work removed from core 0 — a 130:1 trade, which is why no driver is special-cased: when the split is on, **all** of them move. +**The pull-model preview transport bounds those Preview numbers from above.** Since the preview became a pull channel (one resumable drain, no socket writes from the encode thread at all), `PreviewDriver::tick` on core 1 only gathers and ARMS a message — every socket byte moves on the transport's tick20ms on core 0, paced by TCP. Two consequences the table rows cannot show: with no standing client request the preview costs **zero** (the tick returns before any work, so an unwatched device spends nothing), and a congested link no longer touches the render path at all — `renderWait` stays at its normal few ms where the prior direct-send path could hold the output stage up to its ~150 ms stall budget (bench-observed as 180,000 µs renderWait spikes on S3/WiFi, gone after the change; four boards then held ~12 minutes of continuous viewing with zero socket closes). + **It also removes a contention.** A ~19 ms inline encode on core 0 starves the network stack sharing that core: the LightCrafter 16's W5500 Ethernet drops its link and HTTP times out while the render loop keeps ticking. With the encode on core 1, an HTTP hammer during a heavy 8192-light encode holds: 77 requests, median 163 ms, one timeout. **Per-chip `renderWait`** — the read-only KPI reporting the worst core-0 wait at the frame boundary in the last second. It says how much idle time a *second* handoff buffer (the deferred ping-pong step) would recover, so the decision is measured rather than assumed: diff --git a/moondeck/event/precommit.py b/moondeck/event/precommit.py index 3efa6434..a314c3c6 100644 --- a/moondeck/event/precommit.py +++ b/moondeck/event/precommit.py @@ -64,7 +64,7 @@ def build_gates(firmware, full_esp32=False): Gate("host tests (JS)", ["node", "--test", "test/js/**/*.test.mjs"], - lambda f: touches(f, "web-installer/", "test/js/")), + lambda f: touches(f, "web-installer/", "test/js/", "src/ui/")), # Needs a board plugged in, so it is recommended rather than blocking. Its trigger # is the provisioning path it covers; run_gates drops it from the report entirely diff --git a/moondeck/scenario/_preview_ws.py b/moondeck/scenario/_preview_ws.py index c01a6bcd..826ad99a 100644 --- a/moondeck/scenario/_preview_ws.py +++ b/moondeck/scenario/_preview_ws.py @@ -1,11 +1,12 @@ -"""Minimal RFC 6455 WebSocket client for the device's /ws preview stream. +"""Minimal RFC 6455 WebSocket client for the device's /wsp preview stream. Stdlib-only (socket/base64/os/time) — the live test scripts must run anywhere uv runs, with no third-party deps. Sibling-private helper like _observed.py. -The device pushes two things on /ws: full-state JSON as text frames (~1 Hz) and +The device serves TWO WebSocket paths: `/ws` (control plane, JSON state) and `/wsp` +(the lossy preview channel, binary only). This reader takes `/wsp`, so it sees PreviewDriver binary frames — 0x03 coordinate tables and 0x02 RGB frames -(`[0x02][count u16 LE][stride u16 LE][rgb × count]`, see +(`[0x02][count u32 LE][stride u16 LE][epoch u8][drops u8][rgb x count]`, a 9-byte header, see src/light/drivers/PreviewDriver.h). This reader skips everything except 0x02. Two simplifications the firmware guarantees (HttpServerModule.cpp): @@ -24,14 +25,14 @@ class PreviewSocket: - """One /ws connection. `host` is the device's HTTP address ("ip[:port]").""" + """One /wsp connection. `host` is the device's HTTP address ("ip[:port]").""" def __init__(self, host: str, timeout_s: float = 5.0): h, _, p = host.partition(":") self.sock = socket.create_connection((h, int(p or 80)), timeout=timeout_s) key = base64.b64encode(os.urandom(16)).decode() self.sock.sendall( - (f"GET /ws HTTP/1.1\r\nHost: {host}\r\n" + (f"GET /wsp HTTP/1.1\r\nHost: {host}\r\n" f"Upgrade: websocket\r\nConnection: Upgrade\r\n" f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode()) raw = b"" @@ -49,6 +50,13 @@ def __init__(self, host: str, timeout_s: float = 5.0): + head.split(b"\r\n", 1)[0].decode(errors="replace")) # Bytes after the handshake headers are already the first frame(s). self._buf = rest + # The pull model: a client that asks for nothing receives nothing. Post the standing + # frame request ([0x51][stride][fps]) as a masked client frame (RFC 6455 requires it). + mask = os.urandom(4) + payload = bytes([0x51, 1, 25]) + frame = bytes([0x82, 0x80 | len(payload)]) + mask + bytes( + b ^ mask[i % 4] for i, b in enumerate(payload)) + self.sock.sendall(frame) # Status 101 is proof enough; skipping the Sec-WebSocket-Accept check # saves the SHA-1 dance against our own firmware. @@ -98,8 +106,8 @@ def wait_for_solid(host: str, rgb, tolerance: int = 0, min_match_pct: float = 10 break if opcode != 0x2 or not payload or payload[0] != 0x02: continue # text/state frame or 0x03 coordinate table - count = payload[1] | (payload[2] << 8) - triples = payload[5:5 + count * 3] + count = payload[1] | (payload[2] << 8) | (payload[3] << 16) | (payload[4] << 24) + triples = payload[9:9 + count * 3] if count == 0 or len(triples) < count * 3: continue matched = 0 diff --git a/src/core/BinaryBroadcaster.h b/src/core/BinaryBroadcaster.h index 29c1269c..bf364177 100644 --- a/src/core/BinaryBroadcaster.h +++ b/src/core/BinaryBroadcaster.h @@ -11,20 +11,6 @@ namespace mm { // producer depends only on "something I can send bytes to" — not on the HTTP // server's full surface. Domain-neutral: the bytes' meaning is the caller's. struct BinaryBroadcaster { - // Stream ONE binary WS frame whose payload is PUSHED incrementally, so the caller never - // holds the whole frame in a buffer. Begin/push/end trio, fitting a forward-only producer - // like Layouts::placeLights (push from inside its callback): - // beginBinaryFrame(totalLen) — build + send the WS header (totalLen = exact payload size) - // pushBinaryFrame(data, len) — send the next payload slice (call as many times as needed) - // endBinaryFrame() — finish; returns true if every client got the whole frame - // The implementation streams straight to the clients with no frame-sized staging buffer, so a - // large frame (e.g. PreviewDriver's coordinate table, tens of KB) goes out on a memory-tight - // board where a contiguous staging block won't fit. The caller MUST push exactly `totalLen` - // bytes between begin and end. Only one frame may be open at a time. - virtual void beginBinaryFrame(size_t totalLen) = 0; - virtual void pushBinaryFrame(const uint8_t* data, size_t len) = 0; - virtual bool endBinaryFrame() = 0; - // RESUMABLE one-frame send for a payload that lives in a STABLE caller-owned buffer (no copy): // one WS message = `header` (copied — small, may be a stack local) followed by `body` (a pointer // the caller keeps stable until the send completes or is cancelled). The implementation drains it @@ -39,32 +25,44 @@ struct BinaryBroadcaster { // effective frame rate self-limits to what the link sustains. // cancelBufferedSend() — abandon the in-flight send NOW. The caller calls this before it // frees/reallocates the `body` buffer (a geometry rebuild), keeping a - // cursor reading only live memory. The sole caller today cancels on a - // new-client connect, which also bumps clientGeneration() and re-sends - // a fresh coordinate table — so a client that got a partial frame is - // re-primed by the next full message. + // cursor reading only live memory. A client caught mid-message is + // closed by the implementation (the only honest exit once bytes are + // out); it reconnects and the generation bump primes it fresh. // Only PreviewDriver uses this today (the color frames: full-res hands the producer buffer, - // downsampled hands its gathered staging buffer). The coord table keeps the begin/push/end path - // (rare — geometry/client changes only). + // downsampled and the coord table hand their gathered staging buffers), so every /wsp message + // rides the one paced path. virtual bool sendBufferedFrame(const uint8_t* header, size_t headerLen, const uint8_t* body, size_t bodyLen) = 0; virtual bool bufferedSendIdle() const = 0; virtual void cancelBufferedSend() = 0; - // A counter that increments each time a new client connects. A producer whose - // first message is stateful (e.g. PreviewDriver's coordinate table, which color - // frames then reference) watches this: when it changes, a fresh client just joined - // and needs that priming message re-sent NOW, rather than waiting for the producer's - // periodic re-broadcast. Cheap, broadcast-only (no per-client send / inbound routing): - // the producer re-broadcasts to everyone, idempotent on existing clients. - virtual uint32_t clientGeneration() const = 0; + + + // How many subscribers are listening right now. Purely observational (a status line, a log); + // producers must not branch per subscriber through this, the channel stays broadcast-only. + virtual int subscriberCount() const { return 0; } + + // Inbound client messages, delivered OPAQUELY: the transport unmasks a client's WS frame + // (framing is its job) and hands the payload bytes to the registered sink; only the producer + // knows what they mean. onClientGone fires when a client's slot closes or turns over, so a + // producer keeping per-slot standing state (the preview's [stride][fps] request) can drop it + // with the client. Both fire on the transport's own thread (core 0 under the split); a + // producer ticking elsewhere stores single-byte fields the reader tolerates racing on, the + // lossy-channel rule. + struct ClientMessageSink { + virtual void onClientMessage(int slot, const uint8_t* payload, int len) = 0; + virtual void onClientGone(int slot) = 0; + protected: + ~ClientMessageSink() = default; + }; + virtual void setClientMessageSink(ClientMessageSink* sink) { (void)sink; } // Exclusive access to the sender, for a producer that does NOT run on the transport's own thread. - // The multicore split (Drivers `multicore`) ticks the offloaded PreviewDriver on core 1 while this - // transport drains and pushes state on core 0 — two producers, two cores, one socket set and one - // resumable send slot. A producer therefore brackets its whole message in tryAcquire/releaseSend: - // a multi-call stream (begin/push/end) must not have another core's write land between its parts, - // and a frame arm must not race the drain that is reading the slot. + // The multicore split (Drivers `multicore`) ticks the offloaded PreviewDriver on core 1 while + // this transport drains, reaps and admits on core 0: two producers, two cores, one preview + // socket set and one resumable send slot (the control channel stays core-0-only and outside + // this lease). A producer therefore brackets its whole message in tryAcquire/releaseSend: + // a frame arm must not race the drain that is reading the slot. // // TRY-acquire, never block: the caller may be on the render or encode thread, where blocking is a // hot-path violation (CLAUDE.md § Hot path). false = the transport is busy this instant → SKIP the diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 9be7bac1..660fb129 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -56,10 +56,21 @@ void HttpServerModule::onSchemaChanged() { } void HttpServerModule::release() { - // Drop any in-flight send before the clients go (frees an owned state-frame body; a preview frame - // borrows its buffer, nothing to free) — the same self-safe drop cancelBufferedSend() does. + // Drop the in-flight sends before the clients go: the preview frame borrows its buffer + // (nothing to free); the state frame owns its JSON body. cancelBufferedSend(); + if (stateSend_.active) { + platform::free(const_cast(stateSend_.body)); + stateSend_.body = nullptr; + stateSend_.active = false; + } for (auto& ws : wsClients_) ws.close(); + // Every close site notifies the producer (see cancelBufferedSend): without this, ghost + // standing requests would keep the driver gathering frames for nobody after a release. + for (int i = 0; i < MAX_PREVIEW_CLIENTS; i++) { + if (previewClients_[i].valid() && clientSink_) clientSink_->onClientGone(i); + previewClients_[i].close(); + } server_.close(); if (instance_ == this) { MoonModule::setSchemaChangedHook(nullptr); instance_ = nullptr; } MoonModule::release(); // chain: uniform override-and-chain (no buffers/children today, but the convention holds) @@ -75,10 +86,10 @@ void HttpServerModule::tick20ms() MM_NONBLOCKING { // (architecture.md § Parallelism). Drain BEFORE accept so a connection burst can't starve an // active send. No-op when nothing is in flight. drainPreviewSend(); + drainStateSend(); // Fast-path a PENDING FULL RESYNC on the 20 ms cadence instead of waiting for the 1 s tick: a // fresh WS connect (or a structural change) sets fullResyncPending_, and the client shows NOTHING - // until the full state arrives — including no preview, since a preview frame can't take the shared - // send slot before the state does. Gated on the flag, so this is a rare event (a connect), not a + // until the full state arrives. Gated on the flag, so this is a rare event (a connect), not a // per-20 ms serialize: the expensive buildStateJson runs only when a resync is actually pending, // and the steady-state value patch stays on tick1s (unchanged). Cuts connect→first-preview latency // from up to ~1 s + drain down to a few tens of ms. No-op in the common (no-resync) case. @@ -96,9 +107,9 @@ void HttpServerModule::tick20ms() MM_NONBLOCKING { // connection the instant the backlog is empty, which breaks the loop early in the common idle case. constexpr int kAcceptsPerTick = 8; // Bound the batch by WALL-CLOCK too, not just count: each handleConnection serves synchronously and a - // stalled peer can burn up to the write deadline (TcpConnection::write's ~2 s) per connection, so 8 - // stalled clients in one tick could stack to ~16 s — past the task WDT. Break once the batch has spent - // this budget; the remaining backlog drains on the next tick. Subtraction-based compare, rollover-safe. + // stalled peer can burn up to TcpConnection::write's total ceiling (8 s) per connection, so a batch of + // stalled clients could stack past the task WDT. Break once the batch has spent this budget; the + // remaining backlog drains on the next tick. Subtraction-based compare, rollover-safe. constexpr uint32_t kAcceptBudgetMs = 100; const uint32_t batchStart = platform::millis(); for (int i = 0; i < kAcceptsPerTick; i++) { @@ -224,10 +235,14 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { if (queryStart) *queryStart = 0; // Check for WebSocket upgrade (case-insensitive header check) - if (std::strcmp(method, "GET") == 0 && std::strcmp(path, "/ws") == 0 && + // Two WebSocket paths: `/ws` is the control plane (JSON state), `/wsp` the lossy binary + // preview channel. Separate connections so a large preview frame cannot delay a state push. + const bool isWs = std::strcmp(path, "/ws") == 0; + const bool isWsp = std::strcmp(path, "/wsp") == 0; + if (std::strcmp(method, "GET") == 0 && (isWs || isWsp) && (std::strstr(req, "Upgrade: websocket") || std::strstr(req, "upgrade: websocket") || std::strstr(req, "Upgrade: WebSocket"))) { - handleWebSocketUpgrade(conn, req); + handleWebSocketUpgrade(conn, req, isWsp); return; // don't close — connection is now a WebSocket } @@ -242,6 +257,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { else if (std::strcmp(path, "/install-picker.js") == 0) serveFile(conn, "install-picker.js", "application/javascript"); else if (std::strcmp(path, "/semver.js") == 0) serveFile(conn, "semver.js", "application/javascript"); else if (std::strcmp(path, "/preview3d.js") == 0) serveFile(conn, "preview3d.js", "application/javascript"); + else if (std::strcmp(path, "/preview-adapt.js") == 0) serveFile(conn, "preview-adapt.js", "application/javascript"); else if (std::strcmp(path, "/style.css") == 0) serveFile(conn, "style.css", "text/css"); else if (std::strcmp(path, "/moonlight-logo.png") == 0) serveFile(conn, "moonlight-logo.png", "image/png"); else if (std::strcmp(path, "/api/state") == 0) serveState(conn); @@ -828,6 +844,7 @@ void HttpServerModule::serveFile(platform::TcpConnection& conn, const char* file else if (std::strcmp(filename, "install-picker.js") == 0) { data = ui::installPickerJs; dataLen = ui::installPickerJsLen; gzipped = true; } else if (std::strcmp(filename, "semver.js") == 0) { data = ui::semverJs; dataLen = ui::semverJsLen; gzipped = true; } else if (std::strcmp(filename, "preview3d.js") == 0) { data = ui::preview3dJs; dataLen = ui::preview3dJsLen; gzipped = true; } + else if (std::strcmp(filename, "preview-adapt.js") == 0) { data = ui::previewAdaptJs; dataLen = ui::previewAdaptJsLen; gzipped = true; } else if (std::strcmp(filename, "style.css") == 0) { data = ui::styleCss; dataLen = ui::styleCssLen; gzipped = true; } else if (std::strcmp(filename, "moonlight-logo.png") == 0) { data = ui::logoPng; dataLen = ui::logoPngLen; } @@ -2321,7 +2338,8 @@ void HttpServerModule::handleFirmwareUrl(platform::TcpConnection& conn, const ch sendResponse(conn, 202, "application/json", "{\"ok\":true}"); } -void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, const char* req) { +void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, const char* req, + bool previewChannel) { // Extract Sec-WebSocket-Key const char* keyHeader = std::strstr(req, "Sec-WebSocket-Key: "); if (!keyHeader) { conn.close(); return; } @@ -2353,17 +2371,46 @@ void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, con acceptKey); conn.write(reinterpret_cast(response), respLen); + // A preview client lands in its own, smaller array: the two channels have separate caps because + // they share one lwIP socket budget (see MAX_PREVIEW_CLIENTS). A preview client needs none of the + // control-plane bookkeeping below, no state resync, no generation bump, because it receives + // only binary frames the driver pushes. + if (previewChannel) { + // The slot array and previewSend_ bookkeeping are shared with core 1 (which arms frames + // under the same lease), so the cursor decision below needs it. Busy is the COMMON case + // (core 1 streams most ticks), so refusing the connection there made the browser retry on + // its ~1.2 s backoff over and over, a bench-visible storm of zero-frame reconnects. + // Instead ADMIT unconditionally and, when the lease is busy, take the conservative cursor: + // "this message is already done for me". That is exactly what the newcomer needs (its + // stream starts at the next whole frame) and it is safe without reading previewSend_'s + // lengths, since a cursor at SIZE_MAX is past any total the drain computes. + LockGuard admitLease{wsLock_}; + for (int i = 0; i < MAX_PREVIEW_CLIENTS; i++) { + if (previewClients_[i].valid()) continue; + previewClients_[i] = std::move(conn); + // The slot turns over: the producer drops its predecessor's standing request. The new + // client announces its own wishes itself (the pull model), so nothing is volunteered. + if (clientSink_) clientSink_->onClientGone(i); + // A frame mid-drain to OTHER clients keeps draining; this slot marks itself already + // done so the newcomer is never spliced into a half-sent message (its stream starts + // with the next whole frame). Cancelling the send instead abandoned the frame + // mid-message for every existing viewer, a torn stream on their side. + previewSend_.sent[i] = !admitLease ? SIZE_MAX + : (previewSend_.active ? previewSend_.hdrLen + previewSend_.bodyLen : 0); + return; + } + conn.close(); // preview cap reached: the client keeps /ws and simply shows no preview + return; + } + // Store connection as WebSocket client. for (int i = 0; i < MAX_WS_CLIENTS; i++) { if (!wsClients_[i].valid()) { wsClients_[i] = std::move(conn); - previewSend_.sent[i] = 0; // fresh slot: clear any stale cursor a prior client left here - // Abandon any in-flight buffered frame: this new client would otherwise either be skipped - // (its stale cursor ≥ total looked "done", so it got no frame and the browser showed - // nothing) or spliced into a half-sent message. Cancelling makes the next frame start - // clean for every client. The generation bump re-streams the coord table first. - previewSend_.active = false; - wsClientGeneration_++; + // A full state mid-drain to other clients is exactly what this newcomer needs too: its + // cursor starts at 0, so it receives the whole in-flight message from the top, no + // splice, no cancel. requestFullResync below still queues a fresh one for everyone. + stateSend_.sent[i] = 0; // A new client needs the FULL state, not a patch against a baseline it never received. // Global cache → resync everyone (cheap, connects are rare); the next push sends full state. requestFullResync(); @@ -2390,18 +2437,12 @@ void HttpServerModule::pushStateToWebSockets() { // to drain in chunks on tick20ms, NOT a blocking write on the render tick. buildStateJson // serialises the WHOLE tree — the expensive path — but only when fullResyncPending_, not every // second. - // The shared send slot may hold an in-flight frame. A borrowed PREVIEW frame is a *view* — the - // full state is what makes a freshly-connected client usable at all, so the resync PREEMPTS a - // preview (otherwise continuous preview from another client could keep the new client blank; - // preview resumes on its next frame). An OWNED frame in flight is ITSELF a state drain from a - // prior push that hasn't finished — don't stomp it; let it complete and skip this push (the - // slot is single-occupancy, and a half-then-half state is worse than one whole one arriving a - // tick later). fullResyncPending_ stays TRUE until startBufferedTextSend actually accepts the - // new payload, so a rejected/failed start retries next tick rather than dropping the resync. - if (!bufferedSendIdle()) { - if (previewSend_.ownsBody) return; // a state drain is already in flight — let it finish - cancelBufferedSend(); // preempt a borrowed preview - } + // A prior full state still draining finishes first, the slot is single-occupancy, and a + // half-then-half state is worse than one whole one arriving a tick later. fullResyncPending_ + // stays TRUE until startBufferedTextSend actually accepts the new payload, so this push + // simply retries next tick. (The preview has its own slot on its own channel; the two no + // longer contend.) + if (stateSend_.active) return; JsonSink sink; buildStateJson(sink); const size_t len = sink.size(); @@ -2416,6 +2457,10 @@ void HttpServerModule::pushStateToWebSockets() { // leaves, ~1–2 KB). This is the whole fix: the 30 KB of unchanging option/detail metadata is // NEVER serialised or sent here, so tick1s no longer spikes the render thread. The patch is // small, so it sends inline (no resumable drain) — a non-blocking per-client write of ~2 KB. + // While a full state is mid-drain, hold the patch: a small frame written into the middle + // of the chunked big one would interleave inside a WS message on that client. One skipped + // second of telemetry; the drained full state carries the fresh values anyway. + if (stateSend_.active) return; JsonSink sink; const uint16_t changed = buildStatePatch(sink); if (changed > 0) { @@ -2439,6 +2484,7 @@ void HttpServerModule::pushStateToWebSockets() { // Build and push the WLED {state, info} object to every WS client. Shares the same body // writers as /json/si. void HttpServerModule::pushWledStateToWebSockets() { + if (stateSend_.active) return; // never interleave with the chunked full-state drain bool hasClients = false; for (auto& ws : wsClients_) if (ws.valid()) { hasClients = true; break; } if (!hasClients) return; @@ -2467,6 +2513,38 @@ void HttpServerModule::pushWledStateToWebSockets() { // small text frame we care about is handled; we ignore continuation/binary/control frames // (a ping/close is rare on this short-lived control socket and harmless to skip). void HttpServerModule::pollWledStateFromWebSockets() { + // Reap preview clients that closed cleanly. They send nothing, so the ONLY other signal is a + // failed send, which can lag by seconds, and meanwhile the slot counts against the preview cap. + // With the cap reached, every new preview connection is refused and the preview appears to + // "stall until you refresh". Bench-observed on an S3 (2026-08-25). read() == 0 is a peer FIN; + // -1 is "nothing pending" and leaves a live client alone. + // No lease needed: since every /wsp byte moves through the resumable drain on this core, the + // encode core never touches these sockets, so a read or close here races nothing. + for (int i = 0; i < MAX_PREVIEW_CLIENTS; i++) { + auto& pc = previewClients_[i]; + if (!pc.valid()) continue; + uint8_t buf[64]; + const int n = pc.read(buf, sizeof(buf)); + if (n == 0) { // clean close (peer FIN): free the slot NOW + pc.close(); + if (clientSink_) clientSink_->onClientGone(i); + continue; + } + if (n > 0 && clientSink_) { + // WALK the read: TCP coalesces, so several small requests can arrive as one buffer. + // Each complete frame's unmasked payload goes to the sink in arrival order; the + // payload's meaning is the producer's business (the pull-model boundary). + for (int off = 0; off < n; ) { + uint8_t payload[8]; + int used = 0; + const int len = parsePreviewUplink(buf + off, n - off, payload, &used); + if (used <= 0) break; // nothing parseable left (or a partial tail) + if (len > 0) clientSink_->onClientMessage(i, payload, len); + off += used; + } + } + } + for (auto& ws : wsClients_) { if (!ws.valid()) continue; uint8_t f[512]; @@ -2537,60 +2615,24 @@ bool HttpServerModule::sendWsTextFrame(platform::TcpConnection& conn, const char return conn.write(reinterpret_cast(data), len); } -// Write the whole span via repeated non-blocking writeSome; close the client + return false if it -// can't all go right now. Bounded TOTAL would-block spins (not reset on progress) hard-bound how -// long this synchronous send can occupy the caller's loop; a span that doesn't complete in budget -// closes the client (the browser reconnects). Used by the begin/push/end stream (the coord table -// and downsampled color frame); the full-res color frame uses the resumable sendBufferedFrame. -bool HttpServerModule::sendAllOrClose(platform::TcpConnection& ws, const uint8_t* data, size_t len) { - size_t sent = 0; - int stalls = 0; - while (sent < len) { - int n = ws.writeSome(data + sent, len - sent); - if (n < 0) { ws.close(); return false; } // real socket error - if (n == 0) { // WouldBlock — lwIP send buffer momentarily full - if (++stalls > kDirectSendSpins) { ws.close(); return false; } - continue; - } - sent += static_cast(n); - } - return true; -} - -// Streamed frame: header now, payload pushed in slices, no frame-sized staging buffer — so a -// large frame (PreviewDriver's coordinate table or color frame) goes out on a memory-tight -// board where a contiguous block won't fit. The producer (placeLights) pushes forward-only; -// each slice fans to every client before the next push. A client that can't keep up is closed -// (its WS message ends incomplete → it reconnects), so this never blocks the tick indefinitely. -void HttpServerModule::beginBinaryFrame(size_t totalLen) { - wsFrameAllSent_ = true; - uint8_t wsHeader[10]; - int wsHeaderLen; - wsHeader[0] = 0x82; - if (totalLen < 126) { wsHeader[1] = static_cast(totalLen); wsHeaderLen = 2; } - else if (totalLen < 65536) { - wsHeader[1] = 126; wsHeader[2] = static_cast((totalLen >> 8) & 0xFF); - wsHeader[3] = static_cast(totalLen & 0xFF); wsHeaderLen = 4; - } else { - wsHeader[1] = 127; - for (int i = 0; i < 8; i++) - wsHeader[2 + i] = static_cast((static_cast(totalLen) >> (56 - 8 * i)) & 0xFF); - wsHeaderLen = 10; - } - for (auto& ws : wsClients_) { - if (ws.valid() && !sendAllOrClose(ws, wsHeader, static_cast(wsHeaderLen))) - wsFrameAllSent_ = false; - } -} - -void HttpServerModule::pushBinaryFrame(const uint8_t* data, size_t len) { - if (!data || len == 0) return; - for (auto& ws : wsClients_) { - if (ws.valid() && !sendAllOrClose(ws, data, len)) wsFrameAllSent_ = false; - } +int HttpServerModule::parsePreviewUplink(const uint8_t* buf, int n, uint8_t out[8], int* consumed) { + if (consumed) *consumed = 0; + // One masked client frame: [0x81|0x82][0x80|len][mask x4][payload...]. Framing only: the + // payload is handed on opaquely. Anything that is not a small masked data frame (a ping, a + // close, an oversized payload) is refused, and a malformed length can never read past `n`, + // these are network bytes, bounds first. + if (n < 6) return -1; // header(2) + mask(4) is the minimum + const uint8_t op = buf[0] & 0x0F; + if (op != 0x01 && op != 0x02) return -1; // text/binary only + if (!(buf[1] & 0x80)) return -1; // client frames must be masked (RFC 6455) + const int len = buf[1] & 0x7F; + if (len > 8 || n < 6 + len) return -1; // small request payloads only + if (consumed) *consumed = 6 + len; + const uint8_t* mask = buf + 2; + for (int i = 0; i < len; i++) out[i] = buf[6 + i] ^ mask[i & 3]; + return len; } -bool HttpServerModule::endBinaryFrame() { return wsFrameAllSent_; } // Resumable full-frame send. One WS message = WS framing header + the caller's app header (both // copied into previewSend_.hdr) + the caller's `body` (a pointer, NOT copied). Each client's @@ -2632,10 +2674,9 @@ bool HttpServerModule::sendBufferedFrame(const uint8_t* header, size_t headerLen std::memcpy(previewSend_.hdr + wsLen, header, headerLen); previewSend_.hdrLen = wsLen + headerLen; - previewSend_.body = body; + previewSend_.body = body; // borrowed: PreviewDriver keeps the pixel buffer alive previewSend_.bodyLen = bodyLen; - previewSend_.ownsBody = false; // preview borrows its pixel buffer (kept alive by PreviewDriver) - for (int i = 0; i < MAX_WS_CLIENTS; i++) previewSend_.sent[i] = 0; + for (int i = 0; i < MAX_PREVIEW_CLIENTS; i++) previewSend_.sent[i] = 0; previewSend_.active = true; // Deliberately do NOT drain here. sendBufferedFrame is called from PreviewDriver's tick() on the // RENDER thread; a socket writeSome is variable-cost (0..~ms) and would land that cost — and its @@ -2649,16 +2690,15 @@ bool HttpServerModule::sendBufferedFrame(const uint8_t* header, size_t headerLen // push so the 20 KB JSON drains in chunks on tick20ms rather than a blocking write on the render tick. bool HttpServerModule::startBufferedTextSend(char* ownedBody, size_t bodyLen) { // A send already in flight: drop this one and free its buffer — the next second's state is fresher. - if (previewSend_.active) { platform::free(ownedBody); return false; } + if (stateSend_.active) { platform::free(ownedBody); return false; } // No app header for the state frame (the JSON is the whole payload), just the WS text header. - const size_t wsLen = writeWsFrameHeader(previewSend_.hdr, 0x81, bodyLen); - previewSend_.hdrLen = wsLen; - previewSend_.body = reinterpret_cast(ownedBody); - previewSend_.bodyLen = bodyLen; - previewSend_.ownsBody = true; // we allocated this JSON buffer; the drain frees it when done - for (int i = 0; i < MAX_WS_CLIENTS; i++) previewSend_.sent[i] = 0; - previewSend_.active = true; - return true; // drained on tick20ms, same as preview — never a blocking write on the render tick + const size_t wsLen = writeWsFrameHeader(stateSend_.hdr, 0x81, bodyLen); + stateSend_.hdrLen = wsLen; + stateSend_.body = reinterpret_cast(ownedBody); + stateSend_.bodyLen = bodyLen; + for (auto& c : stateSend_.sent) c = 0; + stateSend_.active = true; + return true; // drained on tick20ms, never a blocking write on the render tick } // Per-client cursor over the logical [hdr ++ body] stream: write whatever the socket takes now (up @@ -2675,11 +2715,11 @@ void HttpServerModule::drainPreviewSend() { if (!lease) return; if (!previewSend_.active) return; const size_t total = previewSend_.hdrLen + previewSend_.bodyLen; - const size_t chunk = previewChunkBytes(); + const size_t chunk = drainChunkBytes(); bool anyLiveClient = false; bool allDone = true; - for (int i = 0; i < MAX_WS_CLIENTS; i++) { - auto& ws = wsClients_[i]; + for (int i = 0; i < MAX_PREVIEW_CLIENTS; i++) { + auto& ws = previewClients_[i]; if (!ws.valid()) continue; anyLiveClient = true; size_t& cur = previewSend_.sent[i]; @@ -2692,7 +2732,11 @@ void HttpServerModule::drainPreviewSend() { else { src = previewSend_.body + (cur - previewSend_.hdrLen); span = total - cur; } if (span > budget) span = budget; int n = ws.writeSome(src, span); - if (n < 0) { ws.close(); break; } // real error — drop this client + if (n < 0) { // real error: drop this client and its requests + ws.close(); + if (clientSink_) clientSink_->onClientGone(i); + break; + } if (n == 0) break; // WouldBlock — leave the rest for next tick (no spin) cur += static_cast(n); budget -= static_cast(n); @@ -2700,23 +2744,54 @@ void HttpServerModule::drainPreviewSend() { if (ws.valid() && cur < total) allDone = false; } // Done when every live client finished, or no client remains to send to. - if (!anyLiveClient || allDone) { - if (previewSend_.ownsBody) { // free the state JSON buffer we allocated for this frame - platform::free(const_cast(previewSend_.body)); - previewSend_.body = nullptr; - previewSend_.ownsBody = false; + if (!anyLiveClient || allDone) previewSend_.active = false; +} + +// The same cursor drain for the full-state frame, over the CONTROL clients. Core-0 only (tick20ms, +// the pushes, admission all run there), so unlike the preview slot it needs no sender lease. +void HttpServerModule::drainStateSend() { + if (!stateSend_.active) return; + const size_t total = stateSend_.hdrLen + stateSend_.bodyLen; + const size_t chunk = drainChunkBytes(); + bool anyLiveClient = false; + bool allDone = true; + for (int i = 0; i < MAX_WS_CLIENTS; i++) { + auto& ws = wsClients_[i]; + if (!ws.valid()) continue; + anyLiveClient = true; + size_t& cur = stateSend_.sent[i]; + size_t budget = chunk; + while (cur < total && budget > 0) { + const uint8_t* src; + size_t span; + if (cur < stateSend_.hdrLen) { src = stateSend_.hdr + cur; span = stateSend_.hdrLen - cur; } + else { src = stateSend_.body + (cur - stateSend_.hdrLen); span = total - cur; } + if (span > budget) span = budget; + int n = ws.writeSome(src, span); + if (n < 0) { ws.close(); break; } // real error, drop this client + if (n == 0) break; // WouldBlock, resume next tick + cur += static_cast(n); + budget -= static_cast(n); } - previewSend_.active = false; + if (ws.valid() && cur < total) allDone = false; + } + if (!anyLiveClient || allDone) { + platform::free(const_cast(stateSend_.body)); // the frame owns its JSON body + stateSend_.body = nullptr; + stateSend_.active = false; } } // Per-tick per-client chunk cap, derived from free contiguous memory: a tight board takes small // bites (so one drain can't dominate the tick), a roomy board drains a big frame in a tick or two. // Bounded both ways — never below a floor (forward progress) nor above a ceiling (tick occupancy). -size_t HttpServerModule::previewChunkBytes() const { +size_t HttpServerModule::drainChunkBytes() const { constexpr size_t kFloor = 2048; // always make real progress, even on a fragmented board constexpr size_t kCeil = 65536; // cap tick occupancy regardless of how much RAM is free const size_t block = platform::maxAllocBlock(); + // 0 = unlimited/not reported (desktop): no artificial ceiling; writeSome stops at the socket + // buffer anyway, so TCP itself paces the drain and the endpoints are the only limits. + if (block == 0) return static_cast(1) << 30; size_t chunk = block / 8; // a fraction of the largest contiguous block if (chunk < kFloor) chunk = kFloor; if (chunk > kCeil) chunk = kCeil; diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h index fd2d23f7..b0dfcafd 100644 --- a/src/core/HttpServerModule.h +++ b/src/core/HttpServerModule.h @@ -41,13 +41,17 @@ class Scheduler; /// stream through a `JsonSink` — no fixed-buffer ceiling, so a tree of any size serializes correctly. /// /// **WebSocket:** `GET /ws` with `Upgrade: websocket` does the RFC 6455 handshake (SHA-1 + -/// base64), up to `MAX_WS_CLIENTS` (8) concurrent clients. Binary frames take two paths, both without a frame-sized -/// buffer: a synchronous stream (`beginBinaryFrame` / `pushBinaryFrame` / `endBinaryFrame`) for a -/// forward-only producer, and a resumable buffered send (`sendBufferedFrame`) that drains a -/// memory-adaptive chunk per client per `tick20ms` from a stable caller-owned buffer — so a large -/// frame is delivered over wall-clock ticks without spinning any loop, yet stays one atomic WS -/// message. One buffered send is in flight at a time (newest-wins backpressure: a new offer while -/// one is active is dropped). Clients send nothing back over WS; mutations go through REST. +/// base64). Two WS channels by traffic class, with separate caps on one lwIP socket budget: +/// `/ws` carries the control plane (JSON state and patches, `MAX_WS_CLIENTS` = 8) and `/wsp` the +/// lossy binary preview stream (`MAX_PREVIEW_CLIENTS` = 4). Every binary message takes ONE path: +/// the resumable buffered send (`sendBufferedFrame`), draining a memory-adaptive chunk per client +/// per `tick20ms` from a stable caller-owned buffer, so a large frame is delivered over +/// wall-clock ticks without any loop ever waiting on a socket, yet stays one atomic WS message. +/// One buffered send is in flight at a time per slot (newest-wins backpressure: a new offer while +/// one is active is dropped); a client is closed only on a real error or FIN, never for slowness. +/// Inbound `/wsp` payloads are unmasked and handed opaquely to the registered producer sink; the +/// producer's vocabulary is `[0x51][stride][fps]` (standing frame request) and `[0x52][stride]` +/// (one-shot table request). Other mutations go through REST. /// /// **State push — diff on the wire (the recognizable snapshot-then-patch model, cf. Redux / /// Firestore sync, JSON Patch RFC 6902):** the state a client needs is the full module tree @@ -125,9 +129,6 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { /// BinaryBroadcaster — stream one binary WS frame to every connected client, pushed /// incrementally so no frame-sized buffer is held. Producers (PreviewDriver) push the /// payload bytes; this prepends the WS header. Domain-neutral: no knowledge of the content. - void beginBinaryFrame(size_t totalLen) override; - void pushBinaryFrame(const uint8_t* data, size_t len) override; - bool endBinaryFrame() override; /// Resumable one-frame send from a stable caller-owned buffer (no copy), drained a bounded chunk /// per client per tick20ms (drainPreviewSend) so a large frame stays off this module's hot path; @@ -135,25 +136,43 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { bool sendBufferedFrame(const uint8_t* header, size_t headerLen, const uint8_t* body, size_t bodyLen) override; bool bufferedSendIdle() const override { return !previewSend_.active; } - // Drop the in-flight buffered send. Frees the body first when the frame OWNS it (a state frame - // owns its ~30 KB JSON; a preview frame borrows its pixel buffer) — same rule as release(), so - // this is self-safe for any caller, not only ones that know a borrowed frame is in flight. - // A cancelled OWNED frame is a state resync that was still draining (a preview-geometry rebuild - // can cancel mid-drain); re-arm fullResyncPending_ so the resync is retried on the next push - // rather than silently lost — a client that already saw a partial state must not be left stale. + // Drop the in-flight buffered preview frame (a geometry rebuild is about to free its body). + // A client that already received part of the message has a desynced stream if we just stop - + // the next message's bytes get parsed as this one's payload, so the only honest exit for a + // mid-frame client is CLOSE (it reconnects and gets the fresh table). Untouched clients keep + // their connection. void cancelBufferedSend() override { - if (previewSend_.ownsBody) { - platform::free(const_cast(previewSend_.body)); - previewSend_.body = nullptr; - previewSend_.ownsBody = false; - fullResyncPending_ = true; // a state drain was interrupted → retry it + if (previewSend_.active) { + const size_t total = previewSend_.hdrLen + previewSend_.bodyLen; + for (int i = 0; i < MAX_PREVIEW_CLIENTS; i++) + if (previewClients_[i].valid() && + previewSend_.sent[i] > 0 && previewSend_.sent[i] < total) { + previewClients_[i].close(); + // Every close site notifies the producer, or the dead slot's standing + // request would keep steering the shared stream until the slot is reused. + if (clientSink_) clientSink_->onClientGone(i); + } } previewSend_.active = false; } - /// Bumped on each new WS client (see handleWebSocketUpgrade). PreviewDriver watches it to - /// re-stream its coordinate table the moment a fresh page connects, so a refresh shows the - /// preview immediately. - uint32_t clientGeneration() const override { return wsClientGeneration_; } + + + int subscriberCount() const override { + int n = 0; + for (const auto& pc : previewClients_) if (pc.valid()) n++; + return n; + } + + /// Register the producer that receives this channel's inbound client messages (opaque bytes). + void setClientMessageSink(ClientMessageSink* sink) override { clientSink_ = sink; } + + /// Parse ONE masked client data frame (text/binary, payload up to 8 bytes) from a /wsp read, + /// unmasking the payload into `out`. Returns the payload length (>=0) or -1 when the buffer + /// holds no complete parseable frame; `consumed` receives the whole frame's byte length so a + /// caller can walk a read that coalesced several frames (0 when nothing was consumed). The + /// payload's MEANING belongs to the registered sink; this only does RFC 6455 framing. + /// Pure and static so the byte handling is unit-testable without a socket. + static int parsePreviewUplink(const uint8_t* buf, int n, uint8_t out[8], int* consumed); // The cross-core sender lease (see BinaryBroadcaster). Guards previewSend_ + the wsClients_ socket // writes against this module's own core-0 drain / state push while an offloaded PreviewDriver @@ -267,18 +286,23 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { // is just an fd + a small cursor, so the array stays tiny. static constexpr int MAX_WS_CLIENTS = 8; platform::TcpConnection wsClients_[MAX_WS_CLIENTS]; - uint32_t wsClientGeneration_ = 0; // ++ on each new WS client; see clientGeneration() - - // begin/push/endBinaryFrame stream a binary WS frame straight to every client with NO - // frame-sized buffer: the header goes out on begin, each pushed slice is fanned to all - // clients, and end reports whether every client got the whole frame. A producer (PreviewDriver - // streaming the producer buffer / placeLights) holds no copy. wsFrameAllSent_ tracks the - // current frame's all-sent result across the push calls. - bool wsFrameAllSent_ = true; - // Max TOTAL WouldBlock spins for one span in sendAllOrClose before a stuck client is closed. - // Used by the begin/push/end stream (coord table + downsampled color frame); the full-res - // color frame goes through the resumable sendBufferedFrame instead, which never spins. - static constexpr int kDirectSendSpins = 2000; + + // `/wsp`, the SECOND channel, for lossy binary streams (the preview). Its own connections, so a + // 10 KB preview frame can never delay a state push: they are separate TCP connections, which is + // the standard remedy for the head-of-line blocking one socket carrying both traffic classes + // produces. `/ws` stays the control plane (JSON state + patches). + // + // Its cap is DELIBERATELY lower than MAX_WS_CLIENTS. Both arrays draw on one + // CONFIG_LWIP_MAX_SOCKETS budget of 16, shared with HTTP, mDNS, Art-Net, MQTT and OTA, a + // preview socket per WS client would consume the whole budget at the cap and starve the rest. + // 4 is sized from the observed use: one browser almost always, two often enough that it must + // just work, more only occasionally, while REST callers (Home Assistant, scripts) never open + // a preview socket at all. A refused upgrade costs that client only its preview; its /ws + // control connection is untouched. + static constexpr int MAX_PREVIEW_CLIENTS = 4; + platform::TcpConnection previewClients_[MAX_PREVIEW_CLIENTS]; + + ClientMessageSink* clientSink_ = nullptr; // the producer's inbound-message sink (PreviewDriver) // Resumable full-frame send (BinaryBroadcaster::sendBufferedFrame). One WS message = a copied // header + a pointer into the caller's STABLE body buffer (the PreviewDriver producer buffer), @@ -288,42 +312,59 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { // the in-flight one kept). The caller calls cancelBufferedSend() before freeing/reallocating the // body (a geometry rebuild), so a cursor never reads freed memory. struct PreviewSend { - uint8_t hdr[16] = {}; // WS + app header, copied (caller's may be a stack local) + // 24, not 16: a payload over 64 KB takes the 10-byte WS length form, and the preview app + // headers add up to 11 more. 16 silently refused every uncapped-size frame. + uint8_t hdr[24] = {}; // WS + app header, copied (caller's may be a stack local) size_t hdrLen = 0; const uint8_t* body = nullptr; // the frame body — see ownsBody for lifetime size_t bodyLen = 0; - size_t sent[MAX_WS_CLIENTS] = {}; // per-client cursor over [hdr ++ body]; a slow client lags + size_t sent[MAX_PREVIEW_CLIENTS] = {}; // per-PREVIEW-client cursor over [hdr ++ body]; a slow client lags bool active = false; - // Body lifetime: the preview path BORROWS body (PreviewDriver keeps its pixel buffer alive), - // so ownsBody is false and the drain frees nothing. The state push builds a fresh JSON buffer - // per second that must outlive the chunked drain, so it hands OWNERSHIP (ownsBody true) and the - // drain frees it on completion / on release. One slot serves both large-frame producers. - bool ownsBody = false; + // body is BORROWED: PreviewDriver keeps its pixel buffer alive; prepare() cancels before a + // resize frees it. The state push has its own slot (StateSend) since the channel split - + // sharing this one routed the full state to /wsp and starved every /ws client of its resync. }; PreviewSend previewSend_; - // Guards the WS sender — previewSend_ AND the wsClients_ socket writes — because it has TWO - // producers on TWO cores once the multicore split engages: core 0 (this module's tick20ms drain, - // the 1 Hz state push, connect/disconnect) and core 1 (the offloaded PreviewDriver's tick, which - // arms a frame and directly streams the coordinate table). Without it, core 1's partial-write - // stream interleaves with core 0's drain inside one WS frame (corrupt framing) or observes a torn - // previewSend_. try_lock only, never a blocking lock: whichever core loses the race SKIPS its - // slot (the hot-path rule, CLAUDE.md § Hot path). Preview already has that skip path — it is the - // same back-off its adaptive frame rate takes when the link is busy — so a lost race costs one - // preview frame, never a stalled render or encode. + // Resumable full-state send to the CONTROL channel (`/ws`): same cursor-per-client shape as + // PreviewSend, but it drains to wsClients_ and always OWNS its JSON body (built per resync, + // freed on drain-complete / release). Each WS message a client receives stays atomic: while + // this is active, the patch and WLED pushes to /ws are skipped so nothing interleaves. + struct StateSend { + uint8_t hdr[16] = {}; + size_t hdrLen = 0; + const uint8_t* body = nullptr; + size_t bodyLen = 0; + size_t sent[MAX_WS_CLIENTS] = {}; + bool active = false; + }; + StateSend stateSend_; + // Guards the PREVIEW channel's shared state: previewSend_ and the previewClients_ sockets, + // which have producers on TWO cores once the multicore split engages. Core 1 (the offloaded + // PreviewDriver's tick) arms frames and directly streams the coordinate table; core 0 touches + // the same sockets and bookkeeping in drainPreviewSend, the uplink reap and /wsp admission. + // Without it, a partial-write stream on one core interleaves with the other inside one WS + // frame (corrupt framing), a close lands under a concurrent write on the same fd, or one side + // observes a torn previewSend_. The CONTROL channel (wsClients_, stateSend_) is deliberately + // outside its scope: every /ws writer runs on core 0. try_lock only, never a blocking lock: + // whichever core loses the race SKIPS its slot (the hot-path rule, CLAUDE.md § Hot path); a + // lost race costs one preview frame or defers a reap/admission one tick, never a stalled + // render or encode. mutable TryLock wsLock_; - // Queue a TEXT frame (opcode 0x81) whose body this module OWNS, through the same resumable slot the - // preview binary send uses — so the (20 KB) state JSON drains in chunks on tick20ms instead of a - // blocking write on the render tick. Takes ownership of `ownedBody` (freed on drain-complete / - // release). Returns false (and frees ownedBody) if a send is already in flight — drop-new, the next - // second's state is fresher. Internal (not the BinaryBroadcaster interface, which stays binary). + // Queue a TEXT frame (opcode 0x81) whose body this module OWNS into the STATE slot, the + // (20 KB) full-state JSON drains in chunks to /ws clients on tick20ms instead of a blocking + // write on the render tick. Takes ownership of `ownedBody` (freed on drain-complete / + // release). Returns false (and frees ownedBody) if a state send is already in flight - + // drop-new, the next second's state is fresher. bool startBufferedTextSend(char* ownedBody, size_t bodyLen); // Drain one memory-adaptive chunk per client of the in-flight resumable send; mark it done when // every live client has the whole frame, freeing an owned body then. Called from tick20ms. No-op // when none is active. void drainPreviewSend(); + // Same, for the in-flight full-state send to /ws clients. Called from tick20ms. + void drainStateSend(); // Largest chunk to push per client per drain tick, derived from free contiguous memory so a // tight board takes small bites (bounded tick occupancy) and a roomy board drains fast. - size_t previewChunkBytes() const; + size_t drainChunkBytes() const; // All JSON API responses (/api/state, /api/types, /api/system) and the WS // state push stream through a JsonSink — no shared fixed-size buffer. @@ -485,13 +526,13 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { // ----------------------------------------------------------------------- // WebSocket // ----------------------------------------------------------------------- - void handleWebSocketUpgrade(platform::TcpConnection& conn, const char* req); + /// `previewChannel` = the request arrived on `/wsp`, so the connection joins previewClients_ + /// (the lossy binary channel) instead of the control plane's wsClients_. + void handleWebSocketUpgrade(platform::TcpConnection& conn, const char* req, + bool previewChannel = false); void pushStateToWebSockets(); void pushWledStateToWebSockets(); // WLED-app {state,info} frame on /ws (see impl) static bool sendWsTextFrame(platform::TcpConnection& conn, const char* data, int len); - // Write the whole span to one client via repeated non-blocking writeSome; close it + return - // false if it can't all go (a stuck/too-slow client). The push primitive behind begin/push/end. - static bool sendAllOrClose(platform::TcpConnection& ws, const uint8_t* data, size_t len); }; } // namespace mm diff --git a/src/light/drivers/NdiDriver.h b/src/light/drivers/NdiDriver.h index 3ab74b92..f506f2d9 100644 --- a/src/light/drivers/NdiDriver.h +++ b/src/light/drivers/NdiDriver.h @@ -1,24 +1,28 @@ #pragma once -// NdiDriver — projectMM as an NDI video source. -// -// The rendered frame reaches OBS, Resolume, TouchDesigner or any other NDI receiver, on this -// machine or another. That last part is why NDI and not Spout/Syphon: a shared GPU texture cannot -// leave the box, and one NDI implementation covers Windows, macOS, Linux and ARM where Spout and -// Syphon are two platform-specific ones covering two of them. -// -// **Desktop only** (`platform::hasNdi`): the NDI runtime is a closed binary built only for Intel -// and ARM (SSSE3 / NEON floor), so no ESP32 can load one and there is no source to port. An ESP32 -// reaches the same receivers over Art-Net / sACN / DDP, which projectMM implements itself. -// -// **The runtime is the user's.** projectMM is GPL-3.0 and the NDI runtime is proprietary, so it is -// never bundled or linked — the platform layer resolves it on demand, exactly as it does Npcap for -// the panel-card driver. A machine without it runs normally and this driver says so in its status. -// The whole NDI surface lives behind `platform::` (see platform.h § NDI); no NDI type appears here. -// -// Prior art: the NDI protocol and SDK are NewTek/Vizrt's; this driver is our own code against the -// documented C API. The frame-pacing and status shape follow PreviewDriver, the other driver that -// turns the rendered buffer into frames for a remote consumer. -// Author: projectMM original +/// NdiDriver, projectMM as an NDI video source. +/// +/// The rendered frame reaches OBS, Resolume, TouchDesigner or any other NDI receiver, on this +/// machine or another. +/// +/// **Why NDI and not Spout/Syphon.** One implementation covers Windows, macOS, Linux and ARM, it +/// discovers by name, and it crosses machines. Spout (Windows) and Syphon (macOS) share a GPU texture +/// zero-copy and are bit-exact, but they are same-machine only, are TWO platform-specific +/// implementations, and leave Linux and the Pi with nothing. At LED-wall pixel counts the latency +/// difference sits far below one frame of the render loop, so coverage decides, not latency. +/// +/// **Desktop only** (`platform::hasNdi`): the NDI runtime is a closed binary built only for Intel +/// and ARM (SSSE3 / NEON floor), so no ESP32 can load one and there is no source to port. An ESP32 +/// reaches the same receivers over Art-Net / sACN / DDP, which projectMM implements itself. +/// +/// **The runtime is the user's.** projectMM is GPL-3.0 and the NDI runtime is proprietary, so it is +/// never bundled or linked, the platform layer resolves it on demand, exactly as it does Npcap for +/// the panel-card driver. A machine without it runs normally and this driver says so in its status. +/// The whole NDI surface lives behind `platform::` (see platform.h § NDI); no NDI type appears here. +/// +/// Prior art: the NDI protocol and SDK are NewTek/Vizrt's; this driver is our own code against the +/// documented C API. The frame-pacing and status shape follow PreviewDriver, the other driver that +/// turns the rendered buffer into frames for a remote consumer. +/// Author: projectMM original #include "core/Control.h" #include "core/ScratchBuffer.h" @@ -147,7 +151,11 @@ class NdiDriver : public DriverBase { } // Controls - char sourceName[32] = ""; // blank = the device name + /// The name a receiver lists this source under. Blank uses the device's own name, which is what + /// a user scanning OBS's source list expects to find. + char sourceName[32] = ""; + /// Frame-rate ceiling. The driver sends no faster than this and declares the rate in each frame; + /// the link may deliver fewer. uint8_t fps = 30; private: diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h index 400575d0..072bfa49 100644 --- a/src/light/drivers/PreviewDriver.h +++ b/src/light/drivers/PreviewDriver.h @@ -19,61 +19,105 @@ namespace mm { /// HTTP server is a domain-neutral BinaryBroadcaster that just writes the bytes: /// // --8<-- [start:wire-format] -/// 0x03 coordinate table (sent when the geometry changes — every LUT/layout rebuild -/// via prepare — and when a new client connects, so a refresh gets it; never -/// per-frame): -/// [0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count] +/// 0x03 coordinate table (sent ONLY in answer to a client's [0x52] request; the client +/// caches tables per (epoch, stride), so a stride change to a known rung asks nothing): +/// [0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][epoch:u8][(x,y,z):u8x3 x count] /// bx/by/bz = bounding-box extent (for client centring); positions are -/// 1 byte/axis (a layout box ≤255/axis is the realistic case). count is u32 so a -/// >65535-light panel (big ArtNet/HUB75 walls) isn't capped by the wire format — -/// it matches nrOfLightsType (u32 on PSRAM boards). +/// 1 byte/axis (scaled when an axis exceeds 255). count is u32 so a >65535-light +/// panel isn't capped by the wire format; epoch bumps on every geometry rebuild +/// and keys the client's cache. /// -/// 0x02 per-frame channels: [0x02][count:u32][stride:u16][(r,g,b) × count] -/// RGB by driver index, every `stride`-th light. The browser positions -/// triple i at coord-table entry i*stride. +/// 0x02 per-frame channels: [0x02][count:u32][stride:u16][epoch:u8][drops:u8][(r,g,b) x count] +/// RGB of every kept light, in the coord table's order. drops = frames discarded +/// at the source since the last delivered one, the congestion signal the client's +/// controller adapts on. +/// +/// Client requests (masked WS frames, unmasked by core, interpreted only here): +/// [0x51][stride][fps] standing frame request (most conservative across viewers wins; +/// the targetFps control is the ceiling) +/// [0x52][stride] one-shot: send me the coordinate table // --8<-- [end:wire-format] /// /// `count` is the number of points actually kept after lattice downsampling (the /// lights whose position satisfies `pos ≡ 0 mod stride`) — a client sizes its buffer -/// from this `count`, not from the light total. `stride` rises above 1 only when the -/// point set would exceed the runtime send-buffer cap (`min(display, memory)`); below -/// the cap every light is sent (stride 1), so a sparse layout streams in full. +/// from this `count`, not from the light total. `stride` rises above 1 when a client +/// requests it, or when the memory cap forces a floor; with no cap in play every light +/// is sent (stride 1), so a sparse layout streams in full. +/// **Its own channel (`/wsp`), and why.** Preview frames are lossy and large; control-plane state is +/// small and latency-sensitive. Sharing one WebSocket made the small messages queue behind the big +/// ones, head-of-line blocking, which users saw as a flickering connection indicator and a UI that +/// stopped responding while a large layout streamed. Separate TCP connections is the standard remedy +/// for that mixed-criticality pairing. +/// +/// **Resolution is client-driven.** The browser reads the drops counter each frame carries (the +/// device's own congestion signal) and posts the `[0x51][stride][fps]` standing request it wants; +/// the device serves the most conservative request across viewers. The memory cap +/// (`maxPreviewPoints()`) is the only floor a request cannot go finer than. +/// +/// **No request, no work.** `tick()` returns immediately when no standing request exists, so a +/// dismissed preview pane (or a hidden tab) costs the device nothing, not merely nothing on the +/// wire. +/// /// @card PreviewDriver.png -class PreviewDriver : public DriverBase { +class PreviewDriver : public DriverBase, public BinaryBroadcaster::ClientMessageSink { public: /// The 3D preview the web UI renders streams from this driver. Deleting or /// replacing it from the UI would silently kill that preview, so it opts out /// of user-editing — it stays a fixed child of Drivers. bool userEditable() const override { return false; } - /// Preview stream rate (Hz), independent of render FPS. User-tunable 1-60. This - /// is a *ceiling*: the effective rate self-limits to what the link sustains. - uint8_t fps = 24; + /// The frame rate the preview aims for (Hz), independent of render FPS. User-tunable 1-60. + /// The device never sends faster; the browser's controller trades resolution toward it. + uint8_t targetFps = 24; /// Set the sink each message is pushed to (HttpServerModule, as a /// BinaryBroadcaster). Wired in main.cpp. Light depends only on the - /// interface, not the concrete HTTP server. - void setBroadcaster(BinaryBroadcaster* b) { broadcaster_ = b; } + /// interface, not the concrete HTTP server; the driver registers itself + /// as the channel's inbound-message sink (the pull model's request path). + void setBroadcaster(BinaryBroadcaster* b) { + broadcaster_ = b; + if (b) b->setClientMessageSink(this); + } + + /// The pull protocol, this producer's whole request vocabulary: + /// [0x51][stride][fps] standing frame request: serve stride s at rate f + /// (fps 0/absent = the targetFps control's value). + /// [0x52][stride] one-shot: send me the coordinate table (for the served stride). + /// Arrives on the transport thread; single-byte slot fields, so the encode-thread reader + /// tolerates the race (lossy-channel rule). Out-of-range bytes are ignored at the store: + /// requests aggregate conservatively, so one hostile value must not mask every real one. + void onClientMessage(int slot, const uint8_t* payload, int len) override { + if (slot < 0 || slot >= kMaxRequestSlots || len < 2) return; + if (payload[0] == 0x51) { + const uint8_t stride = payload[1]; + if (stride < 1 || stride > 64) return; + reqStride_[slot] = stride; + reqFps_[slot] = (len >= 3 && payload[2] >= 1 && payload[2] <= 25) ? payload[2] : 0; + } else if (payload[0] == 0x52) { + tableRequested_ = true; + } + } + void onClientGone(int slot) override { + if (slot < 0 || slot >= kMaxRequestSlots) return; + reqStride_[slot] = 0; // a dead client's request dies with its slot + reqFps_[slot] = 0; + } - /// Test-only: flip the resumableFrames A/B directly (production toggles it through the control + - /// affectsPrepare path). Lets a test drive the buffer alloc/free without a control write. - void setResumableFramesForTest(bool on) { resumableFrames = on; } - /// The current adaptive downsample factor (1 = full resolution). Test-only — lets a test pin the - /// coarsen (additive) / refine (multiplicative) recovery cadence. + /// The currently served downsample factor (1 = full resolution). Test-only: lets a test pin + /// that the stride mirrors the standing client requests and nothing else. nrOfLightsType downscaleForTest() const { return downscale_; } + /// Preview shows the raw logical buffer, no correction. bool hasCorrectionControls() const override { return false; } - /// Bind the controls: `fps` (1-60) and `resumableFrames` (the downsampled-frame transport A/B). - /// resumableFrames is an EXPERT control — a dev/tuning A/B, not a knob a normal user should touch (its - /// ON leg tears the preview until the slot-sharing fix lands), so it shows only when System.expertMode - /// is on. It still persists and still accepts API writes; only the default UI hides it. + /// Bind the controls: `targetFps` (1-25), the frame rate the preview aims for. The device never + /// sends faster, and when the link cannot sustain it the BROWSER trades resolution to get + /// closer: a low target keeps full detail at a low rate, a high target accepts a coarser + /// preview to stay responsive. void defineDriverControls() override { - controls_.addControl("fps", fps, 1, 60); - controls_.addControl("resumableFrames", resumableFrames); - controls_.setAdvanced(controls_.count() - 1); + controls_.addControl("targetFps", targetFps, 1, 25); } /// Point the driver at the sparse driver buffer the LED/ArtNet drivers also read @@ -86,30 +130,41 @@ class PreviewDriver : public DriverBase { /// A rebuild (layout add/replace/remove, resize, modifier change) ran — the /// light set / positions may have changed, so rebuild + broadcast the coordinate /// table (the MoonLight "positions once at mapping time"). Cancels any in-flight - /// resumable color send *first*: a resize frees+reallocs the producer buffer, so + /// color send *first*: a resize frees+reallocs the producer buffer, so /// a half-sent frame would read freed memory — a use-after-free guard pinned by a /// test. This coupling spans PreviewDriver ↔ HttpServerModule ↔ the Layer buffer. void prepare() override { - // A resize frees+reallocs the producer buffer, so any in-flight resumable color send holds + // A resize frees+reallocs the producer buffer, so any in-flight color send holds // a pointer that's about to dangle — cancel it BEFORE the rebuild (the browser discards the // half-sent message and gets the fresh table + frame next tick). Guards a use-after-free. - // The cancel also covers stage_: with no drain in flight, the grow below can't dangle it. if (broadcaster_) broadcaster_->cancelBufferedSend(); - if (resumableFrames) ensureStage(); // allocate the staging buffer only when the A/B wants it - else freePreviewBuffers(); // OFF: release the ~24 KB (readout drops to match) - // Re-anchor the LINK-adaptive downsample on a geometry change: a rebuild is a fresh layout, so a - // previous grid's link-struggle coarsening must not carry over and hold a now-small grid coarse - // (the "add a 16×16 → 4 blobs for ~10 s" bug — it inherited a big config's downscale_). The - // memory/display cap in buildAndSendCoordTable still sets the honest floor for THIS grid instantly - // (a 90×90 lands at its 1/3 with no ramp), and downscale_ only re-coarsens if this grid's own - // frames actually stall. Reset here (the true rebuild seam), NOT in buildAndSendCoordTable, which - // the adaptive loop itself calls — resetting there would undo the adaptation mid-flight. - downscale_ = 1; - slowStreak_ = 0; - cleanStreak_ = 0; - framesWaiting_ = 0; // the old grid's drain count must not make the new grid's first frame read slow - buildAndSendCoordTable(); - refreshStatus(); // surface any resumable-path degradation (alloc miss) in the tab + else freePreviewBuffers(); // no broadcaster wired: nothing streams, release the buffers + // downscale_ is NOT reset here: it is the client's standing request, and tick() mirrors the + // standing requests every pass anyway, the client asks finer when the new geometry deserves it. + // A rebuild is a NEW epoch: frames start carrying it, every client's table cache misses, + // and each asks via [0x52]. The device never volunteers a table (the pull model). + epoch_++; + buildCoordTable(); + // Pre-size the staging and index buffers for the FINEST stride this geometry can be + // served at (stride 1, bounded by the memory cap). Both are grow-only, so every later + // stride adopt on the render tick reuses this capacity and allocates nothing: the one + // allocation the tick path could reach moves here, the cold rebuild seam. + if (layer_ && layer_->layouts()) { + const nrOfLightsType finest = layer_->layouts()->totalLightCount(); + const nrOfLightsType capPts = maxPreviewPoints(); + const size_t maxPts = finest < capPts ? finest : capPts; + ensureStaging(maxPts * 3u); + if (!denseGrid() && keptIdxCap_ < maxPts) { + auto* grown = static_cast(platform::alloc(maxPts * sizeof(nrOfLightsType))); + if (grown) { + if (keptIdx_) platform::free(keptIdx_); + keptIdx_ = grown; + keptIdxCap_ = static_cast(maxPts); + publishHeapBytes(); + } + } + } + refreshStatus(); // surface an index-cache alloc miss in the tab } void release() override { @@ -117,12 +172,9 @@ class PreviewDriver : public DriverBase { DriverBase::release(); } - /// The `resumableFrames` A/B flips the transport AND which buffers exist, so a change re-runs prepare - /// (which allocates them when ON, frees them when OFF) — the standard "config change applies live" - /// path, off the render thread. `fps` doesn't change structure, so it stays a plain control edit. - bool affectsPrepare(const char* name) const override { - return name && std::strcmp(name, "resumableFrames") == 0; - } + /// No control changes the transport structure: `targetFps` is a plain value edit, so nothing + /// here re-runs prepare. Geometry changes come through onRebuild, not a control. + bool affectsPrepare(const char* /*name*/) const override { return false; } /// Per-tick: (re)stream the coordinate table when the geometry or client set /// changed, then stream one color frame if the previous one finished draining. @@ -130,93 +182,72 @@ class PreviewDriver : public DriverBase { /// spatial resolution via adaptive downscale), so a large grid never stalls the /// loop or tears — it always delivers a complete frame. // REPORTED AS BLOCKING, deliberately: sendFrame() writes to a socket and - // buildAndSendCoordTable() resizes keptIdx_. Both are real and both are on the render path, + // buildCoordTable() resizes keptIdx_. Both are real and both are on the render path, // so clang-hotpath lists them rather than hiding them. Backlogged (backlog-core: hot path). void tick() MM_NONBLOCKING override { - if (fps == 0) return; + // THE PULL MODEL: the device serves standing client requests and volunteers nothing. + // No standing request (no viewer, every pane closed, a tab hibernating) means no gather, + // no downsample, no send, nothing: closing the preview genuinely frees the device. + if (!broadcaster_) return; + nrOfLightsType wantStride = 0; + uint8_t wantFps = 255; + for (int i = 0; i < kMaxRequestSlots; i++) { + const uint8_t rs = reqStride_[i]; + if (!rs) continue; + if (rs > wantStride) wantStride = rs; // coarsest wins + const uint8_t rf = reqFps_[i] ? reqFps_[i] : targetFps; + if (rf < wantFps) wantFps = rf; // slowest wins + } + if (wantStride == 0) return; // nobody asked: no work + if (wantFps > targetFps) wantFps = targetFps; // the control is the ceiling + if (wantFps == 0) return; + uint32_t now = platform::millis(); - uint32_t interval = 1000 / fps; - if (now - lastSendTime_ < interval) return; // fps CEILING (max rate); link may be slower - - // Hold the sender for this WHOLE tick, because under the multicore split this runs on core 1 - // while the transport drains and pushes state on core 0. The bracket must span the entire - // message set below — a coordinate table streams as begin/push/end, and another core's write - // landing between those parts would corrupt the WS frame; arming a frame likewise must not - // race the drain reading the slot. TRY-acquire (never block: we are on the encode thread) — - // busy means the transport is mid-drain, so we SKIP this slot, which is exactly the back-off - // the adaptive frame rate already takes when the link is behind. A skipped preview frame is - // invisible; a blocked encode thread would stall the LEDs. + if (now - lastSendTime_ < 1000u / wantFps) return; // rate: the served request + + // Hold the sender for this tick: under the multicore split this runs on core 1 while the + // transport drains on core 0, and ARMING a message (the only socket-adjacent thing this + // thread ever does now) must not race the drain reading the slot. TRY-acquire, never + // block: busy means the transport is mid-drain, so we SKIP this slot, the same back-off a + // busy link already gets. A skipped preview frame is invisible; a blocked encode thread + // would stall the LEDs. SendLease lease{broadcaster_}; if (!lease) return; - lastSendTime_ = now; // only after we own the sender — a skipped slot must retry next tick - - // The coordinate table is (re)streamed only when the geometry changes (prepare — a - // resize / LUT rebuild), when a new client connects (clientGeneration bump, so a page - // refresh gets positions immediately), when the adaptive factor changes, or while a - // previous stream didn't reach every client (coordPending_ retry). NOT per frame: the - // color frames below reference the last-streamed positions. coordCount_==0 = cold start. - uint32_t gen = broadcaster_ ? broadcaster_->clientGeneration() : 0; - if (coordCount_ == 0 || gen != lastClientGen_ || coordPending_) { - lastClientGen_ = gen; - buildAndSendCoordTable(); // streams positions; sets coordPending_ if not all clients got it + lastSendTime_ = now; // only after we own the sender: a skipped slot must retry next tick + + // Adopt the served stride. The lattice rebuild is local bookkeeping (counts + index + // cache + staging), gated on an idle slot only because the staging buffer must not be + // rewritten under a live drain. No table is sent here: frames carrying the new + // (epoch, stride) make every client's cache miss, and each asks via [0x52] when it needs + // the positions, the pull model's answer to the re-stream storms the push design fed. + const bool idle = broadcaster_->bufferedSendIdle(); + if ((wantStride != downscale_ || coordCount_ == 0) && idle) { + downscale_ = wantStride; + buildCoordTable(); } + if (coordCount_ == 0) return; // nothing previewable (empty layout / staging alloc miss) - // ADAPTIVE FRAME RATE. The full-res color frame streams resumably (sendBufferedFrame drains - // across transport ticks), so a frame only starts once the previous one fully drained. We - // gate on that: idle → send the next frame now; still draining → skip this slot. The - // EFFECTIVE fps therefore self-limits to what the link sustains — fast links hit the fps - // ceiling, slow links naturally drop to a few fps, with NO loop stall either way. The slot - // we skip is also the "link is slow" signal (framesWaiting_), so we shed frame rate FIRST. - bool frameOk = true; - bool sentThisSlot = false; - bool sentFrameWasSlow = false; - if (!coordPending_) { - const bool idle = !broadcaster_ || broadcaster_->bufferedSendIdle(); + // A requested table outranks the next frame for the slot: the asker cannot render one + // frame until it lands. + // Rebuild before sending: the staging buffer is shared with the frame gather, so the + // table's bytes are only valid straight after its build. + if (tableRequested_) { if (idle) { - // The previous frame finished draining. How many fps slots did it take? > a couple - // means the link can't sustain this resolution at the requested rate — that frame - // was "slow", the resolution signal below. - sentFrameWasSlow = framesWaiting_ >= kSlowFrames; - frameOk = sendFrame(); // false → a client couldn't take the frame (closed) - sentThisSlot = true; - framesWaiting_ = 0; - } else { - if (framesWaiting_ < 255) framesWaiting_++; // still draining — link behind (saturate, no wrap) + buildCoordTable(); + if (sendCoordTable()) tableRequested_ = false; } + return; } - // ADAPTIVE RESOLUTION (the deeper fallback, after frame rate). The struggle signal is - // LATENCY: the just-completed frame took more than kSlowFrames slots to drain - // (sentFrameWasSlow), or a frame/coord table didn't reach a client. This fires even when - // frames eventually send (the slow-but-complete case a pure all-sent signal misses — a - // full-res 128² frame that delivers at ~2 fps). On a sustained run of slow frames, coarsen - // the lattice (downscale_++) so frames shrink and the rate climbs; a sustained run of - // prompt, fully-sent frames refines back toward full res (downscale_ >>= 1, halving). The streaks only - // advance on slots where a frame completed (sentThisSlot), so a long drain counts as ONE - // slow frame, not many — making kDownscaleAfterSlow a count of slow frames, not ticks. - // Hysteresis stops oscillation; the factor rides the wire stride field to the status line. - const bool linkStruggling = - coordPending_ || (sentThisSlot && (!frameOk || sentFrameWasSlow)); - if (linkStruggling) { - cleanStreak_ = 0; - if (++slowStreak_ >= kDownscaleAfterSlow && downscale_ < 64) { - slowStreak_ = 0; - downscale_++; - buildAndSendCoordTable(); - } - } else if (sentThisSlot) { // only count a clean run on slots where we actually sent - slowStreak_ = 0; - if (downscale_ > 1 && ++cleanStreak_ >= kUpscaleAfterFast) { - cleanStreak_ = 0; - // AIMD-inverse recovery: coarsen ADDITIVELY (+1, gentle — above) but refine - // MULTIPLICATIVELY (halve toward 1). A run of prompt frames means the link has plenty - // of headroom, so a coarse stride collapses to full res in ~log2 refine events, not one - // per unit — the difference between a small grid settling in ~1 s vs. ~10 s. The next - // step still measures before refining again, so overshoot re-coarsens by the +1 path. - downscale_ >>= 1; // guarded by downscale_ > 1 above, so this stays >= 1 - buildAndSendCoordTable(); - } + // One frame in the slot at a time (drop-new): a frame offered while one drains is DROPPED + // at the source, the frame-dropping every lossy stream does, and the drop is REPORTED in + // the next frame's header so the client adapts on the sender's own congestion signal + // instead of probing. Rate self-limits to what the link drains; nothing waits, ever. + if (idle) { + if (!sendFrame() && dropsSinceLast_ < 255) dropsSinceLast_++; + } else if (dropsSinceLast_ < 255) { + dropsSinceLast_++; } } @@ -225,7 +256,7 @@ class PreviewDriver : public DriverBase { /// memory)`, memory from `maxAllocBlock()` — lights are kept on a spatial lattice /// (position ≡ 0 mod stride), sampling positions not indices so there is no moiré. /// Public so tests can drive it deterministically. - void buildAndSendCoordTable() { + void buildCoordTable() { coordCount_ = 0; if (!layer_ || !layer_->layouts()) return; Layouts* layouts = layer_->layouts(); @@ -297,8 +328,7 @@ class PreviewDriver : public DriverBase { // memory-driven cap), so sizing it here — not lazily to a stale point-cap — is what keeps // keptCount_ == coordCount_ and the per-frame gather complete. An alloc miss leaves the // cache too small; the gather then falls back to the full lattice walk (correct, slower). - // Only under resumableFrames — the synchronous path pushes as it walks, no index map needed. - if (resumableFrames && keptIdxCap_ < coordCount_) { + if (keptIdxCap_ < coordCount_) { auto* grown = static_cast(platform::alloc(coordCount_ * sizeof(nrOfLightsType))); if (grown) { if (keptIdx_) platform::free(keptIdx_); @@ -311,38 +341,29 @@ class PreviewDriver : public DriverBase { } } } - if (coordCount_ == 0) { coordPending_ = false; return; } - - // 0x03 app header: [type][count:u32 LE][bx][by][bz][stride:u16 LE] (10 bytes). - uint8_t h[10]; - h[0] = 0x03; - h[1] = static_cast(coordCount_ & 0xFF); - h[2] = static_cast((coordCount_ >> 8) & 0xFF); - h[3] = static_cast((coordCount_ >> 16) & 0xFF); - h[4] = static_cast((coordCount_ >> 24) & 0xFF); - h[5] = bx_; h[6] = by_; h[7] = bz_; - h[8] = static_cast(s & 0xFF); - h[9] = static_cast(s >> 8); - - if (!broadcaster_) { coordPending_ = true; return; } - broadcaster_->beginBinaryFrame(sizeof(h) + static_cast(coordCount_) * 3); - broadcaster_->pushBinaryFrame(h, sizeof(h)); - // Push the kept lights' scaled positions in small slices through a stack scratch. A dense - // grid strides its box directly (closed-form, no walk over skipped cells); a sparse/mapped - // layout walks placeLights with the lattice predicate. BOTH visit the kept lights in the - // SAME order the color pass uses, so color[k] ↔ coord[k] line up. The C callback can't - // capture, so it shares PosCtx (used by both the dense loop and the sparse callback). + if (coordCount_ == 0) return; + + // The table body is built COMPLETE into the staging buffer, ready for sendCoordTable to + // hand to the one resumable send when a client asks. The buffer is stable for a drain's + // lifetime (freed only behind cancelBufferedSend), and rewriting it is gated on an idle + // slot by every caller, so a drain never reads a half-rewritten table. + if (!ensureStaging(static_cast(coordCount_) * 3)) { + coordCount_ = 0; // alloc miss: nothing previewable until memory frees; retried next adopt + return; + } + // Emit the kept lights' scaled positions. A dense grid strides its box directly + // (closed-form, no walk over skipped cells); a sparse/mapped layout walks placeLights with + // the lattice predicate. BOTH visit the kept lights in the SAME order the color pass uses, + // so color[k] ↔ coord[k] line up. The C callback can't capture, so PosCtx is shared. struct PosCtx { - PreviewDriver* self; mm::BinaryBroadcaster* bc; nrOfLightsType s; - uint8_t buf[1536]; uint16_t fill; + PreviewDriver* self; uint8_t* out; size_t at; nrOfLightsType s; void emit(lengthType x, lengthType y, lengthType z) { - buf[fill++] = self->scaleAxis(x); - buf[fill++] = self->scaleAxis(y); - buf[fill++] = self->scaleAxis(z); - if (fill > sizeof(buf) - 3) { bc->pushBinaryFrame(buf, fill); fill = 0; } + out[at++] = self->scaleAxis(x); + out[at++] = self->scaleAxis(y); + out[at++] = self->scaleAxis(z); } }; - PosCtx pc{this, broadcaster_, s, {}, 0}; + PosCtx pc{this, staging_, 0, s}; keptCount_ = 0; // rebuilt below for the sparse path; dense gathers closed-form, no index map if (denseGrid()) { for (lengthType z = 0; z < az; z += s) @@ -362,11 +383,26 @@ class PreviewDriver : public DriverBase { p->emit(x, y, z); }, nullptr, &pc}); } - if (pc.fill) broadcaster_->pushBinaryFrame(pc.buf, pc.fill); - // The coord table must reach the browser before color frames carrying the new count (the - // browser skips a count-mismatched 0x02). endBinaryFrame() reports whether every client got - // it; tick() retries while coordPending_ and withholds color frames until it lands. - coordPending_ = !broadcaster_->endBinaryFrame(); + stagingUsed_ = pc.at; // the built table's byte length, what sendCoordTable ships + } + + /// Answer a [0x52] table request: one 0x03 message for the SERVED stride, through the same + /// resumable slot as everything else. Returns whether the send was accepted (drop-new: false + /// = the slot was busy; the caller keeps the request standing and retries next tick). + /// Header: [0x03][count u32 LE][bx][by][bz][stride u16 LE][epoch] (11 bytes). + bool sendCoordTable() { + if (!broadcaster_ || coordCount_ == 0 || !staging_) return false; + uint8_t h[11]; + h[0] = 0x03; + h[1] = static_cast(coordCount_ & 0xFF); + h[2] = static_cast((coordCount_ >> 8) & 0xFF); + h[3] = static_cast((coordCount_ >> 16) & 0xFF); + h[4] = static_cast((coordCount_ >> 24) & 0xFF); + h[5] = bx_; h[6] = by_; h[7] = bz_; + h[8] = static_cast(previewStride_ & 0xFF); + h[9] = static_cast(previewStride_ >> 8); + h[10] = epoch_; + return broadcaster_->sendBufferedFrame(h, sizeof(h), staging_, stagingUsed_); } /// Stream one per-frame `0x02` RGB message straight from the producer buffer — no @@ -379,8 +415,11 @@ class PreviewDriver : public DriverBase { const nrOfLightsType n = sourceBuffer_->count(); const nrOfLightsType s = previewStride_; - // Header: [0x02][count:u32 LE][stride:u16 LE] (7 bytes). count = the kept lights. - uint8_t header[7]; + // Header: [0x02][count:u32 LE][stride:u16 LE][epoch][drops] (9 bytes). count = the kept + // lights; (epoch, stride) is the client's table-cache key; drops = frames discarded at + // the source since the last delivered one, the sender-side congestion signal the client's + // controller adapts on. + uint8_t header[9]; header[0] = 0x02; header[1] = static_cast(coordCount_ & 0xFF); header[2] = static_cast((coordCount_ >> 8) & 0xFF); @@ -388,6 +427,8 @@ class PreviewDriver : public DriverBase { header[4] = static_cast((coordCount_ >> 24) & 0xFF); header[5] = static_cast(s & 0xFF); header[6] = static_cast(s >> 8); + header[7] = epoch_; + header[8] = dropsSinceLast_; if (s == 1 && cpl == 3 && coordCount_ <= n) { // FULL RES, RGB: the producer buffer IS the payload. Hand it to the RESUMABLE buffered @@ -395,49 +436,33 @@ class PreviewDriver : public DriverBase { // transport ticks without a copy and without spinning this loop, the fix for the // large-frame stall. The common case (any grid ≤ cap, incl. 16K on a no-PSRAM classic). // prepare cancels it before a resize frees the buffer (use-after-free guard). - return broadcaster_->sendBufferedFrame(header, sizeof(header), - src, static_cast(coordCount_) * 3); + const bool ok = broadcaster_->sendBufferedFrame(header, sizeof(header), + src, static_cast(coordCount_) * 3); + if (ok) dropsSinceLast_ = 0; + return ok; } - // Downsampled (s>1) or non-RGB (cpl≠3): the producer buffer is not the payload, so gather the - // kept lights' RGB into the staging buffer and hand THAT to the same RESUMABLE buffered send - // the full-res path uses — the gather is a few thousand byte moves (cheap on this thread), and - // the socket drain happens on the transport's ticks, not here. Building + pushing this payload - // through the synchronous begin/push/end stream instead measured ~17 ms per firing on the - // encode worker at 12K lights — a sub-hot-path violation this resumable handoff removes. The - // kept subset + order MUST match the coord table's, so color[k] ↔ coord[k] line up (the - // browser drops a count/stride-mismatched frame). A dense grid strides its box directly — - // light (x,y,z) is at buffer index z·H·W + y·W + x, closed-form, no walk over skipped cells. A - // sparse/mapped layout walks placeLights with the same lattice predicate (its index↔position - // map is arbitrary — no formula). tick()'s idle gate means no drain holds stage_ right now. - // TRANSPORT A/B (resumableFrames): ON gathers into the staging buffer and hands it to the - // RESUMABLE sender (drains on tick20ms, off the render thread — the sub-hot-path fix). OFF is - // the SYNCHRONOUS begin/push/end stream (blocking socket writes on THIS thread, ~17 ms at 12K - // lights), kept as the proven-correct reference to A/B the resumable path against on hardware. + // Downsampled (s>1) or non-RGB (cpl≠3): the producer buffer is not the payload, so gather + // the kept lights' RGB into the staging buffer (sized at the coord-table build; every + // caller gates on an idle slot, so no drain is reading it) and hand THAT to the same + // resumable send the full-res path uses. The gather is a few thousand byte moves on this + // thread; every socket byte moves on the transport tick. The kept subset + order MUST + // match the coord table's, so color[k] ↔ coord[k] line up (the browser drops a + // count/stride-mismatched frame). A dense grid strides its box directly: light (x,y,z) is + // at buffer index z·H·W + y·W + x, closed-form, no walk over skipped cells. A sparse or + // mapped layout walks placeLights with the same lattice predicate. const size_t bodyBytes = static_cast(coordCount_) * 3; - const bool resumable = resumableFrames && stage_ && stageCap_ >= bodyBytes; - // The gather sink: the staging buffer (resumable) or, per push, the synchronous stream's chunk - // buffer. `emit` is shared; the sink is chosen once here so the walk/loop below is path-agnostic. + if (!staging_ || stagingCap_ < bodyBytes) return false; // alloc miss: skip, lossy channel struct ColCtx { - mm::BinaryBroadcaster* bc; uint8_t* stage; const uint8_t* src; nrOfLightsType n; uint8_t cpl; - bool resumable; uint8_t buf[1536]; uint16_t fill; size_t staged; - void put(uint8_t b) { - if (resumable) { stage[staged++] = b; return; } - buf[fill++] = b; - if (fill > sizeof(buf) - 1) { bc->pushBinaryFrame(buf, fill); fill = 0; } - } + uint8_t* out; size_t at; const uint8_t* src; nrOfLightsType n; uint8_t cpl; void emit(nrOfLightsType idx) { const uint8_t* px = (idx < n) ? src + static_cast(idx) * cpl : nullptr; - put(px ? px[0] : 0); - put((px && cpl >= 2) ? px[1] : 0); - put((px && cpl >= 3) ? px[2] : 0); + out[at++] = px ? px[0] : 0; + out[at++] = (px && cpl >= 2) ? px[1] : 0; + out[at++] = (px && cpl >= 3) ? px[2] : 0; } }; - if (!resumable) { - broadcaster_->beginBinaryFrame(sizeof(header) + bodyBytes); - broadcaster_->pushBinaryFrame(header, sizeof(header)); - } - ColCtx col{broadcaster_, stage_, src, n, cpl, resumable, {}, 0, 0}; + ColCtx col{staging_, 0, src, n, cpl}; if (denseGrid()) { const lengthType W = layer_->physicalWidth(), H = layer_->physicalHeight(); const lengthType az = layer_->physicalDepth() > 0 ? layer_->physicalDepth() : 1; @@ -460,81 +485,83 @@ class PreviewDriver : public DriverBase { p->col->emit(idx); }, nullptr, &sk}); } - if (resumable) return broadcaster_->sendBufferedFrame(header, sizeof(header), stage_, bodyBytes); - if (col.fill) broadcaster_->pushBinaryFrame(col.buf, col.fill); - return broadcaster_->endBinaryFrame(); + const bool ok = broadcaster_->sendBufferedFrame(header, sizeof(header), staging_, col.at); + if (ok) dropsSinceLast_ = 0; + return ok; } private: - /// (Re)size the staging buffer the downsampled/non-RGB color path gathers into — the stable body - /// the resumable buffered send drains across transport ticks. Sized to the point cap (grow-only, - /// off the hot path, from prepare). An alloc miss degrades to skipped frames, never a stall. - /// Free the resumable-path buffers + refresh the memory readout. Cancels any in-flight send first — - /// its body IS stage_, so a drain must not outlive it (the use-after-free guard). Shared by release() - /// and the resumableFrames-off toggle. + /// Free the preview buffers + refresh the memory readout. Cancels any in-flight send first, so + /// a drain can never outlive the buffer it reads (the use-after-free guard). void freePreviewBuffers() { if (broadcaster_) broadcaster_->cancelBufferedSend(); - if (stage_) { platform::free(stage_); stage_ = nullptr; stageCap_ = 0; } if (keptIdx_) { platform::free(keptIdx_); keptIdx_ = nullptr; keptIdxCap_ = 0; keptCount_ = 0; } + if (staging_) { platform::free(staging_); staging_ = nullptr; stagingCap_ = 0; } publishHeapBytes(); } - void ensureStage() { - stageAllocFailed_ = false; - const size_t bytes = static_cast(maxPreviewPoints()) * 3; - if (bytes == 0 || stageCap_ >= bytes) return; - uint8_t* grown = static_cast(platform::alloc(bytes)); - if (!grown) { stageAllocFailed_ = true; return; } // degraded — status surfaced in refreshStatus - if (stage_) platform::free(stage_); - stage_ = grown; - stageCap_ = bytes; - publishHeapBytes(); // the staging buffer grew — refresh the memory readout - // keptIdx_ is sized in buildAndSendCoordTable to the exact per-rebuild coordCount_ — not here, - // because the point-cap is an UPPER bound a sparse layout stays under (kept ≤ box-lattice cells). + /// Grow-only staging for the coord-table and gathered-frame bodies: the ONE stable buffer the + /// resumable drain reads across transport ticks. Rewritten only behind an idle slot; freed only + /// behind cancelBufferedSend (freePreviewBuffers). + bool ensureStaging(size_t bytes) { + if (stagingCap_ >= bytes) return staging_ != nullptr; + auto* grown = static_cast(platform::alloc(bytes)); + if (!grown) return false; + if (staging_) platform::free(staging_); + staging_ = grown; + stagingCap_ = bytes; + publishHeapBytes(); + return true; } - /// Publish the preview's operating status: PLAIN "previewing N points" normally, or a WARNING naming - /// the degradation when a resumable-path buffer could not allocate (RAM-tight board) so the tab shows - /// WHY it fell back — the synchronous send returns (blocking socket writes on the encode thread, the - /// LED-hitch this optimization removed) or the sparse gather walks placeLights per frame. Called from - /// the cold path (prepare) and refreshed on the coord rebuild, never the render loop. + + /// Publish the preview's operating status: who is watching and at what stride normally, or a + /// WARNING when the index cache could not allocate (RAM-tight board), so the tab shows WHY the + /// sparse gather fell back to walking placeLights per frame. Called from the cold path + /// (prepare) and refreshed on the coord rebuild, never the render loop. void refreshStatus() { - if (resumableFrames && stageAllocFailed_) { - setStatus("preview degraded — staging buffer alloc failed, frames send synchronously " - "(may hitch LEDs); disable resumableFrames or free RAM", Severity::Warning); - } else if (resumableFrames && keptIdxAllocFailed_) { + if (keptIdxAllocFailed_) { setStatus("preview degraded — index cache alloc failed, gathering per frame (slower)", Severity::Warning); + } else if (lastClients_ > 0) { + // The observability the bench work had to reconstruct with socket probes: who is + // watching, and at what resolution they asked to be served. + std::snprintf(statusBuf_, sizeof(statusBuf_), "%d watching · 1/%u", + lastClients_, static_cast(downscale_)); + setStatus(statusBuf_, Severity::Status); } else { clearStatus(); } } - // A/B for the downsampled-frame transport: OFF = synchronous begin/push/end stream (blocking socket - // writes on the render thread, proven-correct); ON = gather-then-resumable-send that drains off the - // render thread. Defaults OFF: the resumable path shares the single-occupancy send slot with the ~1 Hz - // full-state push and the next preview frame, so a preempted mid-drain frame reaches the browser - // spliced — a visibly TORN preview (top rows new, the rest stale). The off-thread send is only worth it - // to avoid the ~17 ms render hitch at very large grids with the preview open; until the slot-sharing - // tear is fixed (give preview its own send slot, or drop a preempted drain cleanly), the correct - // synchronous path is the default. Kept in-tree as the A/B reference for that fix. - bool resumableFrames = false; - bool stageAllocFailed_ = false; // resumable staging buffer couldn't allocate → synchronous fallback - bool keptIdxAllocFailed_ = false; // index cache couldn't allocate → per-frame lattice walk - uint8_t* stage_ = nullptr; // gathered color payload for the resumable send (see ensureStage) - size_t stageCap_ = 0; + + /// Housekeeping cadence: refresh the watcher count in the status when it changes. The change + /// guard keeps the common tick at two integer compares. + void tick1s() MM_NONBLOCKING override { + const int c = broadcaster_ ? broadcaster_->subscriberCount() : 0; + if (c != lastClients_ || downscale_ != lastShownStride_) { + lastClients_ = c; + lastShownStride_ = downscale_; + refreshStatus(); + } + MoonModule::tick1s(); + } + int lastClients_ = 0; + nrOfLightsType lastShownStride_ = 0; + char statusBuf_[40]{}; + bool keptIdxAllocFailed_ = false; // index cache couldn't allocate → gather walks per frame nrOfLightsType* keptIdx_ = nullptr; // sparse layouts: kept lights' buffer indices, coord-table order nrOfLightsType keptIdxCap_ = 0, keptCount_ = 0; protected: // Matches DriverBase's visibility — a private override would silently hide the hook from any // future caller holding a DriverBase*. ParallelLedDriver keeps it protected for the same reason. - /// This driver's heap = the base scratch + the two preview buffers (the resumable-send staging buffer - /// and the kept-index cache). Both live only under resumableFrames; summed for the per-module memory - /// readout (see DriverBase::driverHeapBytes). PreviewDriver holds no wire_ scratch, but chaining to - /// the base keeps the rule uniform. + /// This driver's heap = the base scratch + the kept-index cache, summed for the per-module + /// memory readout (see DriverBase::driverHeapBytes). PreviewDriver holds no wire_ scratch, but + /// chaining to the base keeps the rule uniform. size_t driverHeapBytes() const override { - return DriverBase::driverHeapBytes() + stageCap_ - + static_cast(keptIdxCap_) * sizeof(nrOfLightsType); + return DriverBase::driverHeapBytes() + + static_cast(keptIdxCap_) * sizeof(nrOfLightsType) + + stagingCap_; } private: @@ -543,7 +570,7 @@ class PreviewDriver : public DriverBase { // engages — derived at runtime from free contiguous memory, not a fixed per-board constant // (architecture.md § Scaling to available memory: "sizes determined at runtime based on // available memory"). There is no per-frame buffer; the cap bounds the transient work the coord - // table build (3 bytes/point in flight to the socket) and the resumable color send impose. So + // table build (3 bytes/point in flight to the socket) imposes. So // a fragmented classic downscales SOONER (less contiguous RAM) while a roomy PSRAM board goes // far higher — one rule, every board, measured not assumed. The spatial-lattice downsample is // the graceful fallback above the cap. @@ -557,38 +584,24 @@ class PreviewDriver : public DriverBase { bool denseGrid() const { return layer_ && !layer_->lut().hasLUT(); } nrOfLightsType maxPreviewPoints() const { - // TWO independent bounds, take the smaller: - // (1) DISPLAY cap — a preview is a browser canvas a few hundred px wide; beyond ~4096 - // points the lights are sub-pixel and indistinguishable, so MORE points only cost link - // bandwidth (a 16K-point 49 KB frame streams at <1 fps even on Ethernet). Capping to a - // display-sensible count is what makes a big-RAM board (P4) downsample to a frame the - // LINK can actually push fast — the bottleneck here is throughput, not memory. WLED-MM - // caps its live preview the same way. The lattice downsample (and the browser's status) - // handle anything larger gracefully. - // (2) MEMORY cap — derived from maxAllocBlock() so a tight/fragmented board downsamples even - // SOONER than the display cap (architecture.md § Scaling to available memory). - // min(display, memory): the display cap normally wins (it's the smaller); the memory cap - // only bites on a board too tight to stream even 4096 points. - constexpr uint32_t kDisplayCap = 4096; // visual-resolution ceiling for ANY board + // NO display cap: the only bounds are MEMORY (staging + index tables must fit this + // board's largest free block) and the index type. Everything else self-degrades where it + // actually binds: a link that cannot carry full-res frames reports drops and the client + // asks coarser; a browser that cannot RENDER the points measures its own low fps and asks + // coarser too. Pre-capping "for the client's sake" only withheld detail from clients that + // could take it (deduction: the S31's RAM and a desktop GPU both dwarf any fixed number). constexpr size_t kReserve = 32u * 1024u; // leave this much contiguous headroom constexpr size_t kBytesPerPoint = 3u; // RGB on the wire / position bytes in the table constexpr nrOfLightsType kFloor = 1024; // always previewable (hard-downsampled) on any board - const size_t block = platform::maxAllocBlock(); - // maxAllocBlock() returns 0 = "unlimited / not reported" (desktop, test default): memory is - // not the limit there, so the display cap governs. - uint32_t memPts; - if (block == 0) { - memPts = kDisplayCap; - } else { - const size_t usable = block > kReserve ? block - kReserve : 0; - memPts = static_cast(usable / kBytesPerPoint); - if (memPts < kFloor) memPts = kFloor; - } - uint32_t pts = memPts < kDisplayCap ? memPts : kDisplayCap; - // Clamp into the board's nrOfLightsType range (u16 on a no-PSRAM classic). constexpr uint32_t kTypeMax = static_cast(std::numeric_limits::max()); - if (pts > kTypeMax) pts = kTypeMax; - return static_cast(pts); + const size_t block = platform::maxAllocBlock(); + // maxAllocBlock() returns 0 = "unlimited / not reported" (desktop, test default). + if (block == 0) return static_cast(kTypeMax); + const size_t usable = block > kReserve ? block - kReserve : 0; + uint32_t memPts = static_cast(usable / kBytesPerPoint); + if (memPts < kFloor) memPts = kFloor; + if (memPts > kTypeMax) memPts = kTypeMax; + return static_cast(memPts); } // Map an axis coordinate into the 0..255 byte range. posScale_ == 0 means @@ -603,34 +616,27 @@ class PreviewDriver : public DriverBase { Buffer* sourceBuffer_ = nullptr; BinaryBroadcaster* broadcaster_ = nullptr; + uint8_t* staging_ = nullptr; // stable body for table + gathered frames (see ensureStaging) + size_t stagingCap_ = 0; nrOfLightsType coordCount_ = 0; // lights the lattice keeps = the streamed 0x03/0x02 count nrOfLightsType previewStride_ = 1; // wire field: the lattice/downscale factor (1 = full res) - bool coordPending_ = false; // coord table not yet delivered; tick() retries it uint8_t bx_ = 0, by_ = 0, bz_ = 0; int32_t posScale_ = 0; // 0 = positions 1:1; else largest box edge (>255) to scale by uint32_t lastSendTime_ = 0; - uint32_t lastClientGen_ = 0; // last seen broadcaster_->clientGeneration() — re-send coords on change - - // Adaptive downscaling. The preview streams at the finest resolution the link sustains. - // The streamed send is all-or-nothing per client, so a frame (color or coord table) that - // doesn't reach every client means the link can't keep up at this resolution: coarsen - // (downscale_++) after a short run of such frames so the rebuilt lattice sends fewer points. - // A sustained run of fully-sent frames refines back toward full resolution (downscale_ >>= 1, halving). - // downscale_ is an extra floor on the per-axis lattice stride, composing with the cap - // downsample; it rides the wire stride field to the browser's "preview 1/N · link limited" - // status. (≥1; 1 = full resolution.) Hysteresis via the streak thresholds stops oscillation. + // The pull model's standing state, written on the transport thread (onClientMessage / + // onClientGone), read on the encode thread: single bytes, benign to race on a lossy channel. + static constexpr int kMaxRequestSlots = 8; + volatile uint8_t reqStride_[kMaxRequestSlots] = {}; // 0 = no standing request in this slot + volatile uint8_t reqFps_[kMaxRequestSlots] = {}; // 0 = use the targetFps control + volatile bool tableRequested_ = false; // a [0x52] is owed the table + uint8_t epoch_ = 0; // bumped per geometry rebuild; the table-cache key's half + uint8_t dropsSinceLast_ = 0; // frames discarded at the source since the last delivered one + size_t stagingUsed_ = 0; // byte length of the last-built table in staging_ + + // The served per-axis lattice stride: the coarsest standing client request (1 = full + // resolution, the value with no standing request). An extra floor on top of the cap + // downsample; rides the wire stride field to the browser's status line. nrOfLightsType downscale_ = 1; - uint8_t slowStreak_ = 0; // consecutive struggling frames (latency or not-all-sent) - uint8_t cleanStreak_ = 0; // consecutive prompt, fully-sent frames - uint8_t framesWaiting_ = 0; // fps slots skipped because the previous frame is still draining - static constexpr uint8_t kDownscaleAfterSlow = 2; // coarsen after this many slow frames (fast react) - static constexpr uint8_t kUpscaleAfterFast = 6; // refine after this many clean frames — then HALVE - // downscale_ (multiplicative recovery), so a coarse - // stride reaches full res in ~log2 steps, not linearly - // A frame still draining after this many fps slots means the link can't sustain even one frame - // at this resolution at the slowest useful rate → resolution must drop (not just the rate). Set - // above 1 so a normal multi-tick drain on a healthy link isn't mistaken for struggle. - static constexpr uint8_t kSlowFrames = 3; }; } // namespace mm diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 72eab065..884d7f1b 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1616,16 +1616,24 @@ bool TcpConnection::write(const uint8_t* data, size_t len) { // deadline (mirrors the ESP32 impl): this runs on the render thread, and a stalled peer whose TCP // receive window is full would otherwise make send() block forever and hang the loop. On timeout, // return false so the caller closes that client instead of wedging the device. - constexpr uint32_t kWriteDeadlineMs = 2000; + // TWO bounds, mirroring the ESP32 impl: the stall bound (progress resets it) lets a + // slow-but-steady transfer finish (a total-only bound truncated large assets under a parallel + // cold-cache page load); the total bound keeps a byte-trickling peer from holding the loop. + constexpr uint32_t kWriteStallMs = 2000; + constexpr uint32_t kWriteTotalMs = 8000; const uint32_t start = millis(); + uint32_t lastProgress = start; size_t sent = 0; while (sent < len) { auto n = ::send(sock(fd_), reinterpret_cast(data + sent), static_cast(len - sent), 0); if (n > 0) { sent += static_cast(n); + lastProgress = millis(); } else if (sockWouldBlock()) { - if (millis() - start >= kWriteDeadlineMs) return false; // stalled peer — don't hang the loop + const uint32_t now = millis(); + if (now - lastProgress >= kWriteStallMs || now - start >= kWriteTotalMs) + return false; // stalled or crawling peer: close it, never hang the loop std::this_thread::sleep_for(std::chrono::milliseconds(1)); #ifndef _WIN32 } else if (errno == EINTR) { diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 8fd32eae..2a78f484 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -2027,15 +2027,26 @@ bool TcpConnection::write(const uint8_t* data, size_t len) { // device — observed as a WS client connect making the board reboot every few seconds. So bound the wait // by a wall-clock deadline well above a healthy drain (µs) and well below the WDT: on timeout, return // false so the caller closes that client (the browser reconnects) instead of taking the device down. - constexpr uint32_t kWriteDeadlineMs = 2000; + // TWO bounds. The stall bound (progress resets it) is what lets a slow-but-steady transfer + // finish: bounding only the total truncated large assets mid-body on a cold-cache page load + // (six parallel responses contending on WiFi), which broke the UI's module imports until a + // refresh. The TOTAL bound is what keeps this loop off the task WDT (12 s, panic): a peer + // trickling one byte per stall window would otherwise hold the render thread indefinitely, + // a remotely triggerable reboot. Generous total, still far under the WDT. + constexpr uint32_t kWriteStallMs = 2000; + constexpr uint32_t kWriteTotalMs = 8000; const uint32_t start = millis(); + uint32_t lastProgress = start; size_t sent = 0; while (sent < len) { auto n = lwip_write(fd_, data + sent, len - sent); if (n > 0) { sent += static_cast(n); + lastProgress = millis(); } else if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - if (millis() - start >= kWriteDeadlineMs) return false; // stalled peer — don't hang the render loop + const uint32_t now = millis(); + if (now - lastProgress >= kWriteStallMs || now - start >= kWriteTotalMs) + return false; // stalled or crawling peer: close it, never hang the render loop vTaskDelay(pdMS_TO_TICKS(1)); // wait for send buffer space } else { return false; // real error diff --git a/src/ui/app.js b/src/ui/app.js index cf90675b..7dee1708 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -174,6 +174,9 @@ function connectWs() { ws.onmessage = (e) => { if (sock !== ws || wsPaused) return; // ignore a stale socket's late frame + // Binary never arrives here: the preview has its own `/wsp` connection (see + // connectPreview below), so this socket carries only control-plane JSON. A binary frame + // would mean an older firmware still multiplexing both, so keep handling it. if (e.data instanceof ArrayBuffer) { preview.onBinaryMessage(e.data); return; @@ -188,7 +191,14 @@ function connectWs() { // the existing `state` in place, then refresh the DOM. A patch before any full state is // ignored (we have nothing to patch); the device resyncs on connect so this self-corrects. if (Array.isArray(data.patch)) { - if (state && Array.isArray(state.modules)) { applyStatePatch(data.patch); updateValues(); } + if (state && Array.isArray(state.modules)) { + applyStatePatch(data.patch); + updateValues(); + // A targetFps slider move arrives as a PATCH, and the preview controller aims + // at whatever it last heard: without this, the advertised detail/smoothness + // chooser only takes effect on the next reconnect. + preview.setTargetFps(previewTargetFps(state)); + } return; } // The same /ws also carries WLED-compatibility {state,info} frames for the native WLED app @@ -196,12 +206,18 @@ function connectWs() { // without a `modules` array, or it would clobber `state` and blank the module view. if (!Array.isArray(data.modules)) return; state = data; + // Defer the DOM rebuild while the user is mid-interaction: a full state arrives on every + // (re)connect, and rebuilding under an open dropdown makes it unselectable. The state is + // already stored above, so updateValues() below still shows fresh values; the structural + // render happens on the next full state once the interaction ends. + if (userIsEditing()) { updateValues(); preview.setTargetFps(previewTargetFps(state)); return; } renderCards(); // a full state may add/remove/reshape cards (structural resync) — full render // The nav is built from the same tree, so a structural change (a module added or removed) // must rebuild it too, or the sidebar keeps entries the state no longer has. AFTER // renderCards, because its fallback may reassign selectedModule and the nav highlights it. renderNav(); updateValues(); + preview.setTargetFps(previewTargetFps(state)); } catch { // ignore malformed messages } @@ -227,9 +243,32 @@ function setWsDot(connected) { dot.className = connected ? "ws-dot connected" : "ws-dot disconnected"; } -// Visibility / bfcache hooks +// Visibility / bfcache hooks: ONE handler per event (two handlers for the same event is the +// split-rule trap). wsPaused gates message handling for the interval where a socket is open but +// the tab just hid. document.addEventListener("visibilitychange", () => { wsPaused = (document.visibilityState === "hidden"); + if (document.hidden) { + closePreviewSocket(); // stop the stream and the bad measurements NOW + wsHideTimer = setTimeout(() => { + hiddenByVisibility = true; + wsUnloading = true; // suppress the auto-reconnect while hidden + // A backoff reconnect armed BEFORE the tab hid would otherwise fire afterwards and + // open a fresh control socket on a hidden tab, exactly the work this closes. + if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } + if (ws) { try { ws.close(); } catch {} } + }, WS_HIDE_GRACE_MS); + } else { + clearTimeout(wsHideTimer); + wsHideTimer = null; + if (hiddenByVisibility) { + hiddenByVisibility = false; + wsUnloading = false; + connectWs(); // control first: the full state repaints the UI + } + // the preview follows the pane's own wants-frames state + if (previewWanted) connectPreview(); + } }); window.addEventListener("pageshow", (e) => { if (e.persisted) { @@ -248,6 +287,7 @@ window.addEventListener("pagehide", () => { clearInterval(wsHeartbeat); if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } // no reconnect after unload if (ws) { try { ws.close(1000); } catch { /* already closing */ } } + if (wsPreview) { try { wsPreview.close(1000); } catch { /* already closing */ } } // same silent handover for /wsp }); // --------------------------------------------------------------------------- @@ -270,6 +310,19 @@ async function init() { connectWs(); preview.init(); preview.setupLayout(); + // Open the preview channel only while the pane wants frames, and close it the moment it does + // not, the device then builds nothing at all for a dismissed preview. + // The pull model's uplink: tiny request messages ([0x51][stride][fps] standing, + // [0x52][stride] one-shot) on the socket the pane already holds. + preview.onSendRequest((bytes) => { + if (wsPreview && wsPreview.readyState === WebSocket.OPEN) + wsPreview.send(Uint8Array.from(bytes)); + }); + preview.onWantsFrames((wanted) => { + previewWanted = wanted; + if (wanted) { wspRetryMs = WSP_RETRY_MIN_MS; connectPreview(); } + else disconnectPreview(); + }); // First-paint shortcut: render from a one-shot /api/state so the cards appear immediately instead of // waiting for the WS's first full-state push. The WS then keeps everything live. If this fetch fails // (a contended slot), it's non-fatal — the WS full state fills in the moment it lands. Since the WS is @@ -302,13 +355,116 @@ async function init() { // buttons then appear on the next structural render instead of interrupting the edit. fetch("/api/types").then(r => r.json()).then(j => { availableTypes = j.types || []; - const el = document.activeElement; - const editing = el && (el.matches("input, textarea") || el.closest("select") - || document.querySelector('select[data-open="true"]')); - if (state && !editing) renderCards(); + if (state && !userIsEditing()) renderCards(); }).catch(() => {}); } +// The PREVIEW socket, a second connection, deliberately. +// +// Preview frames are LOSSY and large (hundreds of KB/s on a big layout); control-plane state is +// small and must not be delayed. Sharing one TCP connection makes the small messages queue behind +// the large ones, textbook head-of-line blocking, which showed up as a flickering connection dot +// and a UI that stopped responding while a big preview streamed. Separate connections is the +// standard remedy, and it lets the device raise preview resolution instead of capping it to +// protect the control plane. +// +// The device streams only to clients on this channel, so CLOSING this socket stops the work at the +// source: a dismissed preview costs the device nothing. +let wsPreview = null; +let wspRetryTimer = null; // pending preview reconnect, cancelled on close/hide +let previewWanted = false; // does the pane currently want frames? +const WSP_RETRY_MIN_MS = 1000, WSP_RETRY_MAX_MS = 15000; +let wspRetryMs = WSP_RETRY_MIN_MS; + +function connectPreview() { + if (wsPreview && (wsPreview.readyState === WebSocket.OPEN || + wsPreview.readyState === WebSocket.CONNECTING)) return; + const p = new WebSocket(`ws://${location.host}/wsp`); + wsPreview = p; + p.binaryType = "arraybuffer"; + p.onmessage = (e) => { + if (p !== wsPreview || wsPaused) return; // a stale socket's late frame is a no-op + if (e.data instanceof ArrayBuffer) preview.onBinaryMessage(e.data); + }; + // RECONNECT. A dropped preview socket must come back on its own: WiFi hiccups, a device + // reboot, or the transient close a browser does when a tab is backgrounded all end the socket + // while the pane is still visible, and without a retry the preview stayed dead until the user + // pressed refresh (observed on an S3 over WiFi, 2026-08-25). Backoff so a device that REFUSES + // the upgrade (its preview cap is full) is not hammered; reset on a successful open. + p.onopen = () => { + if (p !== wsPreview) return; + wspRetryMs = WSP_RETRY_MIN_MS; + preview.adaptStart(); // the client-side controller runs only while this socket lives + }; + p.onclose = () => { + if (p !== wsPreview) return; // superseded by a newer socket, nothing to do + wsPreview = null; + preview.adaptStop(); + if (!previewWanted) return; // the pane was dismissed; staying closed is correct + // Tracked so closePreviewSocket can cancel it: an untracked retry armed just before a + // tab-hide would reopen the preview on a hidden tab and undo the hibernation. + wspRetryTimer = setTimeout(() => { + wspRetryTimer = null; + if (previewWanted && !wsPreview && !document.hidden) connectPreview(); + }, wspRetryMs); + wspRetryMs = Math.min(wspRetryMs * 2, WSP_RETRY_MAX_MS); + }; + p.onerror = () => { try { p.close(); } catch {} }; // onclose above does the retry +} + +/// Close the socket without touching `previewWanted`, the pane's intent survives a tab-hide, +/// which is what lets the return path reopen it. +function closePreviewSocket() { + if (wspRetryTimer) { clearTimeout(wspRetryTimer); wspRetryTimer = null; } + if (!wsPreview) return; + const p = wsPreview; + wsPreview = null; + preview.adaptStop(); + try { p.close(); } catch {} +} + +function disconnectPreview() { + previewWanted = false; // the pane was dismissed: intent gone too + closePreviewSocket(); +} + +// TAB VISIBILITY → socket hibernation. A hidden tab must cost the device NOTHING: its throttled +// timers make it the worst client shape, subscribed but draining at a trickle, and its ~0 fps +// measurements would coarsen the shared preview for everyone (coarsest request wins). So: +// hidden → the preview socket closes IMMEDIATELY (the expensive stream, and the bad measurer); +// the control socket follows after a grace, so a quick alt-tab never pays the ~30 KB +// full-state resync. With both closed the device does nothing but render effects. +// visible → control reconnects first (the resync repaints the UI), then the preview via the +// normal wants-frames path, whose controller restarts fresh at full detail. +const WS_HIDE_GRACE_MS = 10000; +let wsHideTimer = null; +let hiddenByVisibility = false; + + +// The user's Preview targetFps, from the state tree, the client-side controller aims for it. +function previewTargetFps(st) { + let v = 0; + const walk = (ms) => { for (const m of ms || []) { + if (m.type === "PreviewDriver") + for (const c of m.controls || []) if (c.name === "targetFps") v = c.value; + walk(m.children); + } }; + walk(st && st.modules); + return v; +} + +// Is the user mid-interaction with a control? A full renderCards() rebuilds the DOM, so it destroys +// an open native select or a field being typed into, the control vanishes from under the cursor. +// ONE home for the test, because both re-render triggers need it: the /api/types arrival above and +// the WebSocket full state. The WS case is the one users hit: a full state is sent on every connect, +// so a browser that drops and reconnects (a big layout on modest hardware) re-renders repeatedly, +// and a dropdown can become impossible to click before it disappears. +function userIsEditing() { + const el = document.activeElement; + return !!(el && (el.matches("input, textarea") || el.closest("select") + || document.querySelector('select[data-open="true"]'))); +} + // The message for a failed fetch Response: the server's own `{"error": …}` body (JSON, e.g. // "not enough space (N free)") when present, else a bare `HTTP `. Every /api/* handler // returns errors as that JSON shape, so this is the one place the extraction lives. diff --git a/src/ui/embed_ui.cmake b/src/ui/embed_ui.cmake index 0fde741f..036cbf88 100644 --- a/src/ui/embed_ui.cmake +++ b/src/ui/embed_ui.cmake @@ -54,6 +54,7 @@ gzip_file_hex("style.css" STYLE_CSS) gzip_file_hex("install-picker.js" INSTALL_PICKER_JS) gzip_file_hex("semver.js" SEMVER_JS) gzip_file_hex("preview3d.js" PREVIEW3D_JS) +gzip_file_hex("preview-adapt.js" PREVIEW_ADAPT_JS) file(READ "${UI_DIR}/moonlight-logo.png" LOGO_PNG HEX) # Convert hex string to C array initializer @@ -77,6 +78,7 @@ hex_to_c_array("${STYLE_CSS}" "styleCss" STYLE_ARRAY) hex_to_c_array("${INSTALL_PICKER_JS}" "installPickerJs" INSTALL_PICKER_ARRAY) hex_to_c_array("${SEMVER_JS}" "semverJs" SEMVER_ARRAY) hex_to_c_array("${PREVIEW3D_JS}" "preview3dJs" PREVIEW3D_ARRAY) +hex_to_c_array("${PREVIEW_ADAPT_JS}" "previewAdaptJs" PREVIEW_ADAPT_ARRAY) hex_to_c_array("${LOGO_PNG}" "logoPng" LOGO_ARRAY) string(LENGTH "${INDEX_HTML}" INDEX_HEX_LEN) @@ -85,6 +87,7 @@ string(LENGTH "${STYLE_CSS}" STYLE_HEX_LEN) string(LENGTH "${INSTALL_PICKER_JS}" INSTALL_PICKER_HEX_LEN) string(LENGTH "${SEMVER_JS}" SEMVER_HEX_LEN) string(LENGTH "${PREVIEW3D_JS}" PREVIEW3D_HEX_LEN) +string(LENGTH "${PREVIEW_ADAPT_JS}" PREVIEW_ADAPT_HEX_LEN) string(LENGTH "${LOGO_PNG}" LOGO_HEX_LEN) math(EXPR INDEX_LEN "${INDEX_HEX_LEN} / 2") math(EXPR APP_LEN "${APP_HEX_LEN} / 2") @@ -92,6 +95,7 @@ math(EXPR STYLE_LEN "${STYLE_HEX_LEN} / 2") math(EXPR INSTALL_PICKER_LEN "${INSTALL_PICKER_HEX_LEN} / 2") math(EXPR SEMVER_LEN "${SEMVER_HEX_LEN} / 2") math(EXPR PREVIEW3D_LEN "${PREVIEW3D_HEX_LEN} / 2") +math(EXPR PREVIEW_ADAPT_LEN "${PREVIEW_ADAPT_HEX_LEN} / 2") math(EXPR LOGO_LEN "${LOGO_HEX_LEN} / 2") file(WRITE "${OUT}" "// Auto-generated — do not edit. Rebuild to update.\n") @@ -108,6 +112,8 @@ file(APPEND "${OUT}" "constexpr uint8_t semverJs[] = {${SEMVER_ARRAY}};\n") file(APPEND "${OUT}" "constexpr size_t semverJsLen = ${SEMVER_LEN};\n") file(APPEND "${OUT}" "constexpr uint8_t preview3dJs[] = {${PREVIEW3D_ARRAY}};\n") file(APPEND "${OUT}" "constexpr size_t preview3dJsLen = ${PREVIEW3D_LEN};\n") +file(APPEND "${OUT}" "constexpr uint8_t previewAdaptJs[] = {${PREVIEW_ADAPT_ARRAY}};\n") +file(APPEND "${OUT}" "constexpr size_t previewAdaptJsLen = ${PREVIEW_ADAPT_LEN};\n") file(APPEND "${OUT}" "constexpr uint8_t logoPng[] = {${LOGO_ARRAY}};\n") file(APPEND "${OUT}" "constexpr size_t logoPngLen = ${LOGO_LEN};\n") file(APPEND "${OUT}" "} // namespace mm::ui\n") diff --git a/src/ui/preview-adapt.js b/src/ui/preview-adapt.js new file mode 100644 index 00000000..c3d64f0b --- /dev/null +++ b/src/ui/preview-adapt.js @@ -0,0 +1,71 @@ +// The preview's resolution controller: a pure function over 2-second windows, unit-tested in +// test/js/preview-adapt.test.mjs. It reads ONE signal: the device's own drops counter (each 0x02 +// frame reports how many frames were discarded at the source since the last delivered one). Drops +// are ground truth for "the link cannot carry frames this big at this rate", so there is nothing +// to probe and nothing to guess: +// +// COARSEN while drops persist. One window with drops is a hiccup and changes nothing; two in a +// row (or a window where more frames were dropped than delivered) halves the detail. +// REFINE after enough clean windows, one rung at a time. A refine that brings the drops back is +// taken back, and the next attempt waits twice as long (exponential backoff, capped), the +// abandon-fast retry-slowly rule ABR players use against oscillation. +// +// Everything else follows for free. A renderer-limited device (a heavy effect at 6 fps) delivers +// every frame it makes: zero drops, full detail, correctly held. A dead link delivers nothing and +// drops nothing: the stride simply stands. targetFps never enters this function; it is the rate +// half of the standing request, and rate and size meet only at the device's send slot. + +export function initialPullState() { + return { + stride: 1, // the detail this client requests (1 = full, halved per coarsen, cap 64) + dropRun: 0, // consecutive windows that saw drops + cleanRun: 0, // consecutive windows without drops + refineWait: 2, // clean windows required before the next refine try (backs off 2x) + sinceRefine: -1, // windows since the last refine (-1 = none pending judgment) + request: false, // this transition wants a new standing request announced + }; +} + +export function nextPullState(st, delivered, dropped) { + const s = { ...st, request: false }; + // A silent window (nothing delivered, nothing dropped) carries no evidence in either + // direction: a dead link, a hibernating tab, a paused effect. Verdicts wait for data. + if (delivered === 0 && dropped === 0) return s; + // Grade the window by drop RATIO, not presence: a couple of skipped slots per window is the + // sender's normal pacing jitter (a frame occasionally not drained by its slot), not + // congestion, and coarsening on it made a healthy WiFi link wander (bench). HEAVY = more + // dropped than delivered; DIRTY = a quarter or more; anything less is trace. + const heavy = dropped >= Math.max(1, delivered); + const dirty = dropped * 4 >= Math.max(1, delivered); + if (dirty) { + s.cleanRun = 0; + s.dropRun++; + // A refine done within the last two windows is what brought the drops back: take it back + // and make the next attempt wait twice as long. + const failedRefine = s.sinceRefine >= 0 && s.sinceRefine < 2; + if (failedRefine || s.dropRun >= 2 || heavy) { + if (s.stride < 64) { s.stride *= 2; s.request = true; } + if (failedRefine) s.refineWait = Math.min(32, s.refineWait * 2); + s.dropRun = 0; + s.sinceRefine = -1; + } + } else if (dropped > 0) { + // Trace drops: pressure exists but not enough to act on. Hold the refine clock (walking + // finer INTO pressure would fail) without counting toward a coarsen. + s.cleanRun = 0; + s.dropRun = 0; + if (s.sinceRefine >= 0) s.sinceRefine++; + } else { + s.dropRun = 0; + s.cleanRun++; + if (s.sinceRefine >= 0) s.sinceRefine++; + if (s.sinceRefine >= 4) { s.sinceRefine = -1; s.refineWait = 2; } // the refine held: forgiven + if (s.stride > 1 && s.cleanRun >= s.refineWait && s.sinceRefine < 0) { + s.stride >>= 1; + s.cleanRun = 0; + s.sinceRefine = 0; + s.request = true; + } + } + return s; +} diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js index 095f810a..0f1f6b9a 100644 --- a/src/ui/preview3d.js +++ b/src/ui/preview3d.js @@ -8,6 +8,8 @@ // app only through the DOM (#preview canvas, --bg-0 theme color) and // localStorage (mm_cam). No app.js state crosses the boundary. +import { nextPullState, initialPullState } from "./preview-adapt.js"; + let gl = null; let glProgram = null; let glBuffer = null; @@ -318,7 +320,12 @@ function setupLayout() { const pip = forcePip || window.innerWidth < PIP_BELOW; ws.classList.toggle("mode-pip", pip); ws.classList.toggle("mode-docked", !pip); - ws.classList.toggle("preview-hidden", pip && dismissed); + const hidden = pip && dismissed; + ws.classList.toggle("preview-hidden", hidden); + // Tell the app whether frames are wanted. The device streams the preview only to clients on + // its `/wsp` channel, so a dismissed pane closing that socket is what actually stops the + // traffic at the source, not just hiding pixels the device already paid to send. + if (wantsFrames_) wantsFrames_(!hidden); const showBtn = document.getElementById("preview-show"); if (showBtn) showBtn.hidden = !(pip && dismissed); // The dock button means "pop out" when docked, "re-dock" when floating. @@ -425,11 +432,11 @@ function setupLayout() { } // True-shape preview: two binary message types on the preview WebSocket. -// 0x03 coordinate table (once per layout/LUT rebuild + ~1 Hz keepalive): -// [0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count] +// 0x03 coordinate table (answered on a [0x52] request; cached per (epoch, stride)): +// [0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][epoch:u8][(x,y,z):u8×3 × count] // Stores the real lights' normalised positions in previewCoords_ (the // geometry); per-frame 0x02 messages then just recolor those points. -// 0x02 per-frame channels: [0x02][count:u32][stride:u16][(r,g,b) × count] +// 0x02 per-frame channels: [0x02][count:u32][stride:u16][epoch:u8][drops:u8][(r,g,b) × count] // Color for light i sits at position previewCoords_[i]. // count is u32 so a >65535-light panel (HUB75 walls) isn't capped by the wire format. // Light index i in the 0x02 stream matches coordinate-table entry i (both are @@ -441,7 +448,11 @@ function updatePreviewStatus() { const el = document.getElementById("preview-status"); if (!el) return; const parts = []; - if (previewStride_ > 1) parts.push(`1/${previewStride_} · link limited`); // resolution shed + // Name the RIGHT cause: a stride we asked for is the link adapting; a stride above our own + // request was imposed elsewhere (the device's memory cap, or a coarser co-viewer's request), + // and targetFps cannot make that finer. + if (previewStride_ > 1) + parts.push(`1/${previewStride_}` + (previewStride_ > adaptState_.stride ? " · capped" : " · link limited")); if (effectiveFps_ > 0) parts.push(`${Math.round(effectiveFps_)} fps`); // adaptive rate if (parts.length) { el.textContent = "preview " + parts.join(" · "); @@ -462,50 +473,88 @@ function renderPreviewBinary(buf) { // Parse + cache the coordinate table: normalised (x,y,z) per point, centred on // the bounding box so the cloud sits around the origin like the old grid did. function parsePreviewCoords(view, buf) { - // Header: [0x03][count:u32][bx][by][bz][stride:u16] = 10 bytes. - if (buf.byteLength < 10) return; + // Header: [0x03][count:u32][bx][by][bz][stride:u16][epoch] = 11 bytes. + if (buf.byteLength < 11) return; const count = view.getUint32(1, true); const bx = view.getUint8(5), by = view.getUint8(6), bz = view.getUint8(7); // Validate the full payload BEFORE mutating any parser state — a truncated buffer must leave - // previewStride_ / the status line untouched (else they'd describe coords we never stored). - if (buf.byteLength < 10 + count * 3) return; - previewStride_ = view.getUint16(8, true) || 1; // = device's adaptive downscale factor - updatePreviewStatus(); - const pos = new Uint8Array(buf, 10); + // the cache / status line untouched (else they'd describe coords we never stored). + if (buf.byteLength < 11 + count * 3) return; + const stride = view.getUint16(8, true) || 1; + const epoch = view.getUint8(10); + // A geometry change (a new epoch) is a NEW canvas: verdicts measured on the old one do not + // apply. Restart the controller and re-announce, exactly as a fresh connect does. A + // stride-only table keeps the epoch and resets nothing. + if (lastEpoch_ !== null && lastEpoch_ !== epoch) { + adaptState_ = initialPullState(); + adaptFrames_ = 0; + announceRequest(); + } + lastEpoch_ = epoch; + const pos = new Uint8Array(buf, 11); const maxDim = Math.max(1, bx, by, bz); - previewMaxDim_ = maxDim; - previewCoords_ = new Float32Array(count * 3); + const coords = new Float32Array(count * 3); for (let i = 0; i < count; i++) { - previewCoords_[i * 3 + 0] = (pos[i * 3 + 0] / maxDim) - 0.5 * bx / maxDim; - previewCoords_[i * 3 + 1] = (pos[i * 3 + 1] / maxDim) - 0.5 * by / maxDim; - previewCoords_[i * 3 + 2] = (pos[i * 3 + 2] / maxDim) - 0.5 * bz / maxDim; + coords[i * 3 + 0] = (pos[i * 3 + 0] / maxDim) - 0.5 * bx / maxDim; + coords[i * 3 + 1] = (pos[i * 3 + 1] / maxDim) - 0.5 * by / maxDim; + coords[i * 3 + 2] = (pos[i * 3 + 2] / maxDim) - 0.5 * bz / maxDim; } - previewCoordCount_ = count; - previewBox_ = { x: bx, y: by, z: bz }; - // Draw the grid layout NOW, off (placeholder rings), so a fresh page / UI refresh shows the - // geometry the instant the table arrives — not only once the first color frame happens to land - // (which never comes if the scene is paused/idle). Color frames then light it. + // CACHE the table per (epoch, stride): a stride change back to a cached rung costs zero table + // traffic, the lean channel's core idea. Tables from dead epochs are dropped (the device + // renumbered the world); browser memory for one epoch's whole ladder is a few hundred KB. + for (const k of tableCache_.keys()) if (!k.startsWith(epoch + ":")) tableCache_.delete(k); + tableCache_.set(epoch + ":" + stride, { coords, count, maxDim, bx, by, bz }); + activateTable(epoch, stride); + // Draw the geometry NOW, dark, so a fresh page shows the layout the instant the table arrives, + // not only once the first color frame lands. Color frames then light it. drawLights(null); } +// Make a cached table the rendering one. Returns false when the cache misses (the caller then +// asks the device for it: the pull model). +function activateTable(epoch, stride) { + const t = tableCache_.get(epoch + ":" + stride); + if (!t) return false; + previewCoords_ = t.coords; + previewCoordCount_ = t.count; + previewMaxDim_ = t.maxDim; + previewBox_ = { x: t.bx, y: t.by, z: t.bz }; + previewStride_ = stride; + updatePreviewStatus(); + return true; +} + +// Ask the device for the coordinate table ([0x52][stride]), at most once per half second: the +// answer is paced by the drain, and re-asking faster only queues duplicate work. +function requestTable(stride) { + const now = performance.now(); + if (now - lastTableReq_ < 500) return; + lastTableReq_ = now; + if (sendRequest_) sendRequest_([0x52, stride]); +} + function renderPreviewFrame(view, buf) { if (!gl) initWebGL(); if (!gl) return; - // Hold frames until positions have arrived (the device sends the table on a geometry - // rebuild and when a new client connects, so a fresh client gets it on connect). - if (!previewCoords_ || previewCoordCount_ === 0) return; - // Header: [0x02][count:u32][stride:u16] = 7 bytes. - if (buf.byteLength < 7) return; + // Header: [0x02][count:u32][stride:u16][epoch][drops] = 9 bytes. Validate the WHOLE frame + // before feeding the adaptation counters: a truncated frame is not a delivered frame, and + // counting its drops byte would steer the controller on garbage. + if (buf.byteLength < 9) return; const count = view.getUint32(1, true); const stride = view.getUint16(5, true) || 1; - if (buf.byteLength < 7 + count * 3) return; - const rgb = new Uint8Array(buf, 7); - // RGB[i] colors the light at previewCoords_[i]. The color frame and the coordinate table - // MUST describe the same light set — if their count OR stride (downscale factor) disagree, a - // geometry rebuild (a resize, or the device's adaptive downscale changing the lattice) is - // mid-flight: the colors would land on the wrong positions (a visibly scrambled frame). - // Skip such a frame; the matching coord table arrives within ~1 frame and they realign. - if (count !== previewCoordCount_ || stride !== previewStride_) return; + const epoch = view.getUint8(7); + if (buf.byteLength < 9 + count * 3) return; + adaptFrames_++; // the controller's measurement: frames that actually arrived + windowDrops_ += view.getUint8(8); // sum the device's drop reports over the controller window + // (epoch, stride) is the table-cache key. A hit renders immediately (a stride flip costs zero + // table traffic); a miss asks the device for the positions and skips this frame, the pull + // model's whole geometry story. + if ((epoch !== lastEpoch_ || stride !== previewStride_) && !activateTable(epoch, stride)) { + requestTable(stride); + return; + } + if (count !== previewCoordCount_) return; // mid-rebuild mismatch: the next table realigns + const rgb = new Uint8Array(buf, 9); drawLights(rgb); measureFrameRate(); } @@ -851,8 +900,74 @@ function buildMVP(ex, ey, ez, tx, ty, tz, aspect) { } // Public surface — the only entry points app.js touches. +// Set by app.js: called with true when the preview wants frames, false when it is dismissed. +let wantsFrames_ = null; + +// --- client-side adaptation (see preview-adapt.js for the controller itself) ------------------- +// The loop runs only while the preview socket is open (app.js calls adaptStart/adaptStop with the +// socket lifecycle), so a hidden or dismissed pane costs nothing and sends nothing. +let adaptState_ = initialPullState(); +let lastEpoch_ = null; // geometry epoch of the active table; a change means a new canvas +const tableCache_ = new Map(); // "epoch:stride" -> {coords, count, maxDim, bx, by, bz} +let lastTableReq_ = 0; // requestTable throttle stamp +let windowDrops_ = 0; // drops reported by the device over the current window +let lastFrameAt_ = 0; // self-repair: silence past ~2 s re-announces the standing request +let adaptFrames_ = 0; +let adaptTimer_ = null; +let adaptTargetFps_ = 24; // fed from the device state by app.js (the user's targetFps control) +let sendRequest_ = null; // installed by app.js: (bytes) => send them up the /wsp socket + +// The standing frame request, the pull model's one recurring message: +// [0x51][stride][fps]. Sent on connect, on every controller decision, and as self-repair. +function announceRequest() { + if (sendRequest_) sendRequest_([0x51, adaptState_.stride, adaptTargetFps_ & 0xff]); +} + +function adaptTick() { + // SELF-REPAIR: a device reboot loses every standing request, and the client cannot tell a + // silent link from a forgotten one, so silence past one whole window re-announces. One tiny + // message; a healthy stream renders it a no-op. + if (adaptFrames_ === 0 && performance.now() - lastFrameAt_ > 2000) announceRequest(); + else if (adaptFrames_ > 0) lastFrameAt_ = performance.now(); + + const delivered = adaptFrames_; + const dropped = windowDrops_; + adaptFrames_ = 0; + windowDrops_ = 0; + adaptState_ = nextPullState(adaptState_, delivered, dropped); + if (adaptState_.request) announceRequest(); +} + export const preview = { init: initWebGL, + /// The adaptation loop follows the preview socket's lifecycle (app.js owns the socket). + adaptStart() { + adaptState_ = initialPullState(); + adaptFrames_ = 0; + windowDrops_ = 0; + lastFrameAt_ = performance.now(); + // ANNOUNCE the standing request immediately: under the pull model an unannounced client + // receives NOTHING, the device serves only what is asked. + announceRequest(); + if (!adaptTimer_) adaptTimer_ = setInterval(adaptTick, 2000); + }, + adaptStop() { + if (adaptTimer_) { clearInterval(adaptTimer_); adaptTimer_ = null; } + }, + // A NEW target invalidates every conclusion measured against the old one (a source-limited + // hold, a remembered failed stride), so the controller restarts clean and re-announces. + setTargetFps(v) { + if (!(v > 0) || v === adaptTargetFps_) return; + adaptTargetFps_ = Math.min(25, v); + adaptState_ = initialPullState(); + adaptFrames_ = 0; // the frames counted so far belong to the OLD target's window + windowDrops_ = 0; + announceRequest(); + }, + onSendRequest(cb) { sendRequest_ = cb; }, + /// Install the frames-wanted callback and report the current state immediately, so the caller + /// can open or close the preview socket without waiting for the next visibility change. + onWantsFrames(cb) { wantsFrames_ = cb; if (cb) cb(!document.querySelector(".preview-hidden")); }, setupLayout: setupLayout, onBinaryMessage: renderPreviewBinary, resetCamera: resetCamera, diff --git a/test/js/preview-adapt.test.mjs b/test/js/preview-adapt.test.mjs new file mode 100644 index 00000000..8ff7079f --- /dev/null +++ b/test/js/preview-adapt.test.mjs @@ -0,0 +1,126 @@ +// The preview's drops-driven resolution controller (src/ui/preview-adapt.js): two rules over +// 2-second windows, reading only the device's own drop reports. These tests are the functional +// documentation of how the preview trades detail for rate. +// Run: `node --test "test/js/**/*.test.mjs"`. +import test from "node:test"; +import assert from "node:assert/strict"; +import { nextPullState, initialPullState } from "../../src/ui/preview-adapt.js"; + +const step = (s, delivered, dropped) => nextPullState(s, delivered, dropped); + +test("a clean link never loses detail: any run of drop-free windows holds full resolution", () => { + let s = initialPullState(); + for (let i = 0; i < 50; i++) { + s = step(s, 24, 0); + assert.equal(s.stride, 1); + assert.equal(s.request, false); + } +}); + +test("one dirty window is a hiccup and changes nothing; a second in a row coarsens", () => { + let s = initialPullState(); + s = step(s, 20, 8); // 40% dropped: dirty + assert.equal(s.stride, 1); // tolerated once + s = step(s, 20, 8); + assert.equal(s.stride, 2); // persistent: halve the detail + assert.ok(s.request); +}); + +test("trace drops are pacing jitter, not congestion: they hold refining but never coarsen", () => { + let s = initialPullState(); + for (let i = 0; i < 20; i++) { + s = step(s, 40, 2); // 5% dropped, every window + assert.equal(s.stride, 1); // never coarsens on trace amounts + } + s = step(s, 10, 5); s = step(s, 10, 5); // real congestion -> 2 + assert.equal(s.stride, 2); + for (let i = 0; i < 20; i++) s = step(s, 40, 2); // trace forever: refine never fires either + assert.equal(s.stride, 2); +}); + +test("heavy starvation coarsens immediately: more frames dropped than delivered", () => { + let s = initialPullState(); + s = step(s, 3, 20); + assert.equal(s.stride, 2); + assert.ok(s.request); +}); + +test("a renderer-limited device keeps full detail: slow frames without drops are not congestion", () => { + // A heavy effect renders 6 fps and every frame is delivered. Coarsening could not raise that + // rate, and the controller never tries: no drops, no change. + let s = initialPullState(); + for (let i = 0; i < 30; i++) { + s = step(s, 12, 0); // 6 fps = 12 frames per 2 s window + assert.equal(s.stride, 1); + } +}); + +test("a dead link holds its stride: nothing delivered and nothing dropped is not a verdict", () => { + let s = initialPullState(); + s = step(s, 20, 8); s = step(s, 20, 8); // congestion earned stride 2 + for (let i = 0; i < 20; i++) { + s = step(s, 0, 0); // then total silence + assert.equal(s.stride, 2); // no flapping on no information + } +}); + +test("clean windows refine one rung at a time back to full detail", () => { + let s = initialPullState(); + s = step(s, 10, 5); s = step(s, 10, 5); // -> 2 + s = step(s, 10, 5); s = step(s, 10, 5); // -> 4 + assert.equal(s.stride, 4); + s = step(s, 20, 0); s = step(s, 20, 0); // two clean windows -> refine + assert.equal(s.stride, 2); + assert.ok(s.request); + // the refine held (4 clean windows forgive it), then the next refine may come + let refined = false; + for (let i = 0; i < 8 && !refined; i++) { s = step(s, 30, 0); refined = s.stride === 1; } + assert.equal(s.stride, 1); +}); + +test("a refine that brings the drops back is taken back, and the next try waits twice as long", () => { + let s = initialPullState(); + s = step(s, 10, 5); s = step(s, 10, 5); // -> 2 (congested at full detail) + s = step(s, 20, 0); s = step(s, 20, 0); // clean at 2 -> refine to 1 + assert.equal(s.stride, 1); + s = step(s, 10, 4); // drops immediately back: the refine failed + assert.equal(s.stride, 2); // taken back at once, no second bad window + assert.equal(s.refineWait, 4); // and the next attempt is more patient + s = step(s, 20, 0); s = step(s, 20, 0); s = step(s, 20, 0); + assert.equal(s.stride, 2); // 3 clean windows: still waiting + s = step(s, 20, 0); + assert.equal(s.stride, 1); // the 4th earns the retry +}); + +test("repeatedly failing refines back off exponentially and cap, so a borderline link cannot oscillate", () => { + let s = initialPullState(); + s = step(s, 10, 5); s = step(s, 10, 5); // -> 2 + let waits = []; + for (let round = 0; round < 6; round++) { + while (s.stride > 1) s = step(s, 20, 0); // clean until the refine fires + assert.equal(s.stride, 1); + s = step(s, 10, 4); // and it always fails + assert.equal(s.stride, 2); + waits.push(s.refineWait); + } + assert.deepEqual(waits, [4, 8, 16, 32, 32, 32]); // 2x each failure, capped at 32 +}); + +test("the stride caps at 64 however bad it gets, and never goes below 1", () => { + let s = initialPullState(); + for (let i = 0; i < 20; i++) s = step(s, 1, 50); + assert.equal(s.stride, 64); + for (let i = 0; i < 200; i++) s = step(s, 30, 0); + assert.equal(s.stride, 1); +}); + +test("a held refine is forgiven: later congestion coarsens without extra backoff punishment", () => { + let s = initialPullState(); + s = step(s, 10, 5); s = step(s, 10, 5); // -> 2 + s = step(s, 20, 0); s = step(s, 20, 0); // refine to 1 + for (let i = 0; i < 4; i++) s = step(s, 20, 0); // the refine HOLDS for 4 clean windows + assert.equal(s.refineWait, 2); // patience reset + s = step(s, 10, 5); s = step(s, 10, 5); // unrelated congestion much later + assert.equal(s.stride, 2); + assert.equal(s.refineWait, 2); // coarsened, but not punished as a failed refine +}); diff --git a/test/js/ui-render-guard.test.mjs b/test/js/ui-render-guard.test.mjs new file mode 100644 index 00000000..96ddfea3 --- /dev/null +++ b/test/js/ui-render-guard.test.mjs @@ -0,0 +1,64 @@ +// A full renderCards() rebuilds the module DOM, so it destroys an open native select or a field +// being typed into, the control disappears from under the cursor mid-click. +// +// Both re-render triggers must therefore check `userIsEditing()` first: the /api/types arrival, and +// the WebSocket FULL STATE. The WS one is what users actually hit, because a full state is sent on +// every (re)connect, a browser under load on a big layout reconnects repeatedly, and a dropdown +// becomes impossible to select before it vanishes. Reported on Discord (2026-08-25): "every time I +// get near the option it disappears", and still present after disabling the preview, which is what +// ruled out preview cost as the cause. +// +// app.js is a browser script rather than a module, so this pins the contract by reading the source: +// every renderCards() call on a state-arrival path is guarded. A behavioural test would need a DOM. +// +// Run: `node --test "test/js/**/*.test.mjs"`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const app = readFileSync(join(ROOT, "src", "ui", "app.js"), "utf8"); + +test("the editing guard lives in exactly one place", () => { + const defs = app.match(/function userIsEditing\s*\(/g) || []; + assert.equal(defs.length, 1, "userIsEditing must have one home, not be re-implemented per caller"); + // The predicate must cover all three interaction shapes a rebuild would destroy. + const body = app.slice(app.indexOf("function userIsEditing")); + for (const shape of ["input, textarea", "closest(\"select\")", 'select[data-open="true"]']) { + assert.ok(body.includes(shape), `userIsEditing must consider ${shape}`); + } +}); + +test("the WebSocket full state does not rebuild the DOM while the user is interacting", () => { + // The handler stores `state` then decides whether to re-render; the guard must sit between. + const i = app.indexOf("if (!Array.isArray(data.modules)) return;"); + assert.ok(i > 0, "WS full-state handler not found, this test needs updating"); + const handler = app.slice(i, i + 1200); + const guardAt = handler.indexOf("userIsEditing()"); + const renderAt = handler.indexOf("renderCards()"); + assert.ok(guardAt > 0, "the WS full state must check userIsEditing() before rebuilding"); + assert.ok(guardAt < renderAt, "the guard must precede renderCards(), or the DOM is already gone"); +}); + +test("the /api/types arrival keeps its guard too", () => { + const i = app.indexOf('fetch("/api/types")'); + assert.ok(i > 0, "/api/types fetch not found, this test needs updating"); + const block = app.slice(i, i + 400); + assert.ok(block.includes("userIsEditing()"), + "/api/types must not rebuild the DOM mid-edit either"); +}); + +test("a truncated preview frame feeds no adaptation counters: validate first, then count", () => { + // Source-pinned: renderPreviewFrame must complete BOTH length checks before adaptFrames_ or + // windowDrops_ move, or a garbage frame steers the resolution controller. + const src = readFileSync(new URL("../../src/ui/preview3d.js", import.meta.url), "utf8"); + const fn = src.slice(src.indexOf("function renderPreviewFrame")); + const body = fn.slice(0, fn.indexOf("drawLights(rgb)")); + const lastCheck = body.lastIndexOf("byteLength < 9 + count * 3"); + assert.ok(lastCheck > 0, "the body-length check must exist"); + assert.ok(body.indexOf("adaptFrames_++") > lastCheck, "frames counted only after full validation"); + assert.ok(body.indexOf("windowDrops_ +=") > lastCheck, "drops counted only after full validation"); +}); diff --git a/test/js/ui-visibility.test.mjs b/test/js/ui-visibility.test.mjs new file mode 100644 index 00000000..fa82c253 --- /dev/null +++ b/test/js/ui-visibility.test.mjs @@ -0,0 +1,71 @@ +// Tab-visibility hibernation: a hidden tab must cost the device nothing and, critically, must not +// keep MEASURING, its throttled timers read ~0 fps and would coarsen the shared preview for every +// viewer (coarsest request wins; observed live on the desktop before this landed). +// +// app.js is a browser script, so these pin the contract in the source, like ui-render-guard does. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const app = readFileSync(join(ROOT, "src", "ui", "app.js"), "utf8"); + +test("hiding the tab closes the preview socket immediately, keeping the pane's intent", () => { + const i = app.indexOf('addEventListener("visibilitychange"'); + assert.ok(i > 0, "no visibilitychange handler"); + const h = app.slice(i, i + 1600); + const hiddenBranch = h.slice(h.indexOf("document.hidden")); + assert.ok(hiddenBranch.includes("closePreviewSocket()"), + "hide must close the preview socket without clearing previewWanted"); + assert.ok(!hiddenBranch.slice(0, hiddenBranch.indexOf("} else")).includes("disconnectPreview("), + "hide must NOT dismiss the pane, intent has to survive for the return path"); +}); + +test("the control socket closes only after a grace, so alt-tab costs no resync", () => { + const i = app.indexOf("WS_HIDE_GRACE_MS"); + assert.ok(i > 0, "no hide grace constant"); + assert.ok(/setTimeout\([\s\S]{0,900}WS_HIDE_GRACE_MS\)/.test(app), + "the control-socket close must sit behind the grace timer"); + assert.ok(app.includes("clearTimeout(wsHideTimer)"), + "returning within the grace must cancel the pending close"); +}); + +test("returning reopens control first, then the preview via the pane's own intent", () => { + const i = app.indexOf('addEventListener("visibilitychange"'); + const visible = app.slice(i, i + 2200); + const elseAt = visible.indexOf("} else {"); + const ret = visible.slice(elseAt); + const ctl = ret.indexOf("connectWs()"); + const prev = ret.indexOf("connectPreview()"); + assert.ok(ctl > 0 && prev > 0 && ctl < prev, + "control reconnect must precede the preview reconnect"); + assert.ok(ret.includes("if (previewWanted)"), + "the preview reopens only when the pane still wants frames"); +}); + +test("closing the preview socket stops the adaptation loop with it", () => { + const i = app.indexOf("function closePreviewSocket"); + assert.ok(i > 0); + assert.ok(app.slice(i, i + 400).includes("preview.adaptStop()"), + "a closed socket must not keep measuring, that is the backgrounded-tab bug"); +}); + +test("hiding cancels a pending reconnect, so no control socket opens on a hidden tab", () => { + const i = app.indexOf('addEventListener("visibilitychange"'); + const hidden = app.slice(i, app.indexOf("} else {", i)); + assert.ok(/clearTimeout\(wsReconnectTimer\)/.test(hidden), + "the hide path must cancel a reconnect armed before the tab hid"); +}); + +test("a preview reconnect armed before hiding is cancelled, and never fires on a hidden tab", () => { + assert.ok(app.includes("wspRetryTimer = setTimeout("), + "the preview retry must be tracked, not a fire-and-forget setTimeout"); + const closer = app.slice(app.indexOf("function closePreviewSocket")); + assert.ok(/clearTimeout\(wspRetryTimer\)/.test(closer.slice(0, 400)), + "closing the preview socket must cancel a pending retry"); + assert.ok(app.includes("!document.hidden) connectPreview()"), + "a retry that fires anyway must refuse to open a socket on a hidden tab"); +}); diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json index 0eadc942..db316221 100644 --- a/test/scenarios/light/scenario_Audio_mutation.json +++ b/test/scenarios/light/scenario_Audio_mutation.json @@ -106,7 +106,7 @@ "desktop-macos": { "tick_us": [ 8, - 234 + 282 ], "free_heap": [ 0, @@ -118,7 +118,7 @@ ], "at": [ "2026-06-12", - "2026-07-31" + "2026-08-25" ] }, "desktop-windows": { diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json index dfc0faa3..9df67155 100644 --- a/test/scenarios/light/scenario_Driver_mutation.json +++ b/test/scenarios/light/scenario_Driver_mutation.json @@ -77,7 +77,7 @@ "desktop-macos": { "tick_us": [ 8, - 148 + 156 ], "free_heap": [ 0, @@ -89,7 +89,7 @@ ], "at": [ "2026-06-13", - "2026-07-31" + "2026-08-25" ] }, "desktop-windows": { @@ -170,7 +170,7 @@ "desktop-macos": { "tick_us": [ 8, - 103 + 107 ], "free_heap": [ 0, @@ -182,7 +182,7 @@ ], "at": [ "2026-06-13", - "2026-07-31" + "2026-08-25" ] }, "desktop-windows": { @@ -354,7 +354,7 @@ "desktop-macos": { "tick_us": [ 8, - 117 + 141 ], "free_heap": [ 0, @@ -366,7 +366,7 @@ ], "at": [ "2026-06-13", - "2026-08-20" + "2026-08-25" ] }, "desktop-windows": { @@ -445,7 +445,7 @@ "desktop-macos": { "tick_us": [ 8, - 96 + 107 ], "free_heap": [ 0, @@ -457,7 +457,7 @@ ], "at": [ "2026-06-13", - "2026-07-31" + "2026-08-25" ] }, "desktop-windows": { diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json index 03c6c756..c033b371 100644 --- a/test/scenarios/light/scenario_Layouts_mutation.json +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -79,7 +79,7 @@ "desktop-macos": { "tick_us": [ 8, - 303 + 568 ], "free_heap": [ 0, @@ -91,7 +91,7 @@ ], "at": [ "2026-06-05", - "2026-07-31" + "2026-08-25" ] }, "desktop-windows": { diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json index 91089471..e49e0956 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json @@ -614,7 +614,7 @@ "desktop-macos": { "tick_us": [ 0, - 65 + 83 ], "free_heap": [ 0, @@ -626,7 +626,7 @@ ], "at": [ "2026-06-26", - "2026-07-15" + "2026-08-25" ] }, "esp32s3-n16r8": { @@ -697,7 +697,7 @@ "desktop-macos": { "tick_us": [ 0, - 40 + 55 ], "free_heap": [ 0, @@ -709,7 +709,7 @@ ], "at": [ "2026-06-26", - "2026-08-14" + "2026-08-25" ] }, "esp32s3-n16r8": { @@ -780,7 +780,7 @@ "desktop-macos": { "tick_us": [ 3, - 30 + 44 ], "free_heap": [ 0, @@ -792,7 +792,7 @@ ], "at": [ "2026-08-19", - "2026-08-23" + "2026-08-25" ] } } diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index c7ca4a0f..dd76826f 100644 --- a/test/scenarios/light/scenario_MoonLive_pipeline.json +++ b/test/scenarios/light/scenario_MoonLive_pipeline.json @@ -595,7 +595,7 @@ "desktop-macos": { "tick_us": [ 2, - 34 + 423 ], "free_heap": [ 0, @@ -607,7 +607,7 @@ ], "at": [ "2026-08-09", - "2026-08-23" + "2026-08-25" ] }, "esp32s3-n16r8": { @@ -973,7 +973,7 @@ "desktop-macos": { "tick_us": [ 3, - 41 + 44 ], "free_heap": [ 0, @@ -985,7 +985,7 @@ ], "at": [ "2026-08-09", - "2026-08-23" + "2026-08-25" ] }, "esp32s3-n16r8": { diff --git a/test/scenarios/light/scenario_modifier_chain.json b/test/scenarios/light/scenario_modifier_chain.json index b7397e42..d717679e 100644 --- a/test/scenarios/light/scenario_modifier_chain.json +++ b/test/scenarios/light/scenario_modifier_chain.json @@ -102,7 +102,7 @@ "desktop-macos": { "tick_us": [ 6, - 108 + 202 ], "free_heap": [ 0, @@ -114,7 +114,7 @@ ], "at": [ "2026-06-26", - "2026-07-09" + "2026-08-25" ] }, "desktop-windows": { @@ -149,7 +149,7 @@ "desktop-macos": { "tick_us": [ 5, - 133 + 221 ], "free_heap": [ 0, @@ -161,7 +161,7 @@ ], "at": [ "2026-06-26", - "2026-07-15" + "2026-08-25" ] }, "desktop-windows": { diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index 4cee39f5..ef33e22d 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -396,7 +396,7 @@ "desktop-macos": { "tick_us": [ 3, - 106 + 128 ], "free_heap": [ 0, @@ -408,7 +408,7 @@ ], "at": [ "2026-06-07", - "2026-08-06" + "2026-08-25" ] }, "esp32-eth": { diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json index ed9cad44..721deb12 100644 --- a/test/scenarios/light/scenario_perf_full.json +++ b/test/scenarios/light/scenario_perf_full.json @@ -1708,7 +1708,7 @@ "desktop-macos": { "tick_us": [ 62, - 540 + 646 ], "free_heap": [ 0, @@ -1720,7 +1720,7 @@ ], "at": [ "2026-06-17", - "2026-07-31" + "2026-08-25" ] }, "esp32s3-n16r8": { diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json index 352ce492..34f4e00f 100644 --- a/test/scenarios/light/scenario_peripheral_grid_sweep.json +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -1254,7 +1254,7 @@ "desktop-macos": { "tick_us": [ 271, - 1529 + 2420 ], "free_heap": [ 0, @@ -1266,7 +1266,7 @@ ], "at": [ "2026-07-26", - "2026-08-21" + "2026-08-25" ] }, "desktop-windows": { @@ -1563,7 +1563,7 @@ "desktop-macos": { "tick_us": [ 67, - 378 + 1570 ], "free_heap": [ 0, @@ -1575,7 +1575,7 @@ ], "at": [ "2026-07-26", - "2026-08-21" + "2026-08-25" ] }, "desktop-windows": { diff --git a/test/scenarios/light/scenario_peripheral_switch.json b/test/scenarios/light/scenario_peripheral_switch.json index c0042c57..cd1885fa 100644 --- a/test/scenarios/light/scenario_peripheral_switch.json +++ b/test/scenarios/light/scenario_peripheral_switch.json @@ -380,7 +380,7 @@ "desktop-macos": { "tick_us": [ 4, - 25 + 30 ], "free_heap": [ 0, @@ -392,7 +392,7 @@ ], "at": [ "2026-07-24", - "2026-08-21" + "2026-08-25" ] }, "esp32p4rev1-eth": { @@ -487,7 +487,7 @@ "desktop-macos": { "tick_us": [ 4, - 26 + 67 ], "free_heap": [ 0, @@ -499,7 +499,7 @@ ], "at": [ "2026-07-24", - "2026-08-21" + "2026-08-25" ] }, "esp32p4rev1-eth": { @@ -719,7 +719,7 @@ "desktop-macos": { "tick_us": [ 4, - 25 + 32 ], "free_heap": [ 0, @@ -731,7 +731,7 @@ ], "at": [ "2026-07-24", - "2026-08-21" + "2026-08-25" ] }, "esp32p4rev1-eth": { diff --git a/test/unit/core/unit_HttpServerModule_apply.cpp b/test/unit/core/unit_HttpServerModule_apply.cpp index 15e6b26d..fc6acb4c 100644 --- a/test/unit/core/unit_HttpServerModule_apply.cpp +++ b/test/unit/core/unit_HttpServerModule_apply.cpp @@ -558,3 +558,82 @@ TEST_CASE("a file write with no scheduler is a no-op, not a crash") { http.applyFileChanged("/moonlive/plasma.mle"); // must simply return CHECK(true); } + +// The preview channel's inbound framing: masked client data frames whose payloads are handed on +// OPAQUELY (the producer owns their meaning). The parser's job is refusal and unmasking: wrong +// opcode, unmasked, oversized, truncated, all -1, never a read past the buffer. +TEST_CASE("the preview uplink parser unmasks exactly one small client data frame") { + uint8_t out[8]; + int used = 0; + // [0x82 binary][0x82 masked len2][mask 4][0x51^m0][7^m1] + uint8_t good[] = {0x82, 0x82, 0x11, 0x22, 0x33, 0x44, static_cast(0x51 ^ 0x11), + static_cast(7 ^ 0x22)}; + CHECK(mm::HttpServerModule::parsePreviewUplink(good, sizeof(good), out, &used) == 2); + CHECK(used == 8); + CHECK(out[0] == 0x51); + CHECK(out[1] == 7); + + // A 3-byte payload (the [0x51][stride][fps] standing request) round-trips too. + uint8_t req[] = {0x82, 0x83, 0x11, 0x22, 0x33, 0x44, static_cast(0x51 ^ 0x11), + static_cast(4 ^ 0x22), static_cast(24 ^ 0x33)}; + CHECK(mm::HttpServerModule::parsePreviewUplink(req, sizeof(req), out, &used) == 3); + CHECK(used == 9); + CHECK(out[0] == 0x51); CHECK(out[1] == 4); CHECK(out[2] == 24); + + uint8_t unmasked[] = {0x82, 0x02, 0x51, 0x07}; + CHECK(mm::HttpServerModule::parsePreviewUplink(unmasked, sizeof(unmasked), out, &used) == -1); + + uint8_t ping[] = {0x89, 0x80, 0, 0, 0, 0}; + CHECK(mm::HttpServerModule::parsePreviewUplink(ping, sizeof(ping), out, &used) == -1); + + CHECK(mm::HttpServerModule::parsePreviewUplink(good, 5, out, &used) == -1); // truncated + CHECK(used == 0); +} + +// TCP coalesces: two requests sent in quick succession can land in ONE read. The parser reports +// how many bytes a frame occupied so the caller can walk the whole buffer and deliver each +// payload in arrival order. +TEST_CASE("the preview uplink parser reports a frame's length so a coalesced read can be walked") { + uint8_t two[] = { + 0x82, 0x82, 0x11, 0x22, 0x33, 0x44, static_cast(0x51 ^ 0x11), + static_cast(2 ^ 0x22), + 0x82, 0x82, 0x55, 0x66, 0x77, 0x88, static_cast(0x52 ^ 0x55), + static_cast(8 ^ 0x66), + }; + uint8_t out[8]; + int used = 0; + CHECK(mm::HttpServerModule::parsePreviewUplink(two, sizeof(two), out, &used) == 2); + CHECK(used == 8); + CHECK(out[0] == 0x51); CHECK(out[1] == 2); + CHECK(mm::HttpServerModule::parsePreviewUplink(two + used, static_cast(sizeof(two)) - used, + out, &used) == 2); + CHECK(used == 8); + CHECK(out[0] == 0x52); CHECK(out[1] == 8); + + // A partial tail consumes nothing, so the walk stops instead of spinning or reading past it. + int tail = 0; + CHECK(mm::HttpServerModule::parsePreviewUplink(two, 5, out, &tail) == -1); + CHECK(tail == 0); +} + +// The request channel takes only SMALL payloads: anything using the WebSocket extended-length +// forms (126/127) or a plain length over 8 is refused whole, consuming nothing, so a hostile or +// confused client cannot make the walker misstep into its bytes. +TEST_CASE("the preview uplink parser refuses oversized and extended-length frames") { + uint8_t out[8]; + int used = 7; + + uint8_t tooLong[6 + 9] = {0x82, static_cast(0x80 | 9), 1, 2, 3, 4}; + CHECK(mm::HttpServerModule::parsePreviewUplink(tooLong, sizeof(tooLong), out, &used) == -1); + CHECK(used == 0); + + uint8_t ext16[64] = {0x82, static_cast(0x80 | 126), 0, 20, 1, 2, 3, 4}; + used = 7; + CHECK(mm::HttpServerModule::parsePreviewUplink(ext16, sizeof(ext16), out, &used) == -1); + CHECK(used == 0); + + uint8_t ext64[64] = {0x82, static_cast(0x80 | 127), 0, 0, 0, 0, 0, 0, 0, 20}; + used = 7; + CHECK(mm::HttpServerModule::parsePreviewUplink(ext64, sizeof(ext64), out, &used) == -1); + CHECK(used == 0); +} diff --git a/test/unit/light/unit_PreviewDriver.cpp b/test/unit/light/unit_PreviewDriver.cpp index 7c74a4d8..53716f6d 100644 --- a/test/unit/light/unit_PreviewDriver.cpp +++ b/test/unit/light/unit_PreviewDriver.cpp @@ -21,11 +21,11 @@ namespace { -// Captures the two preview message types so tests can inspect them. PreviewDriver STREAMS each -// frame via begin/push/end (no frame buffer); the mock reassembles the pushed slices, strips the -// WS header (begin is given the PAYLOAD length, so what's pushed is exactly the payload), and -// classifies by first byte at end. dropCoord/acceptNext make endBinaryFrame report a client that -// didn't get the whole frame (false) to drive the coord-pending retry + adaptive-downscale paths. +// Captures the two preview message types so tests can inspect them. Every message arrives through +// the ONE resumable send (sendBufferedFrame) as header ++ body, classified by the type byte: +// 0x03 tables (11-byte header, epoch at [10]) into lastCoord, 0x02 frames (9-byte header, epoch +// at [7], drops at [8]) into lastFrame. dropCoord/acceptNext make a send report "slot busy" +// (false) to drive the request-retry and drop-counting paths. // -Wnon-virtual-dtor: BinaryBroadcaster's own destructor is protected and non-virtual on // purpose ("not owned through this interface"), so no code can delete through a base pointer. // This double is a stack local in every test, never owned polymorphically — and it cannot copy @@ -41,37 +41,29 @@ namespace { struct CaptureBroadcaster : mm::BinaryBroadcaster { int coordMsgs = 0, frameMsgs = 0; std::vector lastCoord, lastFrame; - std::vector cur_; // payload accumulated across pushBinaryFrame between begin/end - uint32_t generation = 0; // bump to simulate a new client connecting - bool acceptNext = true; // false → endBinaryFrame reports a color frame not fully sent - bool dropCoord = false; // true → endBinaryFrame reports a coord table not fully sent - - void beginBinaryFrame(size_t /*totalLen*/) override { cur_.clear(); } - void pushBinaryFrame(const uint8_t* data, size_t len) override { - cur_.insert(cur_.end(), data, data + len); + bool acceptNext = true; // false → a color-frame send is refused (slot busy) + bool dropCoord = false; // true → a coord-table send is refused (slot busy) + // The registered inbound-message sink (the driver): tests speak the pull protocol through it. + ClientMessageSink* sink = nullptr; + void setClientMessageSink(ClientMessageSink* s) override { sink = s; } + // Convenience: a client in `slot` posts a standing [0x51][stride][fps] frame request. + void ask(uint8_t stride, uint8_t fps = 0, int slot = 0) { + const uint8_t m[3] = {0x51, stride, fps}; + if (sink) sink->onClientMessage(slot, m, 3); } - bool endBinaryFrame() override { - if (cur_.empty()) return false; - const uint8_t type = cur_[0]; - if (type == 0x03) { - if (dropCoord) return false; // simulate the table not reaching the client - coordMsgs++; lastCoord = cur_; return true; - } - if (type == 0x02) { - if (!acceptNext) return false; // simulate the color frame not reaching the client - frameMsgs++; lastFrame = cur_; return true; - } - return true; + // Convenience: a client asks for the coordinate table ([0x52][stride]). + void askTable(uint8_t stride = 1, int slot = 0) { + const uint8_t m[2] = {0x52, stride}; + if (sink) sink->onClientMessage(slot, m, 2); } - uint32_t clientGeneration() const override { return generation; } // Single-threaded test transport: one producer thread, so there is no race to exclude — grant // unconditionally (the "may return true unconditionally" case in BinaryBroadcaster). bool tryAcquireSend() override { return true; } void releaseSend() override {} - // Resumable buffered send — the color-frame path (coord table uses begin/push/end). The mock - // captures it as a 0x02 frame (header ++ body). `bufferedDrains` models a slow link: the send - // stays "in flight" for that many bufferedSendIdle() polls before going idle (0 = instant). + // The ONE resumable send: every /wsp message (0x03 tables and 0x02 frames alike) arrives + // here, routed by its type byte. `bufferedDrains` models a slow link: the send stays "in + // flight" for that many bufferedSendIdle() polls before going idle (0 = instant). // bufferedFrames counts accepted sends; bufferedDropped counts newest-wins backpressure drops. int bufferedFrames = 0, bufferedDropped = 0; int bufferedDrains = 0; // ticks a send stays active (set >0 to model a slow link) @@ -80,10 +72,18 @@ struct CaptureBroadcaster : mm::BinaryBroadcaster { bool sendBufferedFrame(const uint8_t* header, size_t headerLen, const uint8_t* body, size_t bodyLen) override { if (active_) { bufferedDropped++; return false; } // newest-wins backpressure - if (!acceptNext) return false; - bufferedFrames++; frameMsgs++; - lastFrame.assign(header, header + headerLen); - lastFrame.insert(lastFrame.end(), body, body + bodyLen); + const uint8_t type = headerLen ? header[0] : 0; + if (type == 0x03) { + if (dropCoord) return false; // simulate the table send refused (slot busy) + coordMsgs++; + lastCoord.assign(header, header + headerLen); + lastCoord.insert(lastCoord.end(), body, body + bodyLen); + } else { + if (!acceptNext) return false; // simulate the color frame refused + bufferedFrames++; frameMsgs++; + lastFrame.assign(header, header + headerLen); + lastFrame.insert(lastFrame.end(), body, body + bodyLen); + } lastBody = body; remaining_ = bufferedDrains; // >0 → stays "in flight" to model a slow link active_ = (remaining_ > 0); @@ -95,14 +95,17 @@ struct CaptureBroadcaster : mm::BinaryBroadcaster { } void cancelBufferedSend() override { if (active_) bufferedCanceled++; active_ = false; } - // 0x03 = [type][count:u32][bx][by][bz][stride:u16] (10-byte header) - // 0x02 = [type][count:u32][stride:u16] (7-byte header) + // 0x03 = [type][count:u32][bx][by][bz][stride:u16][epoch] (11-byte header) + // 0x02 = [type][count:u32][stride:u16][epoch][drops] (9-byte header) static uint32_t u32le(const std::vector& b, size_t o) { return b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (static_cast(b[o + 3]) << 24); } int coordCount() const { return lastCoord.size() >= 5 ? static_cast(u32le(lastCoord, 1)) : -1; } int frameCount() const { return lastFrame.size() >= 5 ? static_cast(u32le(lastFrame, 1)) : -1; } - int coordStride() const { return lastCoord.size() >= 10 ? lastCoord[8] | (lastCoord[9] << 8) : -1; } + int coordStride() const { return lastCoord.size() >= 11 ? lastCoord[8] | (lastCoord[9] << 8) : -1; } + int coordEpoch() const { return lastCoord.size() >= 11 ? lastCoord[10] : -1; } + int frameEpoch() const { return lastFrame.size() >= 9 ? lastFrame[7] : -1; } + int frameDrops() const { return lastFrame.size() >= 9 ? lastFrame[8] : -1; } private: mutable bool active_ = false; // a buffered send is in flight @@ -142,7 +145,10 @@ struct PreviewRig { } void produce() { - preview->buildAndSendCoordTable(); + // Drives the build/send methods directly (bypassing tick), so no [0x52] is posted: a + // standing tableRequested_ flag would leak into the tick-driven tests that follow. + preview->buildCoordTable(); + preview->sendCoordTable(); preview->sendFrame(); } }; @@ -159,11 +165,52 @@ TEST_CASE("PreviewDriver coordinate table carries the real lights, not the box") REQUIRE(rig.cap.coordMsgs > 0); CHECK(rig.cap.coordCount() == 210); // the shell, not 729 CHECK(rig.cap.coordStride() == 1); // small → exact, no downsample - // 0x03 = [0x03][count:u32][bx][by][bz][stride:u16] (10-byte hdr) + count*3 position bytes - CHECK(rig.cap.lastCoord.size() == 10u + 210u * 3u); + // 0x03 = [0x03][count:u32][bx][by][bz][stride:u16][epoch] (11-byte hdr) + count*3 positions + CHECK(rig.cap.lastCoord.size() == 11u + 210u * 3u); +} + +// The device serves the resolution the CLIENT requests, it no longer measures the link itself +// (a device-side controller can only see its own socket, and cycled; the receiver measures the +// true end-to-end rate). The stride changes exactly when a request arrives, and never otherwise. +TEST_CASE("PreviewDriver adopts the client-requested stride, and only then") { + mm::GridLayout g; g.width = 64; g.height = 64; g.depth = 1; + PreviewRig rig(&g); + + uint32_t t = 1000; + auto tickAt = [&](int n) { for (int i=0;itick(); } }; + + const int coordsBefore = rig.cap.coordMsgs; + tickAt(50); // 5 s with NO standing request: pure silence + CHECK(rig.cap.frameMsgs == 0); + CHECK(rig.cap.coordMsgs == coordsBefore); + + rig.cap.ask(4); // a client asks for 1/4 + tickAt(1); + CHECK(rig.preview->downscaleForTest() == 4); // served exactly as requested + + tickAt(50); // the request is STANDING, no drift back + CHECK(rig.preview->downscaleForTest() == 4); + CHECK(rig.cap.coordMsgs == coordsBefore); // and no table was volunteered for it + + rig.cap.ask(1); // the client asks for full detail again + tickAt(1); + CHECK(rig.preview->downscaleForTest() == 1); + mm::platform::setTestNowMs(0); +} + +// Garbage from the network must not steer the lattice: hints outside [1, 64] are ignored. +TEST_CASE("PreviewDriver ignores an out-of-range stride request") { + mm::GridLayout g; g.width = 32; g.height = 32; g.depth = 1; + PreviewRig rig(&g); + rig.cap.ask(4); // a real standing request first + mm::platform::setTestNowMs(1000); rig.preview->tick(); + CHECK(rig.preview->downscaleForTest() == 4); + rig.cap.ask(200); // garbage: ignored at the store + mm::platform::setTestNowMs(1100); rig.preview->tick(); + CHECK(rig.preview->downscaleForTest() == 4); // the real request still stands + mm::platform::setTestNowMs(0); } -// Per-frame 0x02 RGB count matches the coordinate-table count. TEST_CASE("PreviewDriver per-frame RGB count matches the coordinate table") { mm::SphereLayout s; s.radius = 4; @@ -172,8 +219,8 @@ TEST_CASE("PreviewDriver per-frame RGB count matches the coordinate table") { REQUIRE(rig.cap.frameMsgs > 0); CHECK(rig.cap.frameCount() == 210); - // 0x02 = [0x02][count:u32][stride:u16] (7-byte hdr) + count*3 RGB bytes - CHECK(rig.cap.lastFrame.size() == 7u + 210u * 3u); + // 0x02 = [0x02][count:u32][stride:u16][epoch][drops] (9-byte hdr) + count*3 RGB bytes + CHECK(rig.cap.lastFrame.size() == 9u + 210u * 3u); } // A small grid sends every light at its grid position (stride 1, exact). @@ -192,17 +239,21 @@ TEST_CASE("PreviewDriver small grid sends all lights exactly") { // index) so the payload fits the send-buffer cap without the diagonal moiré that linear // stride produced on a grid whose width didn't divide the stride. The wire "stride" field // carries the per-axis lattice/downscale factor (color k still maps 1:1 to coord k). -TEST_CASE("PreviewDriver downsamples a large layout on a regular spatial lattice") { - // 200×200 = 40000 lights, over the 4096 display cap → the lattice downsample engages. The - // extent (199) is ≤255/axis, so positions are sent at EXACT integer grid coordinates (no +TEST_CASE("PreviewDriver downsamples on a regular spatial lattice when a client asks coarser") { + // There is NO display cap: a host build (unlimited memory) serves any layout at full detail, + // and coarseness exists only as a client REQUEST. Ask for 1/2 and pin the lattice geometry. + // The extent (199) is ≤255/axis, so positions are sent at EXACT integer grid coordinates (no // byte-scaling rounding) — letting the regularity check below compare true lattice positions. mm::GridLayout g; g.width = 200; g.height = 200; g.depth = 1; PreviewRig rig(&g); + rig.cap.ask(2); // the client requests 1/2 + mm::platform::setTestNowMs(2000); rig.preview->tick(); // adopt the request + mm::platform::setTestNowMs(0); rig.produce(); - CHECK(rig.cap.coordStride() >= 2); // display cap forces a lattice step (the factor) - CHECK(rig.cap.coordCount() <= 4096); // downsampled under the display cap + CHECK(rig.cap.coordStride() == 2); // served exactly as asked + CHECK(rig.cap.coordCount() == 100 * 100); // ceil(200/2) per axis CHECK(rig.cap.coordCount() > 0); CHECK(rig.cap.coordCount() == rig.cap.frameCount()); // table + RGB agree (lockstep) @@ -244,41 +295,41 @@ TEST_CASE("PreviewDriver keeps a sparse large-box layout at full resolution") { } // Default fps is the rate-limited preview stream rate. -TEST_CASE("PreviewDriver fps default") { +TEST_CASE("PreviewDriver targetFps default") { mm::PreviewDriver driver; - CHECK(driver.fps == 24); + CHECK(driver.targetFps == 24); } // Regression: a coordinate table dropped under backpressure must be RETRIED, and color // frames withheld until it lands — otherwise the device sends 0x02 frames the browser skips // (count mismatch) and the preview freezes for the whole session. Drives tick() (where the // coord-pending logic lives) with a broadcaster that drops every 0x03, then lets it through. -TEST_CASE("PreviewDriver retries a dropped coordinate table, withholds frames until it lands") { +TEST_CASE("a table request outranks frames, is retried while refused, and frames then resume") { mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; // 256 lights, full res PreviewRig rig(&g); - rig.cap.dropCoord = true; // every coord table is lost to backpressure rig.cap.frameMsgs = 0; // ignore any frame from rig construction - rig.cap.generation = 1; // a "new client" — forces tick() to rebuild+resend - // the coord table, which dropCoord now loses + rig.cap.coordMsgs = 0; + rig.cap.ask(1); // a viewer wants frames... + rig.cap.askTable(); // ...and asked for the positions first + rig.cap.dropCoord = true; // but every table send is refused (slot busy) - // Advance the test clock past the fps gate (interval = 1000/24 ≈ 42 ms) before each tick(). uint32_t t = 1000; auto tick = [&] { t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); }; - // Pump tick() several times. The rebuilt 0x03 never lands, so NO color frame may go out — - // a 0x02 now would carry a count the browser can't map (the freeze the guard prevents). + // Pump tick(). The owed 0x03 outranks frames, so while it cannot go out, NOTHING does: a + // 0x02 now would carry a count the asker cannot map. for (int i = 0; i < 5; i++) tick(); - CHECK(rig.cap.frameMsgs == 0); // frames withheld while the table is pending - - // Link recovers: the table now lands, and frames resume — matching the same count. - rig.cap.dropCoord = false; - tick(); // retries the pending table (it lands) - tick(); // now a color frame may go out - CHECK(rig.cap.coordMsgs > 0); // the table finally reached the client - CHECK(rig.cap.frameMsgs > 0); // frames resumed - CHECK(rig.cap.coordCount() == rig.cap.frameCount()); // and they agree (no freeze) + CHECK(rig.cap.frameMsgs == 0); + CHECK(rig.cap.coordMsgs == 0); + + rig.cap.dropCoord = false; // the slot frees + tick(); // the owed table lands + tick(); // and frames resume + CHECK(rig.cap.coordMsgs == 1); // sent exactly once, not spammed + CHECK(rig.cap.frameMsgs > 0); + CHECK(rig.cap.coordCount() == rig.cap.frameCount()); - mm::platform::setTestNowMs(0); // release the clock override + mm::platform::setTestNowMs(0); } // Regression: deleting the active Layer must not leave a driver holding a @@ -321,37 +372,33 @@ TEST_CASE("PreviewDriver tolerates the active Layer being deleted") { CHECK(preview->layer() == nullptr); // cleared, not dangling // And producing a frame on the empty pipeline is a safe no-op (no crash). - preview->buildAndSendCoordTable(); + preview->buildCoordTable(); preview->sendFrame(); CHECK(cap.frameMsgs == 0); // nothing to send with no layer } -// Coordinates are sent ONLY when the geometry changes or a new client connects — never -// per-frame and never on a timer (a periodic full-table rebuild would starve the tick). -// A new client (clientGeneration bump) re-sends immediately so a page refresh shows the -// preview at once. Driven through tick() with a frozen clock for determinism. -TEST_CASE("PreviewDriver sends coordinates only on change / new client, never on a timer") { +// The pull model: a coordinate table is sent ONLY when a client asks ([0x52]), never on a +// timer, never per-frame, never volunteered on a connect or a geometry change (the device is a +// dumb producer; a client whose cache misses asks). Driven through tick() with a frozen clock. +TEST_CASE("PreviewDriver sends the coordinate table only when a client asks, never on its own") { mm::platform::setTestNowMs(100000); PreviewRig rig(new mm::GridLayout(), 3); + rig.cap.ask(1); // a standing frame request, but NO table request + rig.cap.coordMsgs = 0; - rig.preview->tick(); // first loop: coords sent (count was 0) - int afterFirst = rig.cap.coordMsgs; - CHECK(afterFirst >= 1); - - // Advance a FULL 3 seconds with no new client and no rebuild: tick() keeps sending - // color frames but must NOT re-send the coordinate table. This is the regression - // guard — the removed ~1 Hz timer would have re-sent ~3 times here. - for (int t = 1; t <= 3; t++) { - mm::platform::setTestNowMs(100000 + t * 1000); + // 3 seconds of frames: the table is never volunteered. + for (int t = 1; t <= 30; t++) { + mm::platform::setTestNowMs(100000 + t * 100); rig.preview->tick(); } - CHECK(rig.cap.coordMsgs == afterFirst); // no timer-driven re-send across 3s + CHECK(rig.cap.frameMsgs > 0); + CHECK(rig.cap.coordMsgs == 0); - // A new client connects (generation bumps). The next tick() re-sends coords at once. - rig.cap.generation++; - mm::platform::setTestNowMs(104200); - rig.preview->tick(); - CHECK(rig.cap.coordMsgs == afterFirst + 1); // re-sent for the fresh client + // The client asks: the next tick answers, exactly once. + rig.cap.askTable(); + mm::platform::setTestNowMs(104100); rig.preview->tick(); + mm::platform::setTestNowMs(104200); rig.preview->tick(); + CHECK(rig.cap.coordMsgs == 1); mm::platform::setTestNowMs(0); // restore the real clock for other tests } @@ -385,34 +432,21 @@ TEST_CASE("PreviewDriver buffered send uses the sparse driver buffer, not the de CHECK(rig.cap.lastBody != rig.layer.buffer().data()); // NOT the dense box — the mapped output } -// The per-module memory readout (dynamicBytes) must ACCOUNT the resumable-path buffers — the staging -// buffer and the kept-index cache — not read 0 while ~24 KB is allocated (the bug: raw platform::alloc -// buffers bypass ScratchBuffer's auto-accounting, so they were invisible). With resumableFrames ON and a -// downsampled layout, dynamicBytes is non-zero and covers both buffers; OFF frees them and it drops to 0. -// -// SKIPPED (doctest::skip): this case was written when resumableFrames defaulted ON — its ON assertions -// relied on the rig constructor's applyState() allocating the staging buffer via that default. resumableFrames -// now defaults OFF (the synchronous transport is the shipped default; the resumable path tears the preview), -// and toggling the flag ON post-construction + prepare() does NOT re-allocate the buffers in this rig the way -// the constructor path did, so the ON reads drop to 0. The dynamicBytes accounting itself (driverHeapBytes -// sums stageCap_ + keptIdxCap_) is unchanged and correct — only this test's default assumption broke. The -// un-skip is backlogged (docs/backlog/backlog-light.md § "PreviewDriver `resumableFrames` default OFF"): wire -// the flag ON into the rig BEFORE its first applyState so the acquire path runs. Original body is in git history. -TEST_CASE("PreviewDriver reports its resumable-path buffers in dynamicBytes" * doctest::skip()) { - MESSAGE("skipped — see docs/backlog/backlog-light.md (resumableFrames default OFF)"); -} - -// Dense-grid CLOSED-FORM downsample, exact color placement: a 200×1 strip pinned over a small cap +// Dense-grid CLOSED-FORM downsample, exact color placement: a wide strip pinned over the cap // strides in x only, so the kept lights are columns 0,s,2s,… The color pass must read each from its // dense buffer index (closed-form x for a 1-row grid) and pack them in the SAME order as the coord // table: no placeLights. Painting a known color at a kept column and finding it at the matching // frame position pins the index math + the lattice order. TEST_CASE("PreviewDriver dense downsample packs colors by closed-form index, in lattice order") { - const int width = 5000; // > the 4096 display cap → forces a stride + const int width = 20000; mm::GridLayout g; g.width = width; g.height = 1; g.depth = 1; PreviewRig rig(&g); + rig.cap.ask(3); // the client requests 1/3 (no cap forces one) + mm::platform::setTestNowMs(2000); rig.preview->tick(); + mm::platform::setTestNowMs(0); + rig.produce(); // ask for + receive the table (pull model) const int s = rig.cap.coordStride(); - REQUIRE(s >= 2); // 5000 cols over the 4096 cap → strided in x + REQUIRE(s == 3); // served exactly as asked const int kept = rig.cap.coordCount(); REQUIRE(kept == (width + s - 1) / s); // ceil(width/s) — closed-form count @@ -423,9 +457,9 @@ TEST_CASE("PreviewDriver dense downsample packs colors by closed-form index, in rig.preview->sendFrame(); // 0x02 = 7-byte hdr + (r,g,b)×kept. The 2nd kept light is the 2nd triple → its G byte at 7+3+1. - REQUIRE(rig.cap.lastFrame.size() == 7u + static_cast(kept) * 3u); - CHECK(rig.cap.lastFrame[7 + 3 + 1] == 150); // painted column landed at the 2nd position - CHECK(rig.cap.lastFrame[7 + 1] == 0); // column 0 is black (1st position) + REQUIRE(rig.cap.lastFrame.size() == 9u + static_cast(kept) * 3u); + CHECK(rig.cap.lastFrame[9 + 3 + 1] == 150); // painted column landed at the 2nd position + CHECK(rig.cap.lastFrame[9 + 1] == 0); // column 0 is black (1st position) mm::platform::setTestMaxAllocBlock(0); } @@ -435,6 +469,7 @@ TEST_CASE("PreviewDriver dense downsample packs colors by closed-form index, in TEST_CASE("PreviewDriver gates the next frame on the buffered send draining (adaptive fps)") { mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; PreviewRig rig(&g); + rig.cap.ask(1); // a standing request: the pull model serves only the asked rig.cap.bufferedDrains = 3; // each send stays "in flight" for 3 idle-polls (slow link) uint32_t t = 1000; @@ -451,67 +486,6 @@ TEST_CASE("PreviewDriver gates the next frame on the buffered send draining (ada mm::platform::setTestNowMs(0); } -// ADAPTIVE RESOLUTION RECOVERY: the downsample coarsens ADDITIVELY (downscale_++ on slow frames, a -// gentle anti-stall) but refines MULTIPLICATIVELY (halve toward 1 on a run of clean frames). So a grid -// that briefly coarsened on a slow link snaps back to full resolution in ~log2 refine events, not one -// step per unit — the fix for a small grid taking ~10 s to reach full detail. This pins the halving so -// the recovery can't silently regress to the old linear crawl. -TEST_CASE("PreviewDriver refines resolution multiplicatively (fast recovery to full res)") { - mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; // 256 lights, trivially full-res-able - PreviewRig rig(&g); - - uint32_t t = 1000; - auto tickSlow = [&] { rig.cap.bufferedDrains = 5; t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); }; - auto tickFast = [&] { rig.cap.bufferedDrains = 0; t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); }; - - // Drive it coarse: a run of slow frames coarsens downscale_ well above 1 (additive +1 per event). - for (int i = 0; i < 40; i++) tickSlow(); - const mm::nrOfLightsType coarsened = rig.preview->downscaleForTest(); - REQUIRE(coarsened > 1); // it did downsample under the slow link - - // Now the link is prompt. Count how many refine EVENTS (clean-streak completions) it takes to reach - // full res. Multiplicative halving needs ~log2(coarsened) events, far fewer than (coarsened-1) linear - // steps. kUpscaleAfterFast clean frames per event; bound the loop generously and assert it converged. - int refineEvents = 0; - mm::nrOfLightsType prev = coarsened; - for (int i = 0; i < 200 && rig.preview->downscaleForTest() > 1; i++) { - tickFast(); - const mm::nrOfLightsType now = rig.preview->downscaleForTest(); - if (now < prev) { refineEvents++; CHECK(now <= (prev + 1) / 2); prev = now; } // each event at least halves - } - CHECK(rig.preview->downscaleForTest() == 1); // reached full resolution - // log2(64 max) = 6 events ceiling; a real coarsened value needs far fewer. Linear would be up to 63. - CHECK(refineEvents <= 6); - - mm::platform::setTestNowMs(0); -} - -// RE-ANCHOR ON REBUILD: a link-struggle coarsening must NOT carry across a geometry change and hold a -// now-fitting grid coarse. A rebuild resets downscale_ to 1, so the memory/display cap alone sets the -// stride for the new grid — a grid that fits renders at full res immediately, no inherited ramp. (This -// is the "add a small grid → stuck at 4 blobs for ~10 s because a prior config had coarsened" fix.) -TEST_CASE("PreviewDriver re-anchors resolution on a geometry rebuild (no inherited coarsening)") { - mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; - PreviewRig rig(&g); - - uint32_t t = 1000; - auto tickSlow = [&] { rig.cap.bufferedDrains = 5; t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); }; - - // Coarsen it under a slow link. - for (int i = 0; i < 40; i++) tickSlow(); - REQUIRE(rig.preview->downscaleForTest() > 1); // it coarsened - - // A rebuild (a resize, or just re-preparing the same fitting grid) must re-anchor to full res: the - // 16×16 (256 lights) is well under the cap, so with downscale_ reset it renders at stride 1. - rig.preview->applyState(); // prepare() re-anchors downscale_ - CHECK(rig.preview->downscaleForTest() == 1); // did NOT inherit the stale coarsening - - mm::platform::setTestNowMs(0); -} - -// USE-AFTER-FREE GUARD: a geometry rebuild (resize) frees+reallocs the producer buffer, so any -// in-flight buffered send (which holds a pointer into it) MUST be cancelled in prepare before -// the buffer goes away — else drainPreviewSend would read freed memory. TEST_CASE("PreviewDriver cancels an in-flight buffered send on rebuild (resize safety)") { mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; PreviewRig rig(&g); @@ -529,3 +503,29 @@ TEST_CASE("PreviewDriver cancels an in-flight buffered send on rebuild (resize s CHECK(rig.cap.bufferedCanceled == cancelsBefore + 1); // the stale send was cancelled } + +TEST_CASE("a wedged link never blocks a tick, never closes a client, and resumes when it drains") { + // The step-1 contract of the lean transport: the producer can only ARM messages; every socket + // byte moves on the transport tick at TCP's pace. A link that stops draining therefore holds + // the preview (one frame parked in the slot), costs the render loop nothing, and, since the + // broadcaster interface has no per-client close at all, the producer CANNOT disconnect anyone. + mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; + PreviewRig rig(&g); + rig.cap.ask(1); // a standing request: the pull model serves only the asked + rig.cap.bufferedDrains = 1000; // effectively wedged: stays in flight for 1000 idle-polls + + const int tableAtSetup = rig.cap.coordMsgs; // the rig's prepare already delivered the table + uint32_t t = 1000; + for (int i = 0; i < 100; i++) { t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); } + CHECK(rig.cap.bufferedFrames == 1); // ONE frame parked in the slot, nothing spammed + CHECK(rig.cap.coordMsgs == tableAtSetup); // and no table churn either + CHECK(rig.cap.bufferedDropped == 0); // held, not spam-and-dropped + + rig.cap.bufferedDrains = 0; // the link recovers + while (!rig.cap.bufferedSendIdle()) {} + const int before = rig.cap.bufferedFrames; + for (int i = 0; i < 3; i++) { t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); } + CHECK(rig.cap.bufferedFrames > before); // and the stream simply resumes + + mm::platform::setTestNowMs(0); +}