fix(updates): re-exec correctly in both source and frozen builds

You were right — the original analysis was inverted. The unprefixed
form (sys.argv only) is the correct fix for frozen/packaged builds
(PyInstaller, zipapp, etc.) where sys.argv[0] == sys.executable ==
<binary>; master's [sys.executable] + sys.argv form re-inserts the
binary as argv[1] in that case, which the interpreter then tries to
parse as the script to run — leading to a recursive-reexec that
never reaches bind().

But for a source checkout launched as `python server.py` via
bootstrap.py / ctl.sh / start.sh, sys.argv[0] is the SCRIPT path and
sys.executable is the interpreter. CPython treats argv[1] as the
script to run, so we must pass [sys.executable] + sys.argv — the
canonical CPython re-exec idiom.

The two cases are mutually exclusive on argv shape, so a flat swap
can't be right for both. Distinguish with sys.frozen (set by
PyInstaller / zipapp / similar) and pick the right form per case.

Also reworded the last-resort except comment — the prior
"(e.g. frozen binary)" parenthetical was misleading; this branch is
the unconditional fallback for any execv failure, not specifically
the frozen case.
This commit is contained in:
PatrickNoFilter 2026-06-02 19:53:28 +07:00 committed by nesquena-hermes
parent f12608487f
commit 1402b56155

View file

@ -1049,10 +1049,37 @@ def _schedule_restart(delay: float = 2.0) -> None:
with _apply_lock:
_wait_until_restart_safe()
try:
os.execv(sys.executable, [sys.executable] + sys.argv)
# Re-exec into the just-pulled image.
#
# sys.argv[0]'s meaning depends on how the server was launched:
#
# * Source checkout (`python server.py` via bootstrap.py /
# ctl.sh / start.sh): sys.argv[0] is the SCRIPT path
# (e.g. "/root/hermes-webui/server.py"), sys.executable is
# the interpreter. CPython treats argv[1] as the script to
# run, so we must pass [sys.executable] + sys.argv.
#
# * Frozen/packaged build (PyInstaller, embedded zipapp,
# etc.): sys.argv[0] == sys.executable == <binary>. Passing
# [sys.executable] + sys.argv would re-insert the binary as
# argv[1] — the kernel launches it, the interpreter treats
# the binary itself as the "script" to run, and execv
# effectively becomes a recursive no-op that never reaches
# bind(), leaving the WebUI stuck "offline" after every
# self-update. Pass argv as-is instead.
#
# Distinguish the two cases with sys.frozen (set by
# PyInstaller / zipapp / similar). For source checkouts the
# `[sys.executable] + sys.argv` form is the canonical CPython
# re-exec idiom (same shape Flask/Django reloaders use) and
# is the correct path.
if getattr(sys, "frozen", False):
os.execv(sys.executable, sys.argv)
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception:
# Last-resort: if execv fails (e.g. frozen binary), just exit
# so the process supervisor (start.sh / Docker) restarts us.
# Last-resort: if execv fails for any reason, just exit so the
# process supervisor (start.sh / Docker) restarts us.
os._exit(0)
threading.Thread(target=_do, daemon=True).start()