← Commit history

Publish 1.0.98

John Lauer ·c953a60568 ·1mo ago ·parent 891c83c
3 files changed +70−32
dashboard/kicad_dashboard.py+68−30
@@ -49,7 +49,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse -VERSION = "4.4.0"+VERSION = "4.5.0" AI_THREAD = "kicad-dashboard" DEFAULT_PORT = 8931 UI_DIR = Path(__file__).resolve().parent / "ui"@@ -1152,8 +1152,11 @@ class Handler(BaseHTTPRequestHandler):             except Exception:                 return self._json({"error": "logo missing"}, 404)         if path == "/health":+            # pid published so a SUCCESSOR can retire us directly instead of+            # inferring ownership. Older builds do not send it, which is why+            # _pid_owning_port() exists and does not depend on this.             return self._json({"ok": True, "app": "kicad-dashboard", "version": VERSION,-                               "sseClients": HUB.count()})+                               "pid": os.getpid(), "sseClients": HUB.count()})         am = re.search(r"/assets/(.+)$", path)         if am:             # Settings screenshots + the shared Adom cog. Real captures only@@ -1408,46 +1411,81 @@ def main():         def _healthy_instance(p_):             return _running_version(p_) is not None -        def _retire_stale(p_, their):-            """VERSION-BLIND REUSE (dock thread, 2026-08-23, twice): after an-            install, `serve` found a healthy OLD instance and happily reused-            it, so /health kept reporting 2.0.0 and new actions answered-            "unknown action" until someone killed it by hand. A healthy-            instance of a DIFFERENT version is not a reason to skip serving;-            it is the thing to replace. Kill only our own serve processes-            (matched by command line), never anything broad."""-            log(f"kicad-dashboard {their} is serving on {p_} but this is {VERSION}; "-                f"retiring the stale instance")+        def _pid_owning_port(p_):+            """The pid LISTENING on p_, via /proc/net/tcp inode -> /proc/*/fd.++            PORT OWNERSHIP, not argv (dockbar guide: "argv is a claim, not a+            fact"). Rehearsed against a real predecessor 2026-08-25: selecting+            victims by argv alone killed an UNRELATED instance serving a+            different port, because every instance of this tool has identical+            argv. The port is the thing being contested, so the port is what+            decides who is retired."""+            want = []+            for fam, path in ((4, "/proc/net/tcp"), (6, "/proc/net/tcp6")):+                try:+                    for line in open(path).read().splitlines()[1:]:+                        f = line.split()+                        if len(f) < 4 or f[3] != "0A":      # 0A = LISTEN+                            continue+                        if int(f[1].split(":")[1], 16) == int(p_):+                            want.append(f[9])                # inode+                except Exception:+                    pass+            if not want:+                return None             me = os.getpid()-            victims = []             for pid in os.listdir("/proc"):                 if not pid.isdigit() or int(pid) == me:                     continue                 try:-                    argv_ = open(f"/proc/{pid}/cmdline", "rb").read().split(b"\0")+                    for fd in os.listdir(f"/proc/{pid}/fd"):+                        try:+                            link = os.readlink(f"/proc/{pid}/fd/{fd}")+                        except Exception:+                            continue+                        if link.startswith("socket:[") and link[8:-1] in want:+                            return int(pid)                 except Exception:                     continue-                # EXACT argv shape, not a substring: a substring match also hit-                # the SHELL whose command text mentioned this script (it killed-                # its own parent shell, 2026-08-23). argv = [python, script, serve, ...]-                if (len(argv_) >= 3 and os.path.basename(argv_[0]).startswith(b"python")-                        and argv_[1].endswith(b"kicad_dashboard.py") and argv_[2] == b"serve"):-                    victims.append(int(pid))+            return None++        def _retire_stale(p_, their):+            """A healthy instance of a DIFFERENT version is not a reason to skip+            serving; it is the thing to replace (dock thread, 2026-08-23).++            Retire the process that OWNS THE PORT, and use argv only as a+            SAFETY FILTER on that one pid - never as the selector. Selecting by+            argv killed a bystander on another port in rehearsal."""+            log(f"kicad-dashboard {their} is serving on {p_} but this is {VERSION}; "+                f"retiring the stale instance")+            victim = _pid_owning_port(p_)+            if victim is None:+                log(f"could not identify the process owning port {p_}; not killing anything")+                return False+            try:+                argv_ = open(f"/proc/{victim}/cmdline", "rb").read().split(b"\0")+            except Exception:+                argv_ = []+            ours = (len(argv_) >= 3+                    and os.path.basename(argv_[0]).startswith(b"python")+                    and argv_[1].endswith(b"kicad_dashboard.py")+                    and argv_[2] == b"serve")+            if not ours:+                log(f"port {p_} is held by pid {victim} which is not one of ours; refusing to kill")+                return False             import signal-            for pid in victims:-                try:-                    os.kill(pid, signal.SIGTERM)-                except Exception:-                    pass+            try:+                os.kill(victim, signal.SIGTERM)+            except Exception:+                pass             for _ in range(40):                # up to ~4s for the port to free                 if _running_version(p_) is None:                     return True                 time.sleep(0.1)-            for pid in victims:-                try:-                    os.kill(pid, signal.SIGKILL)-                except Exception:-                    pass+            try:+                os.kill(victim, signal.SIGKILL)+            except Exception:+                pass             time.sleep(0.5)             return _running_version(p_) is None 
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "kicad-bridge",-  "version": "1.0.97",+  "version": "1.0.98",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the KiCad bridge. The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [
page.json+1−1
@@ -4,7 +4,7 @@   "slug": "kicad-bridge",   "title": "KiCad - the KiCad Bridge",   "brief": "Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.",-  "version": "1.0.97",+  "version": "1.0.98",   "tags": [     "kicad",     "pcb",