Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion multiace/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.25b
1.26b
3 changes: 2 additions & 1 deletion multiace/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion multiace/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 13 additions & 3 deletions multiace/install_multiace.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions multiace/klipper/extras/ace.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@

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
# change to the command vocabulary or the `ace` status object shape, so an
# 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.
Expand Down
61 changes: 32 additions & 29 deletions multiace/tools/multiace_update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion multiace/web/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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())
Expand Down
3 changes: 2 additions & 1 deletion multiace/web/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
Loading