Skip to content

Let the simulator serve the clock's pages - #72

Merged
theAndreas merged 3 commits into
masterfrom
simulator-serves-the-pages
Aug 24, 2026
Merged

Let the simulator serve the clock's pages#72
theAndreas merged 3 commits into
masterfrom
simulator-serves-the-pages

Conversation

@theAndreas

@theAndreas theAndreas commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Stage D. Looking at a page no longer needs a platform./build/bin/Wordclock opens http://localhost:8080/ beside its window, and the node harness that used to do that job is gone.

Two commits: the simulator's server, then the removal it made possible.

Why the old harness could go

serve.js and webhost.cpp put a browser in front of the pages by running node for the HTTP and the web socket, with a host build of the ESP32 backend behind a line protocol on stdio. That arrangement never exercised this backend's server: node owned every socket, /update was node's own fake, and the only handlers a browser reached were the two the line protocol forwarded. What it proved was about the pages and the firmware core — exactly what the simulator now does, with one binary, a real HTTP server and a real web socket.

Gone: 371 lines across the two files, the serve branch and the webhost link in run.sh, and node from the host-test job's requirements.

The design decision worth reviewing

Polled from the wxTimer, not run in a thread. Both hardware backends give their server its own task and pay for it with a lock-free buffer to the firmware. SerialShim buffers in a wxString, so mirroring that here would have meant retrofitting mutexes into the simulator's port. Instead WebHost::task() runs on the tick: a command is injected on the thread that reads it, a frame goes out on the thread that gathered it. No second thread, so nothing here has to be made thread-safe.

The cost is stated rather than hidden: a write blocks the tick. Everything written goes to loopback, where the largest of them — a 70 KB page — fits in the socket buffer many times over. A client that stopped reading entirely could stall the window; a development tool may pay that, a clock on a wall may not.

Also deliberate: loopback only, port 8080 (this serves a console with no password; 80 would need root, and a taken port is not fatal), and a SHA-1 written out because RFC 6455 makes the handshake the base64 of SHA-1(key + GUID) — a checksum a browser demands, not a secret, and a dependency for eighty lines of arithmetic would be a poor trade in a build that otherwise needs only wxWidgets.

/update, honestly

The one thing the harness could do and the simulator could not. It answers now: nothing is installed — a desktop has no second partition — and it reports how much arrived, refusing anything too small to be an image. The same fake node was, standing in for the same thing: the panel's progress and its two outcomes.

Both driven against the binary: a 100 kB body → 200 and {"ok":true}; a 1 kB body → 400 and {"ok":false,"error":"1000 bytes is too small to be a firmware image"}.

Verified by driving it

  • Panel from build/bin/Wordclock: plate 350×256, 17 of 111 cells lit, "ES IST ZEHN NACH EINS", clock 13:12, header "connected", brightness 255 read back over the socket (not the 128 default) — so handshake, framing and answer path all work. No page errors.
  • Every route answers with the right type and length; /nope → 404.
  • All four targets build warning-free; both host suites and the simulator's own tests pass; AVR-Dx byte-identical at 48078/1700; documented sizes check out.

What the simulator was missing

SerialShim gained inject() and a line sink, the backend provides WordclockSerial.h, and CMake gained a rule to generate WebPage.h (until now only PlatformIO and the test harnesses produced it).

Fixed in passing: finishOutputLine() cleared its buffer only when a window had attached, so a run without one grew a single line forever. Harmless while nothing read that line — not once a socket does.

Documentation

Six files. Two claims had gone stale and are corrected rather than relocated: web/README.md counted the backends that serve the pages as two, and CLAUDE.md still called the pages "the one part of this project the simulator cannot show".

Added to CLAUDE.md: the first browser load after a server starts can miss even a 20 s wait on the plate while every load after it is instant — retry once before reading anything into it. That cost me a run before I recognised it, and the same paragraph already says as much about the MIT-SHM error.

🤖 Generated with Claude Code

AndreasBurnickl and others added 3 commits August 24, 2026 13:15
Looking at the panel needed a board or a host process built out of the ESP32
backend - node in front of a binary whose httpd_* functions were fakes - and
that was never a property of the pages. It was a consequence of the web front
end having been written inside a backend. Now that WebFrontend is in the
firmware, the simulator can answer the same routes, and ./build/bin/Wordclock
opens http://localhost:8080/ beside its window.

The transport underneath it is written out rather than pulled in: a desktop
build has no framework to ask for a server, and what a browser needs is a
socket, a little HTTP and RFC 6455's handshake - which is why there is a SHA-1
and a base64 in here, for a checksum a browser demands and nothing else.

