posix.sh now implements the update-marker contract (C1/C2) as a lock: - the daemon claims `.hermes-update-in-progress` as its first action with a v2 body (pid, started_at, `ct:` creation time) via an atomic no-replace create (link(2)); a LIVE foreign owner refuses with exit 2 regardless of age, a marker owned by --desktop-pid is adopted keeping its started_at, a dead one is compare-and-deleted; liveness honours a line-4 `delegate:` (the running `hermes update`), so an orphaned update is never reclaimed; - the launcher waits for the daemon's claim and exits non-zero when it never lands, so the Desktop does not quit into nothing; - release is compare-and-delete and keeps a marker whose delegate is alive. Outcomes follow C3: a legacy rebuild failure, a mac bundle stage/swap failure or a relaunch gate after `hermes update` exited 0 is ok:true + warnings (no more exit 6 / exit 7 "still on the previous version"). The result carries started_at and warnings and is written via a per-pid tmp + rename. Crash safety: start-of-run recovery restores an interrupted .app swap from its aside copy (both swappers' suffixes) and a killed TCC heal backup, and sweeps UI/probe litter of dead pids; the two swap renames are a signal-deferred critical section. `hermes desktop`'s Python swappers recover the same state instead of deleting the only copy first. Also: probes are bounded (60 s), `hermes update` streams into the log, the update child gets default TERM and the daemon default HUP (no SIG_IGN leak into gateways or the relaunched app), the relaunched Linux app drops the update env, the Desktop wait is zombie/pid-reuse aware and never relaunches over a live app, serve-ui.py exits with its orchestrator, and the job section is parsed as one brace group so `hermes update` rewriting posix.sh cannot shift the rest. (cherry picked from commit c7dd9fab00b8af09321ec982d07a62f03351355d)
82 lines
2.7 KiB
Python
82 lines
2.7 KiB
Python
"""Loopback shim server for the desktop update hand-off.
|
|
|
|
Two GET routes: / serves ui.html, /progress serves the status file the
|
|
orchestrator script writes ({"status": "running"|"done"|"error", ...}).
|
|
Exists because a file:// page cannot receive events from a detached
|
|
process. Prints the chosen ephemeral port on stdout, serves until killed.
|
|
|
|
`elapsed_seconds` is stamped per request, not read from the status file:
|
|
stages are minutes apart, so a value frozen at the last publish would sit
|
|
still through the longest waits -- exactly the stall the page exists to
|
|
disprove. Windows' in-process listener computes it the same way.
|
|
"""
|
|
|
|
import http.server
|
|
import json
|
|
import os
|
|
import socketserver
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
html_path, status_path = sys.argv[1], sys.argv[2]
|
|
started_at = float(sys.argv[3]) if len(sys.argv) > 3 else time.time()
|
|
owner_pid = int(sys.argv[4]) if len(sys.argv) > 4 else 0
|
|
with open(html_path, "rb") as f:
|
|
HTML = f.read()
|
|
|
|
|
|
def exit_with_owner():
|
|
"""Never outlive the hand-off: it ignores TERM/HUP, so a killed orchestrator
|
|
used to leave this server running on the old venv's python forever."""
|
|
while True:
|
|
time.sleep(2)
|
|
try:
|
|
os.kill(owner_pid, 0) # windows-footgun: ok — posix.sh-only helper (Windows has its own listener)
|
|
except ProcessLookupError:
|
|
os._exit(0)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def progress_body():
|
|
try:
|
|
with open(status_path, "rb") as f:
|
|
state = json.loads(f.read())
|
|
if not isinstance(state, dict):
|
|
raise ValueError(state)
|
|
except Exception:
|
|
state = {"status": "running", "message": ""}
|
|
state["elapsed_seconds"] = max(0, int(time.time() - started_at))
|
|
|
|
return json.dumps(state).encode("utf-8")
|
|
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, format, *args): # noqa: A002 - base class signature
|
|
pass
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith("/progress"):
|
|
body = progress_body()
|
|
ctype = "application/json; charset=utf-8"
|
|
elif self.path == "/":
|
|
body, ctype = HTML, "text/html; charset=utf-8"
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
|
|
with socketserver.TCPServer(("127.0.0.1", 0), Handler) as srv:
|
|
if owner_pid > 0:
|
|
threading.Thread(target=exit_with_owner, daemon=True).start()
|
|
print(srv.server_address[1], flush=True)
|
|
srv.serve_forever()
|