diff --git a/docs/building.md b/docs/building.md index 0ddb61a..df5d6db 100644 --- a/docs/building.md +++ b/docs/building.md @@ -292,9 +292,9 @@ pkgs/anvil-core/ what makes the machine ours, and nothing else payload/ anvil-env.sh PATH/LD_LIBRARY_PATH/FF_PYTHON -- sourced bin/ ff-startup.py, ff_mcu_bringup.py, ffscreen.py, wifi-action.sh - etc/s6-rc/source/ one directory per service: wifi, nginx, moonraker, - camera, klipper, ui, mcu-bringup, ff-startup, - ok-all + etc/s6-rc/source/ one directory per service: wifi, wifi-dhcp, + nginx, moonraker, camera, klipper, ui, ntp, + mcu-bringup, ff-startup, ok-all nginx/nginx.conf bin/anvil-link-prog.sh links the two below, and the configs, into the absolute paths the stock scripts read diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 7f410bc..5756a7a 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -54,7 +54,9 @@ compiled into a database at build time and supervised by `s6-svscan`: ``` mcu-bringup oneshot toolhead boards out of their bootloaders klipper longrun klippy (depends on mcu-bringup) -wifi oneshot wlan0 + wpa_supplicant + udhcpc +wifi longrun wpa_supplicant on wlan0 +wifi-dhcp longrun udhcpc, on wpa_supplicant's association + events (depends on wifi) nginx longrun nginx (Mainsail) on :80 moonraker longrun moonraker on :7125 camera longrun mjpg-streamer on :8080 (nginx proxies it at /webcam/) diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/ntp/run b/pkgs/anvil-core/payload/etc/s6-rc/source/ntp/run index 131a714..70b276d 100755 --- a/pkgs/anvil-core/payload/etc/s6-rc/source/ntp/run +++ b/pkgs/anvil-core/payload/etc/s6-rc/source/ntp/run @@ -11,19 +11,18 @@ # # A LONGRUN, NOT A ONESHOT AFTER WIFI, and the difference is the whole design: # -# * The wifi oneshot runs ONCE, at boot, and returns immediately (read its -# `up`: the waits are backgrounded on purpose). A time sync hung off the -# end of it would fire while wlan0 was still associating, fail, and never -# be tried again -- and would not fire at all for the printer whose owner -# joins a network from the HelixScreen panel an hour after boot. +# * A time sync hung off the end of `wifi` would fire while wlan0 was +# still associating, fail, and never be tried again -- and would not fire +# at all for the printer whose owner joins a network from the HelixScreen +# panel an hour after boot. # * A clock is not set once. There is no RTC to hold the answer, so the # correction has to be maintained for as long as the printer is up. # # sntpd does both by design: with the network down it logs "will try again # later" and keeps its schedule, rather than exiting, so THIS SERVICE NEEDS NO -# WIFI DEPENDENCY -- which is just as well, because nothing may depend on the -# wifi oneshot (see its `up`). It syncs whenever the network turns up, from -# whichever direction. +# WIFI DEPENDENCY -- and would gain nothing from one, because `wifi` up means +# a supplicant is running, not that anything is routable. It syncs whenever +# the network turns up, from whichever direction. MODDIR=/usr/data/anvil # anvil-env.sh is read HERE, not inherited. s6-supervise starts this script diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/ok-all/contents.d/wifi-dhcp b/pkgs/anvil-core/payload/etc/s6-rc/source/ok-all/contents.d/wifi-dhcp new file mode 100644 index 0000000..e69de29 diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/dependencies.d/wifi b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/dependencies.d/wifi new file mode 100644 index 0000000..e69de29 diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/finish b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/finish new file mode 100755 index 0000000..f228285 --- /dev/null +++ b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/finish @@ -0,0 +1,14 @@ +#!/bin/sh +# Runs after wpa_cli exits -- on every stop, and on every restart. +# +# The action script's DISCONNECTED branch drops the lease already, but only +# when there is a wpa_cli alive to be told. A supplicant killed outright -- an +# OOM, a kill -9, `s6-rc -d change wifi` -- produces no event at all, and the +# udhcpc left behind goes on renewing an address for a network this printer is +# no longer associated to. That is worse than having no address, because it +# looks like success to everything that checks. +# +# Safe on a restart rather than a stop because `run` asks the supplicant +# whether it is still associated and re-runs the action script if it is. +killall udhcpc 2>/dev/null +exit 0 diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/run b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/run new file mode 100755 index 0000000..2fafc19 --- /dev/null +++ b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/run @@ -0,0 +1,69 @@ +#!/bin/sh +# The DHCP client, hung off wpa_supplicant's own association events. +# Exec'd by s6-supervise, never by hand. +# +# WPA_SUPPLICANT ASSOCIATES AND NOTHING MORE; it never asks for an address. +# Stock firmwareExe watched for that and ran udhcpc itself (it is full of +# "udhcpc -i wlan0" strings) and FlashForge shipped an action script at +# /usr/prog/wifi/bin/wifi_reconnect for the same hook. Ours is +# $MODDIR/bin/wifi-action.sh, which has shipped since the s6 migration and, +# until this service existed, was invoked by nothing at all. +# +# WHY A SERVICE AND NOT TWO LINES IN wifi/run. The old wifi oneshot ran udhcpc +# ONCE, straight after association, which covers exactly one story: a printer +# booting in range of a network it already knows. It does not cover the owner +# who joins a network from the HelixScreen panel an hour later -- associated, +# no address, which the panel reports as a connection timeout -- and it does +# not cover a lease lost to an AP reboot. `wpa_cli -a` fires on every +# association change for as long as it runs, so all three become one path. +MODDIR=/usr/data/anvil +IFACE=wlan0 +ACTION=$MODDIR/bin/wifi-action.sh +CTRL=/var/run/wpa_supplicant/$IFACE + +[ -f $MODDIR/anvil-env.sh ] && . $MODDIR/anvil-env.sh + +mkdir -p /usr/data/logs +exec >>/usr/data/logs/anvil-wifi.log 2>&1 + +if [ ! -x "$ACTION" ]; then + echo "wifi-dhcp: !! no $ACTION -- nothing will ask for an address" + sleep 5 + exit 1 +fi + +# wpa_cli attaches to the control socket and exits at once if it is not there. +# `dependencies.d/wifi` only says the supplicant was exec'd, not that it has +# created the socket -- wifi has no notification-fd and nothing it could +# honestly send one on -- so the wait belongs here. +n=0 +while [ $n -lt 30 ] && [ ! -e "$CTRL" ]; do + sleep 1 + n=$((n + 1)) +done +if [ ! -e "$CTRL" ]; then + echo "wifi-dhcp: !! $CTRL never appeared -- is wpa_supplicant up?" + sleep 5 + exit 1 +fi + +killall wpa_cli 2>/dev/null + +# THE RESTART CASE, and it is the one that bites. wpa_cli runs the action +# script on CHANGES; restarted while the printer is already associated there +# is no change left to fire on, and the lease `finish` just dropped would +# never come back. The action script replaces any previous client before it +# starts one, so calling it here is idempotent with an event that may also +# arrive a moment later. +if /usr/sbin/wpa_cli -i $IFACE status 2>/dev/null | grep -q '^wpa_state=COMPLETED'; then + echo "wifi-dhcp: already associated -- asking for a lease now" + "$ACTION" "$IFACE" CONNECTED +fi + +# NO -B, for the reason wifi/run has none. wpa_cli exits when the supplicant +# goes away, which is correct here rather than a fault: s6 restarts this, it +# waits for the new control socket, and the printer comes back without anyone +# typing anything. +# +# And `exec`, so a stop signals wpa_cli rather than a shell that ignores it. +exec /usr/sbin/wpa_cli -i $IFACE -a "$ACTION" diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/type b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/type new file mode 100644 index 0000000..5883cff --- /dev/null +++ b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi-dhcp/type @@ -0,0 +1 @@ +longrun diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/down b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/down deleted file mode 100644 index dcaa8d3..0000000 --- a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/down +++ /dev/null @@ -1,16 +0,0 @@ -/bin/sh -c " -# By name: nothing here was started with a pidfile, and inventing one for a -# daemon that backgrounds itself would be a second thing that can be wrong. -killall wpa_cli 2>/dev/null -killall udhcpc 2>/dev/null -killall wpa_supplicant 2>/dev/null -# killall returns as soon as the signal is sent, so without this wait a -# reconnect a moment later finds the dying supplicant in the process table and -# does nothing. -n=0 -while [ \$n -lt 15 ] && { pgrep -f wpa_supplicant >/dev/null 2>&1 || ps 2>/dev/null | grep wpa_supplican[t] >/dev/null; }; do - sleep 1 - n=\$((n + 1)) -done -echo 'wifi: stopped' -" diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/run b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/run new file mode 100755 index 0000000..68e4ef6 --- /dev/null +++ b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/run @@ -0,0 +1,94 @@ +#!/bin/sh +# wpa_supplicant, as s6 supervises it. Exec'd by s6-supervise, never by hand. +# +# A LONGRUN, AND NO -B, and that pair is the whole point of this file. +# +# This was a oneshot whose `up` backgrounded a subshell and returned at once, +# so that the 15s wait for wlan0 and the 20s wait to associate did not stall +# the boot transition firmwareExe blocks on. The comment there was explicit +# that nothing could depend on it, which was true and not the problem. The +# problem is that the subshell it left behind was a child of +# s6rc-oneshot-runner with its stdout on a pipe s6-rc closes the moment the up +# script returns -- so a `sleep 1` in the wlan0 wait, then an `echo`, is a +# write to a closed pipe. +# +# OBSERVED: a printer that booted with a healthy supervision tree, `s6-rc` +# reporting wifi up, no wlan0 configured, no lease, and not one line anywhere +# saying why -- HelixScreen calling it "wifi system is not ready" because +# there was no supplicant to hold a control socket. The subshell was never +# caught in the act, and could not be: once its pipe is gone it has no way to +# report its own death. Which is the argument. A oneshot that forks and +# returns cannot be relied on and cannot be debugged when it isn't. +# +# A longrun has neither half of that. Blocking IS the job, s6-supervise holds +# the process, and a supplicant that dies is restarted rather than mourned. +MODDIR=/usr/data/anvil +IFACE=wlan0 + +# The saved networks. FlashForge's file: the mod neither ships nor touches it, +# so whatever their UI saved is still what this reads. +CONF=/usr/prog/wifi/wpa_supplicant.conf + +# anvil-env.sh is read HERE, not inherited. s6-supervise starts this script +# from the SCANNER's environment, which is firmwareExe's at boot but whatever +# an ssh session had when the service is restarted by hand. +[ -f $MODDIR/anvil-env.sh ] && . $MODDIR/anvil-env.sh + +# The same log the action script writes, so association and address are one +# story in one file. Appending, so a respawn cannot erase the reason for it. +mkdir -p /usr/data/logs +exec >>/usr/data/logs/anvil-wifi.log 2>&1 +echo "=== wifi `date 2>/dev/null` ===" + +# app_startup.sh insmods 8821cu.ko just before it runs firmwareExe, so on a +# normal boot wlan0 is already here; the wait covers a slow USB enumeration +# and the restart typed over ssh. A run script cannot decline to run -- s6 +# respawns it -- so a missing interface is reported and slept on rather than +# exec'd into a loop that fills the log once a second. +n=0 +while [ $n -lt 30 ] && [ ! -d /sys/class/net/$IFACE ]; do + sleep 1 + n=$((n + 1)) +done +if [ ! -d /sys/class/net/$IFACE ]; then + echo "wifi: !! $IFACE never appeared -- is 8821cu.ko loaded?" + sleep 5 + exit 1 +fi + +# A MISSING CONFIG IS NOT PERMANENT, so this retries slowly instead of giving +# up the way the oneshot did. It appears the first time a network is saved. +# Note the standing chicken-and-egg, unchanged by this file and not ours to +# fix here: a printer with no config has no supplicant, so it has no control +# socket for HelixScreen to add a network through either. +if [ ! -f "$CONF" ]; then + echo "wifi: !! no $CONF -- no saved network to join" + sleep 60 + exit 1 +fi + +# s6-supervise runs one copy of this at a time, so any supplicant answering +# now is a leftover -- one started by hand over ssh, or by the rescue block an +# older firmwareExe carried. Two supplicants on one interface fight over the +# association and the loser's retries look like a flapping AP. +killall wpa_supplicant 2>/dev/null +n=0 +while [ $n -lt 15 ] && { pgrep -f wpa_supplicant >/dev/null 2>&1 \ + || ps 2>/dev/null | grep wpa_supplican[t] >/dev/null; }; do + sleep 1 + n=$((n + 1)) +done + +/sbin/ifconfig $IFACE up 2>/dev/null +mkdir -p /var/run/wpa_supplicant + +# NO -B: with it, s6 would hold the parent that forked and exited, call the +# service dead and respawn it for ever beside a real supplicant supervised by +# nobody -- the same trap nginx's `daemon off;` avoids. +# +# nl80211 first, wext as the fallback: the 8821cu is a Realtek USB part and +# which one it registers varies by build. +# +# And `exec`, so the SIGTERM behind `s6-rc -d change wifi` reaches the +# supplicant rather than a shell that ignores it. +exec /usr/sbin/wpa_supplicant -i $IFACE -c "$CONF" -D nl80211,wext diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/type b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/type index bdd22a1..5883cff 100644 --- a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/type +++ b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/type @@ -1 +1 @@ -oneshot +longrun diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/up b/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/up deleted file mode 100644 index 3d176ee..0000000 --- a/pkgs/anvil-core/payload/etc/s6-rc/source/wifi/up +++ /dev/null @@ -1,58 +0,0 @@ -/bin/sh -c " -# wlan0 + wpa_supplicant + udhcpc. Nothing in /etc/init.d brings the interface -# up: FlashForge's firmwareExe owned the whole network stack, and app_startup.sh -# only insmods the 8821cu driver. So the moment the mod replaces firmwareExe the -# printer boots with no ssh, no Mainsail and no way to diagnose either. -# -# A ONESHOT, not a longrun: wpa_supplicant backgrounds itself with -B and so -# does udhcpc, so there is no process for s6-supervise to hold. -# -# THE WAITS ARE BACKGROUNDED and this returns at once, because a oneshot's up -# runs inside the transition firmwareExe is blocked on -- up to 15s for wlan0 to -# register after the insmod and 20s more to associate, and that delay would land -# on exactly the printer with no network. Returning early is lying to whatever -# depends on this, so NOTHING DEPENDS ON WIFI, deliberately: moonraker and nginx -# bind 0.0.0.0 and klipper talks to serial ports. -# -# The saved networks live in /usr/prog/wifi/wpa_supplicant.conf, which the mod -# never ships or touches, so whatever FlashForge's UI saved is still there. -IFACE=wlan0 -CONF=/usr/prog/wifi/wpa_supplicant.conf - -if pgrep -f wpa_supplicant >/dev/null 2>&1 || ps 2>/dev/null | grep wpa_supplican[t] >/dev/null; then - echo 'wifi: wpa_supplicant already running' - exit 0 -fi -[ -f \"\$CONF\" ] || { echo \"wifi: !! no \$CONF -- nothing to connect to\"; exit 0; } - -( - n=0 - while [ \$n -lt 15 ] && [ ! -d /sys/class/net/\$IFACE ]; do - sleep 1 - n=\$((n + 1)) - done - if [ ! -d /sys/class/net/\$IFACE ]; then - echo \"wifi: !! \$IFACE never appeared -- is 8821cu.ko loaded?\" - exit 1 - fi - /sbin/ifconfig \$IFACE up 2>/dev/null - mkdir -p /var/run/wpa_supplicant - # nl80211 first, wext as the fallback: the 8821cu is a Realtek USB part and - # which one it registers varies by build. - if /usr/sbin/wpa_supplicant -B -i \$IFACE -c \"\$CONF\" -D nl80211,wext; then - echo 'wifi: wpa_supplicant started' - else - echo 'wifi: !! wpa_supplicant failed to start' - exit 1 - fi - n=0 - while [ \$n -lt 20 ]; do - /usr/sbin/wpa_cli -i \$IFACE status 2>/dev/null | grep -q '^wpa_state=COMPLETED' && break - sleep 1 - n=\$((n + 1)) - done - /sbin/udhcpc -i \$IFACE -b -t 5 >/dev/null 2>&1 & - echo \"wifi: udhcpc requesting a lease on \$IFACE\" -) & -echo 'wifi: bringing up wlan0 in the background' -" diff --git a/qa/replica/test_s6rc.py b/qa/replica/test_s6rc.py index 49a5422..46fd99e 100644 --- a/qa/replica/test_s6rc.py +++ b/qa/replica/test_s6rc.py @@ -23,8 +23,9 @@ DB = MODDIR + "/etc/s6-rc/compiled/current" # sets are in ok-all on a real printer. # # BOOT_SET is the longruns that need nothing but the rootfs, so s6-supervise -# must report them up here. wifi is NOT in it despite needing no hardware: it -# is a oneshot, so there is no supervised servicedir for s6-svstat to read. +# must report them up here. wifi and wifi-dhcp are longruns now and do have +# supervised servicedirs, but they are NOT in it: both want a real wlan0, and +# on a machine without one they report the interface missing and exit. BOOT_SET = {"nginx", "moonraker", "ntp"} # HW_SET cannot come up on a replica, so it is asked of the compiled database # instead -- still where a missing or misnamed service shows: @@ -36,8 +37,10 @@ BOOT_SET = {"nginx", "moonraker", "ntp"} # the live list # ui needs no hardware itself, but depends on ff-startup, so the # transition never reaches it -# wifi the oneshot above -HW_SET = {"camera", "klipper", "mcu-bringup", "ff-startup", "ui", "wifi"} +# wifi wants /sys/class/net/wlan0 and the 8821cu behind it +# wifi-dhcp wants wifi's control socket, so it follows wifi down +HW_SET = {"camera", "klipper", "mcu-bringup", "ff-startup", "ui", "wifi", + "wifi-dhcp"} # EVERY s6-rc-init HERE NEEDS THIS, for the reason firmwareExe needs it: the # default deadline is TAIN_INFINITE_RELATIVE, which does not fit this printer's @@ -157,8 +160,8 @@ def test_the_supervisor_is_the_one_we_shipped(box): def test_execline_shipped_too(box): """Every oneshot's `up` runs through s6rc-oneshot-runner, whose `run` is - execline. Without execlineb the wifi oneshot fails at exec and nothing - else does, which reads as a wifi bug.""" + execline. Without execlineb mcu-bringup and ff-startup fail at exec while + every longrun starts fine, which reads as a klipper bug.""" assert box.file(MODDIR + "/bin/execlineb").executable