From 6a750f6bccd50f0d0247aa02efcdbfac8431d345 Mon Sep 17 00:00:00 2001 From: Oleksandr Shyshatskyi Date: Thu, 27 Aug 2026 10:41:24 +0300 Subject: [PATCH] Moonraker serves on the 3.13 we built, with nothing from /usr/prog The previous commit proved every component IMPORTS. Importing is not serving, so this runs it. case-moonraker313.sh starts Moonraker on the exact command line payload/etc/s6/moonraker/run execs -- only with our interpreter -- against the mod's own assets/moonraker.conf, and asks the printer what happened: * bound :7125 in 3s, with /proc/PID/cmdline confirming our interpreter and our entry point rather than a coincidence * GET /server/info answers 200 with 23 components loaded and none failed. /machine/system_info, a different component reading this box's own /proc, reports cpu: mips and python 3.13.7 [GCC 7.2.0] * the database really works, which is the entire reason for the exercise: a POST wrote a namespace, a GET read it back, the namespace appears beside Moonraker's own, and data.mdb is 147456 bytes on disk -- through lmdb's CPython extension, not the compile-at-import cffi fallback that would try to invoke a compiler this printer does not have * still alive and still answering 120 seconds later, with no tracebacks in its log, and SIGTERM released the port in 2s * the RUNNING process maps zero libraries under /usr/prog, with LD_LIBRARY_PATH completely unset -- not merely purged of /usr/prog * negative controls: nothing on :7125 beforehand, and with site-packages moved aside it never binds at all libsodium was the last /usr/prog string in the picture and is now ours. libsodium 1.0.20, cross-built with the Ingenic toolchain in 24 seconds, gated at 0x70001407 nan2008/o32/mips32r2, needing only libpthread, libc and the loader, 406KB stripped into $MODDIR/lib. It computes rather than merely loading: sodium_version_string is 1.0.20, its BLAKE2b-256 matches CPython's own _blake2 on the same kernel, and libnacl.sign.Signer/Verifier -- the exact ed25519 pair authorization.py imports -- signs, verifies, and rejects a tampered message. It costs anvil-env.sh nothing, which was not obvious and had to be measured. libnacl's third fallback builds a path out of its own __file__, so from $MODDIR/lib/python3.13/site-packages/libnacl it resolves $MODDIR/lib/libsodium.so by absolute path -- working with LD_LIBRARY_PATH unset entirely. Recorded in the README, because it works only because the prefix happens to contain the string "lib": a prefix without it would break this silently. klippy was scanned rather than guessed at. Its module-scope third-party imports are cffi, greenlet, jinja2, pyserial and numpy; four of the five are built and proven, and cffi already dlopens the printer's real c_helper.so. numpy is at module scope only in extras/stepper_resonance_tester.py, which Klipper loads on demand, and it is a multi-day problem that costs input-shaper calibration rather than printing. There is no removed-stdlib usage anywhere in klippy -- no imp, distutils, asyncore, cgi or getargspec. What is left is packaging, not capability, and the README says so in order. The sharp one: section 5c's trim deletes include/ and config-3.13-*, which the package build needs, so the package build has to run inside 5c against the untrimmed stage. And the ABI gate walks the build cache rather than the payload, so anything staged straight into $MOD_PAYLOAD would bypass it -- coverage, not logic. Co-Authored-By: Claude Opus 5 (1M context) --- test/integration/printer/case-moonraker313.sh | 487 ++++++++++++++++++ tools/python-packages/README.md | 90 +++- tools/python-packages/build-libsodium.sh | 147 ++++++ 3 files changed, 708 insertions(+), 16 deletions(-) create mode 100755 test/integration/printer/case-moonraker313.sh create mode 100755 tools/python-packages/build-libsodium.sh diff --git a/test/integration/printer/case-moonraker313.sh b/test/integration/printer/case-moonraker313.sh new file mode 100755 index 0000000..092c256 --- /dev/null +++ b/test/integration/printer/case-moonraker313.sh @@ -0,0 +1,487 @@ +#!/bin/sh +# Does Moonraker actually SERVE on the CPython 3.13 this repo cross-builds? +# +# THE DECISIVE GATE for phase 6. case-pyext.sh proved that all 16 components +# IMPORT on 3.13. Importing is not serving: nothing had ever bound :7125 on +# this interpreter. An import exercises module-level code and nothing else -- +# it does not open an lmdb environment, does not construct a tornado +# Application, does not run an asyncio event loop on this kernel, and does not +# touch a socket. Every one of those is a separate way for a cross-built +# interpreter to fail, and every one of them is between here and FF_PYTHON +# pointing at 3.13. +# +# It also settles the last /usr/prog string. libnacl is pure Python and +# ctypes-loads libsodium, which on this firmware exists ONLY at +# /usr/prog/libsodium/lib -- so Moonraker's `authorization` component has kept +# one dependency on FlashForge's tree through the whole exercise. sodium.tgz +# is a libsodium cross-built into the mod's own prefix; section 1 takes +# /usr/prog away and makes libnacl work anyway. +# +# The approach is case-moonraker.sh's, deliberately: stage the tree, start it +# the way the shipped `run` script starts it (`python moonraker.py -d +# /usr/data`), wait for the PORT rather than for the process, and then ask the +# HTTP API. What is NOT reused is the s6/S62moonraker machinery -- that is +# already gated against 3.8 and re-running it here would measure init scripts, +# not the interpreter. This runs the entry point directly, with the same +# arguments and the same data path, so that anything that fails is the +# interpreter, the extensions or Moonraker itself. +FAIL=0 +ok() { echo " PASS $*"; } +bad() { echo " FAIL $*"; FAIL=1; } +note(){ echo " .. $*"; } +skip(){ echo " SKIP $*"; } + +MODDIR=/usr/data/anvil +PY=$MODDIR/bin/python3.13 +SP=$MODDIR/lib/python3.13/site-packages +MRROOT=/usr/data/mrsrc +MAIN=$MRROOT/moonraker/moonraker.py +DATA=/usr/data +PORT=7125 +SODIUM_PROG=/usr/prog/libsodium/lib + +# ---- stage ---------------------------------------------------------------- +mkdir -p $MODDIR +gzip -dc /mnt/py.tgz | tar -x -C $MODDIR || { bad "cannot unpack py.tgz"; exit 1; } +chmod +x $PY 2>/dev/null +mkdir -p $SP +gzip -dc /mnt/ext.tgz | tar -x -C /tmp || { bad "cannot unpack ext.tgz"; exit 1; } +cp -a /tmp/site-packages/. $SP/ || { bad "cannot stage site-packages"; exit 1; } +# libsodium goes into the SAME prefix lib/ the stdlib lives under. That is not +# only tidiness -- see section 1c for why the path matters to libnacl. +gzip -dc /mnt/sodium.tgz | tar -x -C $MODDIR || { bad "cannot unpack sodium.tgz"; exit 1; } +mkdir -p $MRROOT +gzip -dc /mnt/mr.tgz | tar -x -C $MRROOT || { bad "cannot unpack mr.tgz"; exit 1; } +# The mod's own moonraker.conf, from assets/, at the path moonraker -d +# /usr/data resolves to. Not a config invented here: a config the mod ships. +gzip -dc /mnt/conf.tgz | tar -x -C $DATA || { bad "cannot unpack conf.tgz"; exit 1; } +mkdir -p /usr/data/logs /usr/data/tmp + +[ -x "$PY" ] || { bad "no interpreter at $PY"; exit 1; } +[ -f "$MAIN" ] || { bad "no moonraker.py at $MAIN"; exit 1; } +note "interpreter: `$PY -c 'import sys;print(sys.version.split()[0])' 2>&1`" +note "libsodium staged: `ls -l $MODDIR/lib/libsodium.so* 2>/dev/null | wc -l` names" + +echo +echo "=== 1. libsodium: the last /usr/prog dependency ===" + +# ---- 1a. the negative control, FIRST --------------------------------------- +# Everything below is worthless unless libnacl genuinely cannot find a +# libsodium when neither copy is reachable. If it can, some third libsodium is +# on this rootfs and sections 1b/1c are measuring nothing. +mv $MODDIR/lib/libsodium.so /tmp/ls.so 2>/dev/null +mv $MODDIR/lib/libsodium.so.26 /tmp/ls.so.26 2>/dev/null +mv $MODDIR/lib/libsodium.so.26.2.0 /tmp/ls.so.full 2>/dev/null +# The sentinel is printed with a leading newline and matched anchored, because +# CPython 3.13 echoes the failing SOURCE LINE into its traceback -- a `-c` +# program that contains the word it greps for passes its own negative control. +# That is not hypothetical; it is what the first run of this section did. +env -u LD_LIBRARY_PATH $PY -c \ + 'import libnacl; print("\nSENTINEL-loaded", libnacl.nacl._name)' \ + > /tmp/nacl-neg.out 2>&1 +if grep -q '^SENTINEL-loaded' /tmp/nacl-neg.out; then + bad "negative control: libnacl found a libsodium with ours moved aside and no path: `cat /tmp/nacl-neg.out`" +else + ok "negative control: with ours moved aside and /usr/prog off the path, libnacl fails -- `sed -n '$p' /tmp/nacl-neg.out`" +fi +mv /tmp/ls.so.full $MODDIR/lib/libsodium.so.26.2.0 2>/dev/null +mv /tmp/ls.so.26 $MODDIR/lib/libsodium.so.26 2>/dev/null +mv /tmp/ls.so $MODDIR/lib/libsodium.so 2>/dev/null + +# ---- 1b. ours, on LD_LIBRARY_PATH, with /usr/prog NOWHERE ------------------ +LD_LIBRARY_PATH=$MODDIR/lib $PY -c \ + 'import libnacl; print("libnacl ok:", libnacl.nacl._name)' \ + > /tmp/nacl-ours.out 2>&1 +if grep -q '^libnacl ok' /tmp/nacl-ours.out; then + ok "libnacl loads OUR libsodium with \$MODDIR/lib on LD_LIBRARY_PATH: `cat /tmp/nacl-ours.out`" +else + bad "libnacl with \$MODDIR/lib on the path: `tail -3 /tmp/nacl-ours.out`" +fi + +# ---- 1c. and with NO path at all ------------------------------------------- +# libnacl's own search is worth reading rather than assuming. After +# ctypes.util.find_library and a bare dlopen("libsodium.so") it tries +# +# libpath = __file__[0 : __file__.find("lib") + 3] + "/libsodium.so" +# +# and __file__ here is $MODDIR/lib/python3.13/site-packages/libnacl/__init__.py +# -- so the first "lib" in that path IS the prefix's own lib/, and the fallback +# resolves to $MODDIR/lib/libsodium.so by ABSOLUTE path. Which means putting +# libsodium in the prefix may cost anvil-env.sh nothing at all. Measured, not +# assumed, because it is a coincidence of the prefix name and a future prefix +# without "lib" in it would break it silently. +env -u LD_LIBRARY_PATH $PY -c \ + 'import libnacl; print("libnacl ok:", libnacl.nacl._name)' \ + > /tmp/nacl-nopath.out 2>&1 +if grep -q '^libnacl ok' /tmp/nacl-nopath.out; then + ok "libnacl finds it with LD_LIBRARY_PATH UNSET too: `cat /tmp/nacl-nopath.out`" + SODIUM_NEEDS_PATH=0 +else + note "with no LD_LIBRARY_PATH libnacl does not find it: `tail -2 /tmp/nacl-nopath.out`" + note "so \$MODDIR/lib has to go on LD_LIBRARY_PATH -- an anvil-env.sh change" + SODIUM_NEEDS_PATH=1 +fi + +# ---- 1d. it is not merely loaded, it computes ------------------------------ +# A dlopen that succeeds and a crypto library that works are different claims: +# a wrong-ABI .so fails at dlopen, but a miscompiled one loads and returns +# rubbish. So run a real round trip through it and check the ANSWER. +LD_LIBRARY_PATH=$MODDIR/lib $PY - <<'EOP' > /tmp/nacl-use.out 2>&1 +import binascii, ctypes +import libnacl +import libnacl.sign +libnacl.nacl.sodium_version_string.restype = ctypes.c_char_p +print("sodium_version_string(): %s" % libnacl.nacl.sodium_version_string().decode()) +# A KNOWN-ANSWER test, because a dlopen that succeeds and a crypto library +# that is correct are different claims -- a miscompiled one loads and returns +# rubbish. crypto_generichash is BLAKE2b at libsodium's default 32-byte +# output, and the ANSWER is not written down here: it is computed by CPython's +# own _blake2 extension, in this process, on this kernel. Two independent +# mipsel builds of the same algorithm agreeing is a stronger statement than a +# constant pasted from a spec -- and a constant pasted from a spec is how the +# first version of this check failed, against a correct library, because 512 +# is the algorithm's default and 32 bytes is libsodium's. +import hashlib +got = binascii.hexlify(libnacl.crypto_generichash(b"nozzle at 210")).decode() +want = hashlib.blake2b(b"nozzle at 210", digest_size=32).hexdigest() +print("blake2b-256: libsodium=%s" % got) +print(" cpython =%s" % want) +assert got == want, (got, want) +# And then EXACTLY what moonraker/components/authorization.py imports and +# uses: libnacl.sign.Signer / Verifier, the ed25519 pair behind its JWTs. +signer = libnacl.sign.Signer() +sig = signer.signature(b"nozzle at 210") +ver = libnacl.sign.Verifier(signer.hex_vk()) +assert ver.verify(sig + b"nozzle at 210") == b"nozzle at 210" +try: + ver.verify(sig + b"nozzle at 211") + print("FORGERY ACCEPTED") +except ValueError: + print("ed25519 sign/verify ok, and a tampered message is rejected") +print("libsodium works") +EOP +if grep -q '^libsodium works' /tmp/nacl-use.out; then + ok "and it computes -- not merely loads:" + grep -v '^libsodium works' /tmp/nacl-use.out | sed 's/^/ /' +else + bad "libsodium loaded but does not work:" + sed 's/^/ /' /tmp/nacl-use.out | tail -8 +fi + +# ---- 1e. the authorization component, with /usr/prog unreachable ----------- +# The component that caused the original outage, on the new interpreter, with +# the library it needs coming from our prefix. +LD_LIBRARY_PATH=$MODDIR/lib $PY -c " +import sys; sys.path.insert(0, '$MRROOT') +import moonraker.components.authorization as a +print('authorization imported from', a.__file__) +" > /tmp/auth.out 2>&1 +grep -q '^authorization imported' /tmp/auth.out \ + && ok "moonraker's authorization component imports on 3.13 with no /usr/prog libsodium" \ + || bad "authorization: `tail -3 /tmp/auth.out`" + +echo +echo "=== 2. THE DECISIVE ONE: does it SERVE? ===" + +# The environment the server gets: LD_LIBRARY_PATH is UNSET, not merely +# purged of /usr/prog. That is the strongest form of the claim and the one +# worth measuring -- the interpreter is statically linked against its seven C +# libraries, every extension was linked by the same toolchain, and section 1c +# showed libnacl resolving the prefix path by itself. If anything in the +# server needs a search path, this is where it says so. +# TMPDIR is off the ramdisk, exactly as payload/etc/s6/moonraker/run sets it. +MRENV="TMPDIR=/usr/data/tmp" +mrun() { env -u LD_LIBRARY_PATH $MRENV "$@"; } + +port_listening() { + awk -v p=":`printf '%04X' $PORT`" '$2 ~ p"$" && $4 == "0A" { f = 1 } + END { exit !f }' /proc/net/tcp 2>/dev/null +} + +# ---- 2a. negative control: nothing is listening yet ------------------------ +if port_listening; then + bad "negative control: something is ALREADY listening on :$PORT" +else + ok "negative control: nothing is listening on :$PORT before we start" +fi + +# ---- 2b. start it ---------------------------------------------------------- +# Exactly the command line payload/etc/s6/moonraker/run execs, with our +# interpreter substituted for FF_PYTHON and the log named so it cannot be +# confused with a 3.8 run. +LOG=/usr/data/logs/moonraker313.log +rm -f $LOG +echo " .. starting: $PY $MAIN -d $DATA -l $LOG" +# NOT `mrun ... &`. Backgrounding a shell FUNCTION makes busybox sh fork a +# subshell and hand back the SUBSHELL's pid, which still carries the case +# script's own cmdline -- so every check below reads /proc of the wrong +# process, the SIGTERM at the end kills the wrong process, and the server +# survives into the next section. Measured, and it cost a replica run. A +# simple command is exec'd by the background child, so $! is the server. +env -u LD_LIBRARY_PATH $MRENV $PY $MAIN -d $DATA -l $LOG > /tmp/mr-stdout.txt 2>&1 & +MRPID=$! +note "pid $MRPID" +# And prove that, rather than trusting it: a pid whose cmdline is this script +# is the bug above, not a server. +note "pid $MRPID cmdline: `tr '\0' ' ' < /proc/$MRPID/cmdline 2>/dev/null`" + +waited=0 +while [ $waited -lt 240 ]; do + port_listening && break + kill -0 $MRPID 2>/dev/null || break + sleep 3 + waited=$((waited + 3)) +done + +if ! kill -0 $MRPID 2>/dev/null; then + bad "moonraker EXITED before binding :$PORT (after ${waited}s)" + echo " ---- stdout/stderr ----" + sed 's/^/ /' /tmp/mr-stdout.txt | tail -n 40 + echo " ---- $LOG ----" + tail -n 60 $LOG 2>/dev/null | sed 's/^/ /' + echo + echo "RESULT: FAIL (moonraker does not serve on 3.13)" + exit 1 +fi + +if port_listening; then + ok "moonraker BOUND :$PORT after ${waited}s, under $PY" +else + bad "moonraker is still alive after ${waited}s but never bound :$PORT" + tail -n 40 $LOG 2>/dev/null | sed 's/^/ /' +fi + +# ---- 2c. it is our interpreter holding the socket -------------------------- +CMD=`tr '\0' ' ' < /proc/$MRPID/cmdline 2>/dev/null` +case "$CMD" in + *"$PY"*) ok "the process is running under $PY" ;; + *) bad "the process is '$CMD', not our interpreter" ;; +esac +case "$CMD" in + *"$MAIN"*) ok "and it is running $MAIN" ;; + *) bad "it is not running $MAIN: $CMD" ;; +esac + +# ---- 2d. the API answers --------------------------------------------------- +# The HTTP client is our own 3.13, which is convenient and also honest: if the +# client cannot make an HTTP request the interpreter has a bigger problem than +# Moonraker. Every call goes through one helper so a failure names the URL. +cat > /tmp/get.py <<'EOP' +import json, sys, urllib.request +url = sys.argv[1] +data = None +method = "GET" +if len(sys.argv) > 2: + method = "POST" + data = sys.argv[2].encode() +req = urllib.request.Request(url, data=data, method=method, + headers={"Content-Type": "application/json"}) +try: + with urllib.request.urlopen(req, timeout=20) as r: + body = r.read().decode() + print("HTTP %d" % r.status) + print(body) +except Exception as exc: + print("REQUEST FAILED: %r" % (exc,)) + sys.exit(1) +EOP + +get() { mrun $PY /tmp/get.py "http://127.0.0.1:$PORT$1" ${2:+"$2"}; } + +# /server/info -- the endpoint every UI hits first, and the one that names the +# components the server actually LOADED. That list is the point: a component +# that imports but throws in its constructor never reaches it. +get /server/info > /tmp/info.out 2>&1 +if grep -q '^HTTP 200' /tmp/info.out; then + ok "GET /server/info answers 200" + mrun $PY - <<'EOP' > /tmp/info2.out 2>&1 +import json +raw = open("/tmp/info.out").read().split("\n", 1)[1] +d = json.loads(raw)["result"] +comps = sorted(d.get("components", [])) +fail = sorted(d.get("failed_components", [])) +print("klippy_connected=%s klippy_state=%s api_version=%s" % ( + d.get("klippy_connected"), d.get("klippy_state"), d.get("api_version_string"))) +print("moonraker_version=%s" % d.get("moonraker_version")) +print("components(%d): %s" % (len(comps), ", ".join(comps))) +print("failed_components(%d): %s" % (len(fail), ", ".join(map(str, fail)))) +print("warnings: %s" % (d.get("warnings"),)) +EOP + sed 's/^/ /' /tmp/info2.out + NCOMP=`sed -n 's/^components(\([0-9]*\)).*/\1/p' /tmp/info2.out` + [ -n "$NCOMP" ] && [ "$NCOMP" -ge 16 ] \ + && ok "the JSON names $NCOMP loaded components" \ + || bad "server/info reports only '$NCOMP' components" + grep -q '^failed_components(0)' /tmp/info2.out \ + && ok "no failed components" \ + || bad "`grep '^failed_components' /tmp/info2.out`" +else + bad "GET /server/info: `tail -2 /tmp/info.out`" +fi + +# A SECOND endpoint, and deliberately one served by a different component: +# /server/info is the Server object itself, /machine/system_info is the +# `machine` component reading this box's /proc and /sys. Two endpoints from +# two components is the difference between "tornado routes" and "Moonraker +# runs". +get /machine/system_info > /tmp/sys.out 2>&1 +if grep -q '^HTTP 200' /tmp/sys.out; then + ok "GET /machine/system_info answers 200" + mrun $PY - <<'EOP' 2>&1 | sed 's/^/ /' +import json +raw = open("/tmp/sys.out").read().split("\n", 1)[1] +si = json.loads(raw)["result"]["system_info"] +cpu = si.get("cpu_info", {}) +print("cpu: %s x%s %s" % (cpu.get("processor"), cpu.get("cpu_count"), + cpu.get("model", "")[:40])) +print("total_memory: %s %s" % (cpu.get("total_memory"), cpu.get("memory_units"))) +print("python: %s" % (si.get("python", {}).get("version_string"),)) +print("keys: %s" % ", ".join(sorted(si))) +EOP +else + bad "GET /machine/system_info: `tail -2 /tmp/sys.out`" +fi + +# A third, from `webcam` -- the component the mod's moonraker.conf configures +# and the reason the pin is what it is. +get /server/webcams/list > /tmp/cam.out 2>&1 +grep -q '^HTTP 200' /tmp/cam.out \ + && ok "GET /server/webcams/list answers 200 (`sed -n '2p' /tmp/cam.out | cut -c1-90`)" \ + || note "GET /server/webcams/list: `tail -1 /tmp/cam.out`" + +# ---- 2e. THE DATABASE, which is the whole reason for the 3.13 exercise ----- +# This pin keeps its database in lmdb (v0.9.0 is where Moonraker moved to +# sqlite, and MOONRAKER_VERSION is deliberately the last commit before that). +# So what is being proved here is the lmdb C extension driving a real +# environment from inside the running server -- write over HTTP, read back +# over HTTP, and then look at the files on disk. +DBNS=anvil_gate +get /server/database/item "{\"namespace\":\"$DBNS\",\"key\":\"phase6.probe\",\"value\":{\"nozzle\":210,\"ok\":true}}" \ + > /tmp/db-w.out 2>&1 +if grep -q '^HTTP 200' /tmp/db-w.out; then + ok "POST /server/database/item wrote to namespace '$DBNS'" +else + bad "database write: `tail -2 /tmp/db-w.out`" +fi +get "/server/database/item?namespace=$DBNS&key=phase6.probe" > /tmp/db-r.out 2>&1 +if grep -q '^HTTP 200' /tmp/db-r.out && grep -q '210' /tmp/db-r.out; then + ok "GET reads it back: `sed -n '2p' /tmp/db-r.out | cut -c1-100`" +else + bad "database read: `tail -2 /tmp/db-r.out`" +fi +get /server/database/list > /tmp/db-l.out 2>&1 +grep -q "$DBNS" /tmp/db-l.out \ + && ok "and the namespace is listed: `sed -n '2p' /tmp/db-l.out | cut -c1-110`" \ + || bad "namespace not listed: `tail -1 /tmp/db-l.out`" +# On disk. An lmdb environment is data.mdb + lock.mdb, and a data.mdb of +# nonzero size is the claim "there is a database" made without asking the +# process that wrote it. +if [ -f $DATA/database/data.mdb ]; then + ok "lmdb environment on disk: `ls -l $DATA/database/data.mdb | awk '{print $5}'` bytes at $DATA/database/data.mdb" +else + note "no $DATA/database/data.mdb -- looking for one:" + find $DATA -name 'data.mdb' 2>/dev/null | sed 's/^/ /' + bad "no lmdb data.mdb where the database component should have made one" +fi +# And the lmdb the SERVER is using is the CPython extension, not the cffi +# fallback that would try to run a compiler on this printer. +mrun $PY -c \ + 'import lmdb, lmdb.cpython; print("lmdb backend:", lmdb.__file__, lmdb.version())' \ + 2>&1 | sed 's/^/ /' + +# ---- 2f. zero /usr/prog, read off the RUNNING process ---------------------- +# Not off a python that imported some modules -- off the pid that is holding +# :7125, with every component loaded and the database open. This is the claim +# the whole phase is for. +echo " .. /proc/$MRPID/maps:" +awk '{print $NF}' /proc/$MRPID/maps 2>/dev/null | grep '^/' | sort -u > /tmp/maps.txt +sed 's/^/ /' /tmp/maps.txt +NPROG=`grep -c '^/usr/prog' /tmp/maps.txt` +[ "$NPROG" = 0 ] \ + && ok "the RUNNING moonraker maps 0 libraries under /usr/prog" \ + || bad "$NPROG libraries under /usr/prog are mapped by the running moonraker" +if grep -q "^$MODDIR/lib/libsodium" /tmp/maps.txt; then + ok "and libsodium is mapped from the prefix: `grep libsodium /tmp/maps.txt`" +else + bad "our libsodium is NOT mapped by the running server -- authorization did not need it, or found another" +fi + +# ---- 2g. it stays up ------------------------------------------------------- +# A process that binds a port, answers once and exits is not a server. Two +# minutes is not an endurance test and is not claimed as one; it is long +# enough to catch an event loop that dies on its first idle timer, a component +# whose periodic callback throws, and the proc_stats sampler, which runs every +# second and touches /proc on this kernel. +BEFORE=`date +%s` +n=0 +while [ $n -lt 120 ]; do + sleep 10 + n=$((n + 10)) + kill -0 $MRPID 2>/dev/null || break +done +if kill -0 $MRPID 2>/dev/null; then + ok "still alive ${n}s later (pid $MRPID)" + port_listening && ok "and still listening on :$PORT" \ + || bad "alive but no longer listening on :$PORT" + get /server/info > /tmp/info3.out 2>&1 + grep -q '^HTTP 200' /tmp/info3.out \ + && ok "and still answers /server/info after ${n}s" \ + || bad "stopped answering after ${n}s: `tail -1 /tmp/info3.out`" + # proc_stats runs a 1Hz sampler; if it were throwing, the log would say so + # every second. Reading the log for tracebacks is the cheap way to catch a + # component that is failing quietly behind a working endpoint. + NTB=`grep -c 'Traceback' $LOG 2>/dev/null` + [ "$NTB" = 0 ] \ + && ok "no tracebacks in $LOG" \ + || note "$NTB traceback(s) in the log:" + [ "$NTB" = 0 ] || grep -A6 'Traceback' $LOG | head -40 | sed 's/^/ /' +else + bad "moonraker DIED during the ${n}s soak" + tail -n 40 $LOG | sed 's/^/ /' +fi + +# ---- 2h. the shutdown, and the log ---------------------------------------- +echo " .. log head:" +head -n 25 $LOG 2>/dev/null | sed 's/^/ /' +kill -TERM $MRPID 2>/dev/null +w=0 +while [ $w -lt 30 ] && kill -0 $MRPID 2>/dev/null; do sleep 2; w=$((w + 2)); done +kill -0 $MRPID 2>/dev/null && { kill -9 $MRPID 2>/dev/null; note "needed SIGKILL"; } \ + || ok "SIGTERM stopped it in ${w}s" +sleep 3 +port_listening && bad ":$PORT still bound after the server exited" \ + || ok ":$PORT released" + +echo +echo "=== 3. negative control: it is OUR tree that is doing this ===" +# Move site-packages aside and start the same command line again. If it still +# comes up, then FlashForge's 3.8 site-packages are somehow being reached and +# every PASS above belongs to somebody else's build. +mv $SP $SP.away +env -u LD_LIBRARY_PATH $MRENV $PY $MAIN -d $DATA -l /usr/data/logs/mr-neg.log > /tmp/mr-neg.txt 2>&1 & +NEGPID=$! +w=0 +while [ $w -lt 60 ]; do + port_listening && break + kill -0 $NEGPID 2>/dev/null || break + sleep 3; w=$((w + 3)) +done +if port_listening; then + bad "negative control: moonraker bound :$PORT with site-packages moved away" + kill -9 $NEGPID 2>/dev/null +else + ok "negative control: with site-packages moved away it never binds -- `tail -2 /tmp/mr-neg.txt /usr/data/logs/mr-neg.log 2>/dev/null | grep -i 'error\|no module' | head -1`" +fi +kill -9 $NEGPID 2>/dev/null +mv $SP.away $SP + +echo +if [ "$SODIUM_NEEDS_PATH" = 1 ]; then + echo "anvil-env.sh WOULD NEED: $MODDIR/lib on LD_LIBRARY_PATH" +else + echo "anvil-env.sh needs NO new LD_LIBRARY_PATH entry (libnacl resolves the prefix path itself)" +fi +echo "and $SODIUM_PROG can come OFF the list." +echo +[ "$FAIL" = 0 ] && echo "RESULT: PASS" || echo "RESULT: FAIL" +exit $FAIL diff --git a/tools/python-packages/README.md b/tools/python-packages/README.md index 794b715..528de86 100644 --- a/tools/python-packages/README.md +++ b/tools/python-packages/README.md @@ -106,26 +106,84 @@ and all 16 components still import. The phase-6 trap was the lmdb *egg*'s cffi fallback, and installing lmdb as a wheel removes it. Dropping them is a size decision, not a correctness one. -## The one unsolved dependency: libsodium +## Moonraker SERVES on 3.13 -- settled, not assumed -`libnacl` is pure Python and ctypes-loads libsodium, which exists on this box -**only at `/usr/prog/libsodium/lib`**. Measured both ways: it fails with an -empty path and works with that directory on it. So Moonraker's -`authorization` component keeps one `/usr/prog` dependency, and it is the last -one in the picture. Either cross-build libsodium into the prefix, or accept -`anvil-env.sh` keeping that single entry. +Importing is not serving, so it was run. `test/integration/printer/case-moonraker313.sh` +starts Moonraker on the exact command line `payload/etc/s6/moonraker/run` +execs, only with our interpreter, against the mod's own `assets/moonraker.conf`: + +* bound :7125 in 3s, `/proc/PID/cmdline` confirming our interpreter and our + entry point +* `GET /server/info` 200 with **23 components loaded and 0 failed**; + `/machine/system_info` (a different component, reading this box's /proc) + reports `cpu: mips`, `python 3.13.7 [GCC 7.2.0]` +* the **database really works**: POST wrote a namespace, GET read it back, the + namespace appears beside Moonraker's own, and `data.mdb` is 147456 bytes on + disk -- through `lmdb/cpython.cpython-313-mipsel-linux-gnu.so`, not the + compile-at-import cffi fallback +* alive and still answering **120 seconds later**, zero tracebacks in its log, + and SIGTERM released the port in 2s +* **the running process maps 0 libraries under `/usr/prog`**, with + `LD_LIBRARY_PATH` completely unset -- not merely purged of `/usr/prog` +* negative controls: nothing on :7125 beforehand, and with `site-packages` + moved aside it never binds + +What is still unproven is endurance and integration, not capability: no real +hardware, no live klippy attached (`klippy_connected=False` throughout, so the +klippy_apis paths loaded but were never exercised), no upload through +streaming_form_data, no websocket client, and it has not been run through +`S62moonraker` under s6 on 3.13 -- the entry point was driven directly, on +purpose, so that a failure would be the interpreter and not an init script. + +## libsodium: built into the prefix, and it costs anvil-env.sh nothing + +`build-libsodium.sh` cross-builds libsodium 1.0.20 with the Ingenic toolchain +(24s), gated at `0x70001407` nan2008/o32/mips32r2, SONAME `libsodium.so.26`, +needing only libpthread, libc and the loader. 406KB stripped, into +`$MODDIR/lib`. + +It does not need a new `LD_LIBRARY_PATH` entry, which is the pleasant part. +libnacl's third fallback is `__file__[0:__file__.find("lib")+3] + +"/libsodium.so"`, and `__file__` is +`$MODDIR/lib/python3.13/site-packages/libnacl/__init__.py`, so it resolves +`$MODDIR/lib/libsodium.so` **by absolute path** -- measured working with +`LD_LIBRARY_PATH` unset entirely. Worth writing down that this works because +the prefix happens to contain the string `lib`; a prefix without it would +break silently. Adding `$MODDIR/lib` to the path also works and is the +belt-and-braces option, but it would put our libraries in front of everything +for every mod process, which is the kind of entry this file's own history +argues against. + +It computes as well as loads: `sodium_version_string()` is 1.0.20, its +BLAKE2b-256 matches CPython's own `_blake2` on the same kernel, and +`libnacl.sign.Signer/Verifier` -- the exact ed25519 pair `authorization.py` +imports -- signs, verifies and rejects a tampered message. With it, +`/usr/prog/libsodium/lib` can come off `ANVIL_LIBS`, though only as part of the +same one-line `FF_PYTHON` switch, since 3.8's libnacl still needs it. ## What is left before FF_PYTHON can switch -1. **Boot Moonraker on 3.13 and hit the HTTP API.** Every component imports - and the entry point loads, but importing is not serving: nothing has bound - :7125 on 3.13. `case-moonraker.sh` already does this against 3.8; pointing - it at 3.13 is the next gate and the honest remaining unknown. -2. Decide libsodium, above. -3. Wire into `bin/patch.sh`. Note the build needs the **untrimmed** interpreter - stage -- `include/` and `config-3.13-*`, which section 5c's trim currently - deletes -- and the pins want moving into `versions.env` with sha256s. -4. klippy is **not** blocked by packages: cffi, greenlet, jinja2, markupsafe +Capability is settled -- Moonraker serves, the database works, libsodium is +ours. What is left is packaging and integration: + +1. **Wire into `bin/patch.sh`.** The blocker is that section 5c's trim deletes + `include/` and `lib/python3.13/config-3.13-*` and then removes the staging + tree, and the package build needs both. Cleanest fix is to run the package + build inside 5c against the untrimmed stage BEFORE the trim, rather than + caching a second tree. Add a step for libsodium, a step to stage the 19 + packages into `lib/python3.13/site-packages/`, and move every pin into + `versions.env` with sha256s. +2. **Extend the ABI gate's reach.** It walks `$PY_BUILD/bin` and + `$PY_BUILD/lib` -- the build cache, not the payload -- so libsodium and the + twelve extension `.so` files staged straight into `$MOD_PAYLOAD` would + bypass it. No logic change needed, the two-word `DYN -> 0x70001407` rule + already covers them; only coverage. +3. **Run it through `S62moonraker` under s6 on 3.13.** The entry point was + driven directly on purpose, so the s6 readiness and restart machinery is + still gated only against 3.8. +4. Then the switch itself: `FF_PYTHON` in `anvil-env.sh`, and + `/usr/prog/libsodium/lib` off `ANVIL_LIBS` in the same commit. +5. klippy is **not** blocked by packages: cffi, greenlet, jinja2, markupsafe and pyserial all build, and `c_helper.so` binds. `numpy` is the only gap, wanted solely by `extras/stepper_resonance_tester.py`, which Klipper loads on demand. numpy on 3.13 means numpy >= 2.1 and a Meson cross-file, and diff --git a/tools/python-packages/build-libsodium.sh b/tools/python-packages/build-libsodium.sh new file mode 100755 index 0000000..66f07d5 --- /dev/null +++ b/tools/python-packages/build-libsodium.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# --------------------------------------------------------------------------- +# Cross-build libsodium for the Creator 5 Pro (Ingenic mipsel) into the mod's +# prefix root, so that libnacl -- and therefore Moonraker's `authorization` +# component -- stops needing /usr/prog/libsodium/lib. +# +# This is the LAST /usr/prog string in the phase-6 picture. Everything else +# the 3.13 interpreter and its extensions need is either static inside the +# interpreter or built by tools/python-packages/build.sh. +# +# Unlike the interpreter's seven static dependencies, this one MUST be shared: +# libnacl is pure Python and reaches it through ctypes.cdll.LoadLibrary, which +# is dlopen. So it ships as $MODDIR/lib/libsodium.so.26.2.0 with its soname +# symlink and the `libsodium.so` development symlink -- and that last one is +# not decoration, it is the FIRST name libnacl asks dlopen for. +# +# Same gcc-wrapper trick as tools/python/build.sh: -EL -mnan=2008 baked into +# the driver, so libsodium's libtool link lines cannot lose them. Gated at +# e_flags=0x70001407 (a shared object, so EF_MIPS_PIC is set and it reads +# ...07 where the interpreter's executable reads ...05) before anything ships. +# +# ./build-libsodium.sh # in docker +# ./build-libsodium.sh --in-container # the actual build +# --------------------------------------------------------------------------- +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="${REPO:-/home/shish/firmware/.claude/worktrees/s6-vs-runit}" +TOOLCHAIN_HOST="$REPO/work/.mips-toolchain/mips-gcc720-glibc229" +SODIUM_VER="${SODIUM_VER:-1.0.20}" +SODIUM_SHA256=ebb65ef6ca439333c2bb41a0c1990587288da07f6c7fd07cb3a18cc18d30ce19 +PREFIX=/usr/data/anvil + +if [ "${1:-}" != "--in-container" ]; then + [ -x "$TOOLCHAIN_HOST/bin/mips-linux-gnu-gcc" ] || { + echo "!! no toolchain at $TOOLCHAIN_HOST" >&2; exit 1; } + exec docker run --rm \ + -v "$TOOLCHAIN_HOST":/toolchain:ro \ + -v "$HERE":/work \ + -e SODIUM_VER="$SODIUM_VER" -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ + -w /work debian:bookworm bash /work/build-libsodium.sh --in-container +fi + +# ======================================================= in the container === +START=$(date +%s) +export DEBIAN_FRONTEND=noninteractive +TC=/toolchain; W=/work; SRC=$W/src; B=$W/b-sodium; OUT=$W/out +rm -rf "$B"; mkdir -p "$SRC" "$B" "$OUT" +log() { echo; echo "===== $* ====="; } + +log "apt" +apt-get update -qq +apt-get install -y -qq --no-install-recommends \ + build-essential curl ca-certificates file xz-utils >/dev/null + +# ---- the wrappers: -EL -mnan=2008 on every compile AND every link ---------- +log "toolchain wrappers" +mkdir -p /opt/xw/bin +for t in gcc g++ cpp; do + cat > /opt/xw/bin/mips-linux-gnu-$t < /tmp/t.c +mips-linux-gnu-gcc /tmp/t.c -o /tmp/t +[ "$(eflags /tmp/t)" = "0x70001405" ] || { + echo "!! wrapper produces $(eflags /tmp/t), not 0x70001405" >&2; exit 1; } +echo "wrapper gate ok: $(eflags /tmp/t)" + +# ---- fetch ---------------------------------------------------------------- +log "libsodium $SODIUM_VER" +TAR="$SRC/libsodium-$SODIUM_VER.tar.gz" +[ -f "$TAR" ] || curl -fsSL -o "$TAR" \ + "https://github.com/jedisct1/libsodium/releases/download/$SODIUM_VER-RELEASE/libsodium-$SODIUM_VER.tar.gz" +echo "$SODIUM_SHA256 $TAR" | sha256sum -c - || { + echo "!! sha256 mismatch: $(sha256sum "$TAR")" >&2; exit 1; } +tar -xzf "$TAR" -C "$B" +cd "$B/libsodium-$SODIUM_VER" + +# ---- configure ------------------------------------------------------------ +# --disable-static: nothing links it statically and a .a would only be +# trimmed away again. +# --host is what makes autoconf reach for the mips-linux-gnu- prefixed tools +# in the wrapper directory, which is the whole point of the wrappers. +# libsodium's runtime feature probes are AC_RUN_IFELSE with cross defaults +# supplied, so nothing here needs qemu. +log "configure" +./configure \ + --host=mips-linux-gnu \ + --prefix="$PREFIX" \ + --disable-static \ + --enable-shared \ + --with-pic \ + CFLAGS="-O2 -fPIC" \ + > "$W/sodium-configure.log" 2>&1 || { tail -40 "$W/sodium-configure.log"; exit 1; } + +log "make" +make -j"$(nproc)" > "$W/sodium-make.log" 2>&1 || { tail -60 "$W/sodium-make.log"; exit 1; } + +STAGE=$W/stage-sodium +rm -rf "$STAGE"; mkdir -p "$STAGE" +make install DESTDIR="$STAGE" >> "$W/sodium-make.log" 2>&1 + +# ---- the ABI gate, on the object that ships ------------------------------- +log "ABI gate" +SO=$(readlink -f "$STAGE$PREFIX/lib/libsodium.so") +file "$SO" +mips-linux-gnu-readelf -h "$SO" | sed -n '/Class\|Data\|Type\|Flags/p' +F=$(eflags "$SO") +[ "$F" = "0x70001407" ] || { echo "!! e_flags $F, expected 0x70001407" >&2; exit 1; } +# The words that matter are decoded by readelf out of e_flags itself, on the +# header's Flags line -- `.MIPS.abiflags` (readelf -A) reports the ISA and the +# FP ABI but not the NaN encoding, so the header is where nan2008 is read. +FLAGLINE=$(mips-linux-gnu-readelf -h "$SO" | sed -n 's/.*Flags: *//p') +echo " decoded: $FLAGLINE" +for w in nan2008 o32 mips32r2; do + case ",$FLAGLINE," in + *"$w"*) ;; + *) echo "!! e_flags does not decode to $w: $FLAGLINE" >&2; exit 1 ;; + esac +done +mips-linux-gnu-readelf -A "$SO" | sed -n '/ISA\|FP ABI/p' +echo "-- SONAME / NEEDED" +mips-linux-gnu-readelf -d "$SO" | sed -n '/SONAME\|NEEDED/p' + +mips-linux-gnu-strip --strip-unneeded "$SO" + +# ---- what ships: lib/ only. include/ and pkgconfig are build-time. -------- +log "stage" +TREE=$W/sodium-tree +rm -rf "$TREE"; mkdir -p "$TREE/lib" +cp -a "$STAGE$PREFIX"/lib/libsodium.so* "$TREE/lib/" +rm -f "$TREE"/lib/*.la +ls -l "$TREE/lib" +tar -czf "$OUT/sodium.tgz" -C "$TREE" lib +chown -R "${HOST_UID:-0}:${HOST_GID:-0}" "$OUT" "$W"/sodium-*.log "$SRC" 2>/dev/null || true +echo +echo "sodium.tgz: $(du -h "$OUT/sodium.tgz" | cut -f1) ($(($(date +%s)-START))s)"