Files
firmware/tools/supervisor
Oleksandr ShyshatskyiandClaude Opus 5 5b463b889a Five switches that were only ever set to 1
MOD_WEB, MOD_CAM, MOD_UI, MOD_SSH and MOD_WIFI all defaulted to on, and what
each one really offered was a half-installed printer: Mainsail with no
moonraker behind it, a camera that would not stream, a screen dark on purpose,
a root password deliberately not set on a machine whose entire recovery story
is "ssh in". Nobody wants those states. What someone who sets one actually
wants is a stock printer, and that is one FlashForge package away.

What they cost was a second answer to every question. Each init script carried
a gate, each `down` file argued its existence from a flag rather than from the
ordering that was the real reason, and anvil.conf had five lines that told a
user they had a choice.

MOD_SSH was the clearest case: it never turned ssh off. dropbear is the
rootfs's own and listens either way, so all the flag did was decline to set a
password.

Each gate is replaced by the fact it was standing in front of, not by a
constant:

  S60nginx, S62moonraker  nothing -- the web stack is what this mod is
  S65camera               there is no video device, which the script already
                          finds out and reports
  S50wifi                 there is no $WIFI_CONF, likewise
  S80ui                   HelixScreen is not installed, which was already the
                          other half of the condition

To leave a piece out, leave it out: BUILD_MAINSAIL, BUILD_MOONRAKER and
BUILD_HELIX decide what goes into a build, and `opkg remove` decides what stays
on a printer. What survives in anvil.conf is what has a range rather than a
state -- MOD_CAM_RES/FPS/PORT and the NICE_* priorities -- plus MOD_SPLASH,
MOD_STARTUP, MOD_IMPORT and MOD_S6, which are untouched here.

The `down` files stay. They were argued from MOD_WEB and MOD_CAM, but the
argument never depended on them: s6-svscan starts a service the instant the
directory appears, which is before any init script has checked a prerequisite.
Their text now says so.

Measured: `make build` produces a payload with no live read of any of the five,
and qa-static is 265 passed with shellcheck over the edited init scripts. The
test suite still exercises the removed flags and has not been updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gKRUCqj18TVF1Vz1oMpmC
2026-08-29 15:24:36 +03:00
..

Off-the-shelf process supervision for the Creator 5 Pro

An investigation, not yet an adoption. It answers one question: instead of payload/anvil-service.sh hand-rolling liveness checks, bounded stop waits and respawn loops in ash, can we ship a supervisor that already solves those?

Everything below was measured on the printer replica (real rootfs.squashfs under qemu-mipsel), not read off a project page. build.sh rebuilds both stacks; test/integration/printer/case-supervisor.sh is the gate that proves they work on the printer's own kernel.

There is nothing on the printer to reuse

busybox 1.31.1 is PID 1. No systemd, runit, s6, supervisord, monit, procd or daemontools exists anywhere on the rootfs. busybox init does implement respawn, but only from /etc/inittab, and /etc is inside the read-only squashfs a stock flash overwrites -- so the one supervisor already on the box is unreachable to us. Anything we want, we cross-compile ourselves.

Both stacks build and run

The Klipper c_helper.so step already unpacks an Ingenic MIPS toolchain, but static glibc is the wrong libc here: s6 came to 73MB. Built against musl (musl.cc mipsel cross toolchain, -Os, static-PIE, stripped) the same tree is 3.6MB, and the supervision subset we would actually ship is:

binaries size
s6 (subset: svscan, supervise, svc, svstat, svwait, svok, svlisten, ftrig-listen1, mkfifodir, cleanfifodir, notifyoncheck, svscanctl) 13 813KB
s6 libexec/s6-ftrigrd 1 116KB
runit (runsv, runsvdir, sv, svlogd, chpst) 5 335KB

execline was measured here at 53 binaries / 2.1MB and judged unnecessary -- s6 run

scripts can be plain #!/bin/sh.

Two findings that cost real time

Large-file support is mandatory. Built without it, both supervisors die identically the moment they scan their own service directory:

s6-svscan: fatal: unable to readdir .: Value too large for defined data type
runsvdir: warning: unable to read directory ...: unknown error

That is readdir() returning EOVERFLOW -- a 32-bit build meeting 64-bit inodes. -D_FILE_OFFSET_BITS=64 fixes both. Nothing about it is s6- or runit-specific, and it would not have shown up in a build test, only in a run.

s6 bakes its prefix in at compile time. Its waiting verbs (s6-svc -w, s6-svwait) exec s6-svlisten, which spawns s6-ftrigrd from the libexecdir chosen by ./configure --prefix. Ship s6 anywhere other than the prefix it was built for and every waiting verb fails with

s6-svlisten: fatal: unable to ftrigr_startf: No such file or directory

while status and respawn keep working -- so it fails late and partially. runit has no such constraint; its tools find each other on PATH. This is why build.sh configures --prefix=/usr/data/anvil: the mod's install root has to be a real prefix root (bin/, lib/, libexec/, etc/, share/), which is also what lets us drop a newer Python and Moonraker into the same tree later.

What each one buys us

Both supervise correctly on the replica: status answers, a kill -9 is respawned, and -- the thing svc_stop_daemon hand-rolls -- a stop that does not return until the process is really gone (sv -w 20 stop, s6-svc -wD -d). Either would delete the two bugs fixed by hand in anvil-service.sh: busybox start-stop-daemon -K returning early, and klippy's stale /tmp/uds.

The difference is readiness. s6 services can declare themselves ready over a notification-fd, and s6-svwait -U blocks until they do -- measured at 5s against a service that waits 5s before declaring itself. runit has no such concept: sv start returns when the process has been forked, which says nothing about whether it is usable.

That matters here because our boot order is currently expressed as filenames (S60nginx before S62moonraker before S65camera before S70klipper) and the waiting is hand-rolled: S65camera polls /dev/video0 for up to 30s, S70klipper retries an MCU handshake. Readiness notification is the piece that would let those waits be declared instead of coded.

What neither one replaces

anvil-service.sh is not only supervision. The prerequisite checks each init script makes before it brings its service up, anvil-env.sh's library path and FF_PYTHON, S70klipper's force-start handoff to bin/ff-startup.py, and the MCU-retry that counts attempts and gives up all remain ours. A supervisor restarts blindly; it cannot tell "MCU handshake failed" from "clean exit".

And firmwareExe runs $MODDIR/init.d/S* one at a time in the foreground, in filename order. Handing everything to one s6-svscan discards that ordering, which is load-bearing. Replacing it with readiness waits is the actual work in adopting s6 -- not the supervisor itself.

Reproducing

docker build -f tools/supervisor/Dockerfile -t svcbuild-musl tools/supervisor
docker run --rm -v "$PWD/tools/supervisor:/build/in" -v "$PWD/work/sup:/out" \
    svcbuild-musl sh /build/in/build.sh

then pack bin/, libexec/ and runit/ into a tarball and run the gate.

That is how the COMPARISON above was measured, and it is kept here for the record. It is no longer how s6 is built: the answer is s6, so bin/patch.sh now cross-compiles skalibs and s6 from the tarballs pinned in versions.env, inside the repo's own build image, caches the result in work/.s6 and stages it into the payload. Nothing builds runit or execline any more, and case-supervisor.sh accordingly expects only bin/ and libexec/ -- the two directories that cache holds:

tar -czf work/sup.tgz -C work/.s6 bin libexec
PRINTER_IMAGE=monstrofil/creator5-printer:latest \
    ./test/integration/printer-exec.py \
    test/integration/printer/case-supervisor.sh sup.tgz=work/sup.tgz