Files
Oleksandr ShyshatskyiandClaude Opus 5 b98552386b No FlashForge component ships; patch.sh is payload.sh
The software component existed to give the mod one exec hook. It is not needed
for that any more, so a release now carries our installer and our payload and
nothing else: 26MB smaller, 21MB of which was FlashForge's own firmwareExe
travelling back to the partition it was already on, along with their klippy
tree, their wifi module and their configs.

WHAT IT GIVES BACK. /usr/prog/PROGRAM/software is no longer rewritten, so the
version directory there stays FlashForge's with their genuine firmwareExe
inside it -- and app_startup.sh's five-second watchdog, which copies one out of
that directory when nothing by that name is running, has something to copy
again. A printer whose payload fails to install now comes up on the stock UI
instead of a blank screen. That recovery has been broken since we started
deleting their binary out of a component we shipped; docs/how-it-works.md
claimed it worked throughout.

THE ROOT PASSWORD moves with it, because it used to ride in the component's
shadow file. bin/pack.sh bakes ROOT_PW_HASH into the installer, single-quoted
-- an unquoted $6$... would have the printer's shell expand $6 as a positional
parameter and ship a truncated hash -- and the installer writes it to
/usr/prog/etc/shadow. The random-password path asks one question instead of
four: FlashForge ship the same root hash on every machine, so a live shadow
still carrying it means nobody has set a password and it is ours to set. The
.prev-root-hash recording, the shipped-vs-live comparison and the restore-it-
afterwards step were all machinery for surviving a file that nothing overwrites
any more, and are gone. pack.sh checks the hardcoded constant against the stock
package it built from, because getting it wrong is silent and total: every
printer would read as "already set" and none would ever get a password.

bin/patch.sh is bin/payload.sh. It patched nothing any more -- no splice, no
shadow edit, no firmwareExe deletion -- and what is left is the payload: the
feed installed by the printer's own opkg, the seeded config, the manifest. Its
sections are renumbered 1-5 with no gaps, and the five tombstone comments
marking where removed sections used to be are gone with them.

test_recipe_layout.py loses six of its nine tests. Each compared the tree
against a description of the tree -- no fourth directory kind, each kind still
used, 3rdparty carries no files of ours, installer/ holds exactly two named
files -- and none could go red for a reason a printer would notice, while all
six went red the moment the layout legitimately changed. The two that bite are
kept: a payload/ path escaping $MODDIR, and a recipe name at both levels, which
makes pkg_dir silently build the wrong package.

SW_VER is deleted from config.env.example and both workflows: it stamped a
component that is not built.

Gates: 112 static tests pass. The generated installer is verified after
substitution -- all four lines present, and `sh -n` over the result, because a
hash with a stray character in it would otherwise reach a printer as a syntax
error whose only symptom is a package that installs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH7Yb4wuPhnppHAB25UX7e
2026-08-30 15:38:25 +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; qa/replica/test_supervisor.py, with test_s6rc.py and test_web.py beside it, is what 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/payload.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, so only bin/ and libexec/ come out of that cache.

case-supervisor.sh and the printer-exec.py that ran it are gone. What asks these questions now is the replica lane, over the real installed package:

make build && make qa-replica