Polled from the wxTimer rather than run in a thread, and that is the design
decision worth recording. Both hardware backends give their server its own
task and pay for it with a lock-free buffer between the two; SerialShim
buffers in a wxString, which would not survive that. Running task() on the
tick means a browser's command is injected on the thread that reads it and a
frame goes out on the thread that gathered it - no second thread, so nothing
here has to be made thread-safe. It costs a write blocking the tick, which on
loopback returns at once for everything this sends.

Loopback only, and port 8080: this serves a console with no password on it, a
desktop is not the place to open that to a network, and 80 would need root. A
port already taken is not fatal - it says so and the window runs on, the same
shape the ESP32's server failure has.

No /update, because a host process has no second partition and no filesystem
an image belongs in. That one route is still only reachable through
platform/esp32/test/run.sh serve, which is why that harness stays for now.

Two things the simulator was missing came with it: SerialShim gained inject()
and a line sink, and the backend now provides WordclockSerial.h - the name the
firmware reaches the port by. Fixed on the way past: finishOutputLine() only
cleared its buffer when a window had attached, so a run without one would have
grown a single line forever. Harmless while nothing else read that line, and
not once a socket does.

Verified by driving it: the panel renders from build/bin/Wordclock with 17 of
111 cells lit, "ES IST ZEHN NACH EINS", the clock at 13:12, the header on
"connected" and brightness 255 read back over the socket rather than the
unfilled default - so the handshake, the framing and the answer path all work.
Every route answers, /nope gives a 404. The simulator's own tests pass, both
host suites pass, and the AVR-Dx image is byte-identical at 48078/1700.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
serve.js and webhost.cpp put a browser in front of the pages by running node
for the HTTP and the web socket, with a host build of the ESP32 backend behind
a line protocol on stdio. What that arrangement never did was exercise this
backend's server: node owned every socket, /update was node's own fake, and
the handlers a browser reached were the two the line protocol forwarded. So
what it proved was about the pages and the firmware core - which is exactly
what the simulator now does, with one binary, a real HTTP server and a real
web socket.

Gone with it: 371 lines across the two files, the serve branch and the webhost
link in run.sh, and node from the host-test job's requirements.

The one thing the harness could do and the simulator could not was answer
/update, so the simulator answers it first: nothing is installed, because a
desktop has no second partition, and it says how much arrived and refuses
anything too small to be an image. That is the same fake node was, and it
stands in for the same thing - the panel's progress and its two outcomes,
which is the part of that card nothing else reaches without a board.

Both answers were driven against build/bin/Wordclock: a 100 kB body gives
200 and {"ok":true}, a 1 kB body gives 400 and the sentence the panel shows.

Documentation followed to six files. Two claims had gone stale and are
corrected rather than moved: web/README.md counted the backends that serve the
pages as two, and CLAUDE.md still called them the one part of the project the
simulator cannot show. Added there instead: the first browser load after a
server starts can miss even a 20 s wait on the plate and every load after it be
instant - retry once before reading anything into it, which cost me a run
before I recognised it.

All four targets build warning-free, both host suites pass, the simulator's own
tests pass, and the AVR-Dx image is byte-identical at 48078/1700.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three findings and one build step, all from the same cause: WebFrontend moved
into firmware/, which is where the analyser looks. The two expressions it
flagged had been in platform/esp32/include, outside the filter, so the move
did not introduce them - it exposed them.

- FrameSize multiplied PIXELS_NUMBER_OF_LEDS by the colour count in int and
  widened the result to size_t. Neither operand can overflow an int here, but
  the analyser cannot know a display's size and is right to ask rather than to
  work it out. Now multiplied in size_t.
- ChunkWriter::putNumber dropped snprintf's return. Taken and then used: it is
  the length, so the digits go out without a second walk for their terminator,
  and a negative - snprintf's encoding failure, unreachable for "%u" - is
  refused rather than trusted. The catalog is byte for byte what it was, 6430
  bytes over 16 commands.
- The simulator's smallest-plausible-image constant multiplied in unsigned int.

The build step is the interesting one. The clang-tidy job configures without
building, deliberately, since it only needs compile_commands.json - but
WebPage.h is generated rather than checked in, so the one translation unit that
includes it does not compile, and clang-tidy reports that as the file being
missing. That reads like a bad include path rather than a build step that was
skipped. The job now builds that one target, which is the python script and
nothing else.

Run locally the way CI runs it this time, which is what I should have done
before pushing: run-clang-tidy over the same filter is silent, and so is
cppcheck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@theAndreas
theAndreas merged commit 9ed7aac into master Aug 24, 2026
8 checks passed
@AndreasBur
AndreasBur deleted the simulator-serves-the-pages branch August 25, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants