Commit Graph
6 Commits
Author SHA1 Message Date
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
Oleksandr ShyshatskyiandClaude Opus 5 4850be432c Docs: say what is there now, not what the last three commits removed
Deleting test/ and anvil.conf left claims behind in nine files. Every one of
these was present tense about something that no longer exists.

THE CHELPER SYMBOL GATE, in three places that each described it as live:

  notes/20-klipper-fork.md   said pkgs/klipper gates the .so on "both the ELF
                             flags and test-chelper.py's symbol check". It
                             gates on neither now: the ABI is asked once of the
                             installed filesystem in qa/replica/test_abi.py,
                             and the single-source rule -- the .so is compiled
                             from the chelper sources of the tree it ships -- is
                             the whole defence. The page says that instead.
  notes/85-packaging.md      described the KLIPPER_FORK fallback the script no
                             longer had; the script itself is gone too.
  qa-migration.md            "What survives in test/" listed both the 28 unit
                             tests and test-chelper.py as surviving. It is
                             "What survived in test/, and then did not", and it
                             names what went uncovered rather than leaving that
                             to be discovered.

ANVIL.CONF, in the two notes that still read it at runtime:

  notes/80-s6-migration.md   is a dated plan, so it keeps its phases and gains
                             a "Where this ended up" note: anvil.conf and every
                             MOD_* switch were removed, so the phases that
                             source it describe an intermediate state, and the
                             case-*.sh gates they name are qa/replica/ modules.
  notes/85-packaging.md      predicted seed/ emptying via a postinst that seeds
                             anvil.conf.default. It emptied by anvil.conf being
                             deleted instead. Its payload allowlist said six
                             entries including anvil.conf and bin/busybox; the
                             real one in test_ipk.py has five and neither.

THE TWO STANDALONE TREES under tools/, stale before any of this and now fixed:
tools/python/README.md and tools/supervisor/README.md both told the reader to
run their gate with `test/integration/printer-exec.py` over a case-*.sh, and
one offered a `make test-python` target. None of the three has existed for
several commits. Both now point at qa/replica/, which installs the real package
and asks more than hand-feeding a tarball to a case script could. The four
places tools/python/README.md narrates what case-python.sh proves are past
tense, since that is now a record of how the measurements were taken.
versions.env's sqlite3 note pointed at the same dead target.

NAV: every notes/ page was listed except 85-packaging.md, and qa-migration.md
was reachable only by following a link out of Testing, Building or How it
works. Both are in the nav now, which also clears the two "not included in the
nav" lines from every build. .venv/ is gitignored -- docs/requirements.txt
tells you to make one right there in the tree.

Verified: `mkdocs build --strict` is clean and no longer reports pages outside
the nav. qa/static is 123 passed, its 110 errors the same missing
vendor/opkg-utils as before. No .md file is read by any lane, so the check that
matters here is the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bLGYpVAeVzAX6NeAxcU42
2026-08-30 01:16:54 +03:00
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
Oleksandr ShyshatskyiandClaude Opus 5 6e97f946b0 execline was 2.1MB unstripped against musl; it is 1008KB and mandatory
Eight recipes: skalibs, execline, s6, s6-rc, mainsail, moonraker,
helixscreen, anvil-core. bin/patch.sh loses 183 lines and stops being a
build system for any of them.

The supervision stack moves to the Ingenic glibc toolchain and links libc
dynamically, so the musl toolchain -- the only second libc in this tree --
is deleted along with MUSL_TOOLCHAIN_*, S6_STAMP and S6_BUILD. The
measurement that chose musl compared two STATIC builds; dynamic glibc was
never in it. Measured now: s6's shipped tree is 696K against ~930K for the
static musl one, and the whole stack including execline and s6-rc is 2.0M.

Three things that were not guesses:

  * s6 and s6-rc do not link against glibc 2.29 without -lpthread. skalibs'
    pthread_mutex_tailock reaches pthread_mutex_timedlock, which glibc kept
    in libpthread until 2.34. Upstream never sees it and musl never had the
    split. PKG_MAKE_ARGS is the lever, because s6's PTHREAD_LIB is wired
    only to --enable-nsss and its Makefile appends LDLIBS last.
  * s6 ships 21 binaries, not 13. s6-rc's generated scripts exec s6-sudo,
    s6-sudoc, the s6-ipcserver chain, s6-sudod and s6-fdholder-daemon --
    found by running a real up/down cycle with a restricted PATH, not by
    reading the documentation.
  * --enable-static-libc has never been a skalibs option. Its configure
    warns on unknown flags and patch.sh sent that to /dev/null, so it was a
    no-op for as long as section 5b existed.

execline ships whole. A subset picked from today's services fails the day
somebody adds a pipeline, at boot, naming a program nobody chose to leave
out. tools/supervisor/README.md and 80-s6-migration.md said it was not
needed; both now say why that stopped being true.

PY_STAMP was spelled in three places and happened to agree -- the same
shape as the s6 stamp bug, caught before it inverted. It is in common.sh
now, and the test that guarded S6_STAMP by name is rewritten around the
rule so it covers both and dies with neither.

HelixScreen's binaries reach mips_abi_gate for the first time: patch.sh
never gated them. They pass, 3 objects, nan2008/o32/mips32r2.

anvil-core carries a content hash in PKG_STAMP_EXTRA because its version is
a release date: pinned, an edit to payload/ would read as current; unpinned,
it rebuilds daily. The version says which release, the stamp says what is
in it. anvil.conf stays out -- it is user state that run-append.sh
preserves, and a package member is overwritten on every upgrade.

