diff --git a/multiace/VERSION b/multiace/VERSION index 36d529a..e766dac 100644 --- a/multiace/VERSION +++ b/multiace/VERSION @@ -1 +1 @@ -1.25b +1.26b diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index 93b1d4e..a699c31 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -293,7 +293,8 @@ "update_check_btn": "Auf Updates prüfen", "update_checking": "Prüfe...", "update_current": "Installiert", - "update_done": "Update installiert - Klipper wurde neu gestartet.", + "update_done": "Update installiert - Klipper und der Web-Dienst starten neu.", + "update_done_reboot": "Update installiert - bitte den Drucker neu starten, damit Klipper den neuen Code lädt.", "update_failed": "Update fehlgeschlagen", "update_intro": "Holt die neueste multiACE-Release und installiert sie.", "update_latest": "Verfügbar", diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index 3ee71c5..ee3569f 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -293,7 +293,8 @@ "update_check_btn": "Check for updates", "update_checking": "Checking...", "update_current": "Installed", - "update_done": "Update installed - Klipper restarted.", + "update_done": "Update installed - Klipper and the web service are restarting.", + "update_done_reboot": "Update installed - please reboot the printer to load the new Klipper code.", "update_failed": "Update failed", "update_intro": "Pulls the latest multiACE release and installs it.", "update_latest": "Available", diff --git a/multiace/install_multiace.sh b/multiace/install_multiace.sh index f5c1738..7cc025e 100755 --- a/multiace/install_multiace.sh +++ b/multiace/install_multiace.sh @@ -585,7 +585,12 @@ PYEOF else log " Skipped sudoers drop-in update (non-root context)" fi - if [ "$IS_ROOT" = "1" ] && [ -x "$INITD_SCRIPT" ]; then + if [ "$IS_ROOT" = "1" ] && [ -n "$MULTIACE_WEB_PID" ]; then + # Web update from a root-owned backend: it re-execs itself (same + # PID, S98's pidfile stays valid) once the updater returns. A + # stop/start here would kill the caller mid-request. + log " Web update: the backend restarts itself after the install" + elif [ "$IS_ROOT" = "1" ] && [ -x "$INITD_SCRIPT" ]; then "$INITD_SCRIPT" stop >>"$LOGFILE" 2>&1 || true "$INITD_SCRIPT" start >>"$LOGFILE" 2>&1 || log " WARN: start failed - see $LOGFILE" sleep 1 @@ -600,7 +605,12 @@ PYEOF log " WARN: multiACE Web NOT healthy - check $LOGFILE and $WEB_DEST/backend/" fi else - if pgrep -u lava -f 'uvicorn.*main:app' >/dev/null 2>&1; then + if [ -n "$MULTIACE_WEB_PID" ]; then + # Called by the web backend's update endpoint: that process + # re-execs itself once the updater returns. Killing it here + # left the web dead with nobody to restart it (HW 2026-10-10). + log " Web update: the backend restarts itself after the install" + elif pgrep -u lava -f 'uvicorn.*main:app' >/dev/null 2>&1; then pkill -TERM -u lava -f 'uvicorn.*main:app' 2>/dev/null || true log " Sent SIGTERM to running uvicorn (restart needed for new code)" fi @@ -612,7 +622,7 @@ PYEOF log " WARN: :7126 still served by an instance we cannot replace" log " (root-owned?) - new web code is NOT live until reboot" fi - log " Skipped service restart (non-root context) - reboot or use Web Restart" + [ -n "$MULTIACE_WEB_PID" ] || log " Skipped service restart (non-root context) - reboot, or restart S98multiace-web as root" fi fi DATA_DIR="${HOME_DIR}/printer_data" diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index 78ce191..a222c4b 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -16,7 +16,7 @@ KNOWN_PROTOCOLS = (AceProtocolV1, AceProtocolV2) -MULTIACE_VERSION = "1.25b" +MULTIACE_VERSION = "1.26b" # Engine API contract version (see docs/ENGINE_API.md). Distinct from # MULTIACE_VERSION (the product version): this only bumps on a breaking @@ -24,7 +24,7 @@ # external host can detect engine capability. Additive changes do not bump it. ACE_API_VERSION = 1 -MULTIACE_BUILD_TAG = "59f4cd1b" +MULTIACE_BUILD_TAG = "8e027719" MULTIACE_BUNDLE_SHA1 = "0f18123" # The former shipped value of [ace] update_url_base (static mirror); # read as unset so updates come from GitHub. diff --git a/multiace/tools/multiace_update.sh b/multiace/tools/multiace_update.sh index fb0ffc9..e601843 100644 --- a/multiace/tools/multiace_update.sh +++ b/multiace/tools/multiace_update.sh @@ -40,37 +40,35 @@ else fi ACE_PY="/home/lava/klipper/klippy/extras/ace.py" INSTALL_BASE="/home/lava/multiace" -restart_klipper() { - # 1. Moonraker API - preferred on Snapmaker / PAXX. Runs as lava, - # exposes /printer/firmware_restart on 127.0.0.1:7125, works - # whether the updater itself is root or lava. - for url in \ - http://127.0.0.1:7125/printer/firmware_restart \ - http://127.0.0.1:7125/printer/restart; do - if command -v curl >/dev/null 2>&1; then - if curl -sf -X POST "$url" >/dev/null 2>&1; then - return 0 - fi - elif command -v wget >/dev/null 2>&1; then - if wget -q --post-data="" -O /dev/null "$url" 2>/dev/null; then - return 0 +restart_klipper_service() { + # New Python code only loads with a fresh Klipper PROCESS: RESTART and + # FIRMWARE_RESTART keep the already imported extras in memory (HW + # 2026-10-10: banner stayed on the old build after firmware_restart). + # The init script needs root; Moonraker's provider is "none" on the U1, + # so /machine/services/restart is not available. Never during a print. + url="http://127.0.0.1:7125/printer/objects/query?print_stats" + if command -v curl >/dev/null 2>&1; then + st="$(curl -sf "$url" 2>/dev/null)" + else + st="$(wget -q -O - "$url" 2>/dev/null)" + fi + case "$st" in + *'"state": "printing"'*|*'"state": "paused"'*|*'"state":"printing"'*|*'"state":"paused"'*) + echo "WARN: a print is running - Klipper not restarted" >&2 + return 1 ;; + esac + if [ "$(id -u)" -eq 0 ]; then + for init in /etc/init.d/S60klipper /etc/init.d/S55klipper \ + /etc/init.d/S58klipper /etc/init.d/klipper \ + /etc/init.d/S99klipper; do + if [ -x "$init" ]; then + "$init" restart >/dev/null 2>&1 && return 0 fi - fi - done - # 2. Init scripts. Snapmaker U1 / PAXX uses S60klipper; the others - # are listed for forks. Needs root. - for init in /etc/init.d/S60klipper /etc/init.d/S55klipper \ - /etc/init.d/S58klipper /etc/init.d/klipper \ - /etc/init.d/S99klipper; do - if [ -x "$init" ]; then - "$init" restart >/dev/null 2>&1 && return 0 - fi - done - # 3. systemd, not on this hardware but harmless. + done + fi if command -v systemctl >/dev/null 2>&1; then systemctl restart klipper >/dev/null 2>&1 && return 0 fi - echo "WARN: could not restart Klipper automatically - do it manually" >&2 return 1 } current_version() { @@ -316,8 +314,13 @@ cmd_apply() { fi echo "STATUS: applying install_multiace.sh $INSTALL_WEB_FLAG" bash "$SRC/install_multiace.sh" $INSTALL_WEB_FLAG - echo "STATUS: please_reboot_printer" - echo "INFO: Update installed. Please restart the printer for the new code to take effect." + if restart_klipper_service; then + echo "STATUS: klipper_restarted" + echo "INFO: Update installed. Klipper restarted with the new code." + else + echo "STATUS: please_reboot_printer" + echo "INFO: Update installed. Please restart the printer for the new code to take effect." + fi echo "STATUS: done from=$CUR to=$LATEST" } case "${1:-check}" in diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index 6363010..81bda15 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -1801,6 +1801,9 @@ async def _run_update_script(args: list[str], timeout: float) -> dict: "Re-run install_multiace.sh from the repo to ship it.")) env = os.environ.copy() env.update(_read_update_cfg()) + # Tells install_multiace.sh that its caller IS the web backend: it must + # not SIGTERM this process (the backend re-execs itself afterwards). + env["MULTIACE_WEB_PID"] = str(os.getpid()) try: proc = await asyncio.create_subprocess_exec( "bash", update_script, *args, @@ -1934,7 +1937,40 @@ async def update_apply(force: bool = False) -> dict: args = ["apply"] if force: args.append("--force") - return await _run_update_script(args, timeout=600.0) + result = await _run_update_script(args, timeout=600.0) + if result.get("ok") and any( + l.startswith("done") for l in result.get("status_lines", [])): + # The installer replaced our own files; the init script cannot be + # driven from a lava-owned instance and SIGTERM without a restart + # left the web dead (HW 2026-10-10). Re-exec this very process + # with its own command line: same PID (S98's pidfile stays valid), + # works as root and as lava. Delayed so the response gets out. + result["web_restart"] = True + asyncio.get_running_loop().call_later(1.5, _self_reexec) + return result + +def _self_reexec() -> None: + """Replace this process with a fresh copy of itself (new code).""" + argv: list[str] = [] + try: + with open("/proc/self/cmdline", "rb") as f: + argv = [a.decode("utf-8", "replace") + for a in f.read().split(b"\0") if a] + except Exception: + argv = [] + if not argv or not os.path.isabs(argv[0]): + argv = [sys.executable, "-m", "uvicorn"] + sys.argv[1:] + _trace.info("multiace web: re-exec after update: %s", " ".join(argv)) + try: + sys.stdout.flush() + sys.stderr.flush() + except Exception: + pass + try: + os.execv(argv[0], argv) + except Exception as e: + _trace.error("multiace web: re-exec failed (%s) - restart " + "S98multiace-web or reboot", e) _DEBUG_FLAG_PATH = Path("/oem/.debug") @@ -4873,6 +4909,15 @@ async def _spoolman_tag_sweep_loop() -> None: @app.on_event("startup") async def _start_log_listener() -> None: + # Who runs this process decides what an update can restart (S98 starts + # it as root; a lava-owned instance cannot restart the init scripts). + try: + import pwd + _user = pwd.getpwuid(os.getuid()).pw_name + except Exception: + _user = "?" + _trace.info("multiace web starting: pid=%d uid=%d user=%s cwd=%s", + os.getpid(), os.getuid(), _user, os.getcwd()) asyncio.create_task(_moonraker_log_listener()) asyncio.create_task(_spoolman_periodic_push()) asyncio.create_task(_spoolman_startup_push()) diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index 867b606..0a4d405 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -5250,7 +5250,8 @@ createApp({ if (!r.ok) throw new Error(j.detail || `HTTP ${r.status}`); _parseUpdateResult(j); if (j.ok) { - setMacroLog(t("ui.config.update_done")); + const reboot = (j.status_lines || []).some(l => String(l).startsWith("please_reboot")); + setMacroLog(t(reboot ? "ui.config.update_done_reboot" : "ui.config.update_done")); } } catch (e) { updateState.statusText = `${t("ui.config.update_failed")}: ${e.message || e}`;