app
KiCad - the KiCad Bridge
Public Made by Adomby adom
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.
← Commit history
Publish 1.0.98
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",