Twelve packages, byte-identical across two cold builds. 152 static tests.
The replica suite has NOT been run: docker is unavailable here, and the s6
change ships without its behavioural gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:41:46 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 491d0dfe1d The payload carries s6, built here from a pinned hash
Nothing starts it yet. This step only puts the supervisor in the payload, so
that the boot change in the next phase is a boot change and not a build
change at the same time.

skalibs and s6 2.15.1.0 are pinned in versions.env beside a musl mipsel
toolchain, and bin/patch.sh cross-compiles them the way it already
cross-compiles c_helper.so: unpack a pinned toolchain into work/, build,
check the result, stage it. Nothing binary is vendored in this repo, so
anything the printer executes is built from a hash we chose.

Three properties of the build are load-bearing, and all three were measured
on the replica rather than reasoned about:

  * --prefix=/usr/data/anvil, because s6 bakes its libexecdir in at compile
    time. Every waiting verb execs s6-svlisten, which spawns s6-ftrigrd from
    that baked-in path. Built for any other prefix, s6-svstat and respawn
    keep working and only the waiting verbs fail -- it fails late and
    partially, which is the worst way for a supervisor to fail. Moving MODDIR
    now means rebuilding s6.
  * -D_FILE_OFFSET_BITS=64, because without it readdir() returns EOVERFLOW on
    this box: a 32-bit build meeting 64-bit inodes. s6-svscan starts cleanly
    and then cannot see its own scandir.
  * musl, statically. Static glibc produced a 73MB tree here. musl produces
    3.6MB, of which we ship 928K -- the 13 supervision binaries and
    libexec/s6-ftrigrd, nothing else.

--disable-execline had to be added: s6 links against execline by default and
the build stops without its headers. Turning it off is what makes "we ship no
execline" true rather than aspirational; our run scripts are plain /bin/sh.

The build is cached on the two version strings, unlike c_helper.so which
rebuilds on mtime. That difference is deliberate: chelper's sources are a
checkout someone may be editing, while these are tarballs that cannot change
without their sha256 changing. Staging into the payload is never cached, so a
stale payload is not a thing that can happen. The ELF check runs outside the
cache branch, on the tree that will actually ship: a cross-build that quietly
produced host, big-endian or 64-bit objects looks like a clean build here and
like ENOEXEC on a printer, with nothing to explain why.

libexec/ joins bin/ as a payload directory. Its name is part of the ABI
rather than a preference, for the compiled-in-prefix reason above. The
install manifest from the previous commit picked it up with no change, which
was verified rather than assumed.

case-supervisor.sh now runs against the tree the build produced instead of a
hand-made tarball, and execs all 13 binaries rather than one. Its runit half
is gone: the comparison is over and recorded in tools/supervisor/README.md,
nothing in the build produces runit any more, and a gate that still wanted it
could only ever be fed by hand. bin/verify.sh checks for s6-svscan and for
libexec/s6-ftrigrd separately, because a payload missing only the latter
supervises perfectly and fails every waiting verb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:10:13 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 d6c07faa9b Measure s6 and runit on the printer before choosing either
anvil-service.sh hand-rolls what a supervisor does: liveness, a stop that
waits, a respawn loop. The question was whether to keep hand-rolling it. This
answers it with measurements from the replica rather than from project pages,
and changes nothing that ships.

There is nothing on the printer to reuse. busybox 1.31.1 is PID 1 and no
supervisor exists anywhere on the rootfs. busybox init does implement respawn,
but only from /etc/inittab, and /etc is inside the read-only squashfs that a
stock flash overwrites -- the one supervisor already on the box cannot be
reached. So both candidates had to be cross-compiled and run.

Two findings cost most of the time, and neither would have surfaced from a
build test:

  * Without -D_FILE_OFFSET_BITS=64 both supervisors die identically the moment
    they scan their own service directory -- readdir() returns EOVERFLOW,
    "Value too large for defined data type", a 32-bit build meeting 64-bit
    inodes. They start, they load, and then they cannot see their services.
  * s6 resolves its helper programs through a prefix baked in at compile time.
    s6-svc -w and s6-svwait exec s6-svlisten, which spawns s6-ftrigrd out of
    libexecdir. Installed anywhere other than the configured prefix, status
    and respawn keep working and every waiting verb fails -- late, and only
    partially. runit's tools find each other on PATH and do not care.

That second one is why build.sh configures --prefix=/usr/data/anvil. The mod's
install root has to behave like a real prefix root -- bin/, lib/, libexec/,
etc/, share/ -- which is also what would let a newer Python and Moonraker
install into the same tree instead of being special-cased.

Both stacks supervise correctly on the printer's own kernel: status answers, a
kill -9 is respawned, and a stop waits for the process to actually be gone.
Either would delete the two bugs anvil-service.sh fixes by hand. The one real
difference is readiness: an s6 service can declare itself ready over a
notification-fd and s6-svwait -U blocks until it does, measured at 5s against a
service that waits 5s. runit's sv start returns when the process is forked,
which says nothing about whether it works. Our boot order is filenames and our
waiting is hand-rolled -- S65camera polls /dev/video0 for 30s, S70klipper
retries an MCU handshake -- so readiness is the part worth having.

Size, built against musl rather than static glibc, which took s6 from 73MB to
3.6MB: the s6 supervision subset is 13 binaries at 813KB plus s6-ftrigrd at
116KB; runit is 5 binaries at 335KB. execline is not needed -- s6 run scripts
can be plain sh.

test/integration/printer/case-supervisor.sh is the gate proving all of the
above on the replica. tools/supervisor/README.md records what neither
supervisor replaces: MOD_* gating, FF_PYTHON, S70klipper's force-start handoff,
and the fact that firmwareExe runs S* in the foreground in filename order --
ordering that adopting s6 would have to re-express as readiness waits. That,
not the supervisor, is the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:22:12 +03:00