← Commit history

Merge PR #92: Fix Antigravity dock startup with slow editor discovery (fix/dashboard-startup-health -> main) merge

John Lauer ·0311023d4d ·1mo ago ·parents a140889, 64731b6

Showing changes introduced by the merge (against its first parent).

7 files changed +262−27
README.md+8
@@ -24,6 +24,14 @@ adom-antigravity open [--new]        # focus the Antigravity panel (or start a n adom-antigravity serve --print-url   # the dashboard (what a dock click runs) ``` +## Dashboard updates++The dock checks for package updates on launch. Server readiness uses `/api/health`,+which remains fast while `/api/status` discovers the editor and installed tools.+When a release changes, `serve` replaces only the verified Antigravity process+owning its port; repeated launches reuse the current server. A newer running+version and unrelated port owners are left in place.+ ## Where Antigravity shows up  The extension starts Antigravity's own web UI (a local "hub") inside the container and frames it in the editor's side bar. That frame needs the editor to hand out forwarded ports as `http://<port>.localhost:<proxy port>/` (Hydrogen 1.0.224 and later do: Hydrogen's own reverse proxy serves that form with the app's paths untouched and a loopback `Host` upstream, because the hub's page uses absolute asset paths and refuses non-localhost hosts; nothing in code-server or the container image is patched). On an older Hydrogen or on Web Hydrogen, which path-proxy ports as `/proxy/<port>/`, the panel is blank; there the dashboard's **Open Antigravity** turns the dashboard tab into that UI instead. The CLI `agy` works in a terminal tab everywhere.
adom_antigravity.py+97−19
@@ -16,12 +16,12 @@ container (the WSL2 Adom-Workspace on Windows, the Lima container on a Mac):     adom-antigravity open                # focus the Antigravity panel in the editor     adom-antigravity serve --print-url   # the dock dashboard (what a dock click runs) """-import argparse, glob, json, os, platform, shutil, subprocess, sys, time, urllib.request, urllib.error, urllib.parse+import argparse, fcntl, glob, json, os, platform, shutil, signal, subprocess, sys, time, urllib.request, urllib.error, urllib.parse from pathlib import Path from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer  ROOT = Path(__file__).resolve().parent-VERSION = '0.1.5'+VERSION = '0.1.6' EXT_ID = 'Google.google-antigravity' CLI_INSTALL = 'https://antigravity.google/cli/install.sh' EXT_DIRS = [Path.home() / '.local/share/code-server/extensions', Path.home() / '.vscode-server/extensions']@@ -222,6 +222,10 @@ class Handler(SimpleHTTPRequestHandler):         b = r.read(); self.send_response(r.status); self.send_header('Content-Type', r.headers.get('Content-Type', 'application/json')); self.send_header('Content-Length', str(len(b))); self.end_headers(); self.wfile.write(b)      def do_GET(self):+        if self.path == '/api/health':+            # Dock readiness must not wait for agy or editor port discovery.+            return self.json({'app': 'adom/antigravity', 'version': VERSION,+                              'pid': os.getpid(), 'state': 'healthy'})         if self.path in ['/api/status', '/state']:             return self.json(context())         if self.path == '/events':@@ -269,6 +273,93 @@ class Handler(SimpleHTTPRequestHandler):         return self.send_error(404)  +def server_identity(url):+    """Cheap readiness probe; allow older servers time for their full diagnostics."""+    try:+        return json.load(urllib.request.urlopen(url + 'api/health', timeout=1))+    except urllib.error.HTTPError as e:+        if e.code != 404:+            raise+    # 0.1.5's status can take 8s for agy, 4.5s for editor ports, and 1.5s+    # for the hub. A short timeout falsely declares that live server dead.+    return json.load(urllib.request.urlopen(url + 'api/status', timeout=20))+++def retire_server(port):+    """Retire only our same-user Python server owning this loopback listener."""+    inodes = set()+    for line in Path('/proc/net/tcp').read_text().splitlines()[1:]:+        fields = line.split()+        if fields[1] == f'0100007F:{port:04X}' and fields[3] == '0A':+            inodes.add(fields[9])+    owners = []+    for proc in Path('/proc').iterdir():+        if not proc.name.isdigit():+            continue+        try:+            if proc.stat().st_uid != os.getuid():+                continue+            links = {os.readlink(fd) for fd in (proc / 'fd').iterdir()}+            if not any(f'socket:[{inode}]' in links for inode in inodes):+                continue+            argv = (proc / 'cmdline').read_bytes().rstrip(b'\0').split(b'\0')+            if (len(argv) != 5 or Path(os.fsdecode(argv[1])).name != Path(__file__).name+                    or argv[2:] != [b'_serve', b'--port', str(port).encode()]):+                raise RuntimeError('Refusing to stop an unrecognized port owner')+            owners.append(int(proc.name))+        except (FileNotFoundError, PermissionError, ProcessLookupError):+            continue+    if len(owners) != 1:+        raise RuntimeError('Cannot identify exactly one Antigravity server owning the port')+    pid = owners[0]+    # A pidfd binds the signal to this process, even if Linux later reuses its PID.+    with os.fdopen(os.pidfd_open(pid), 'rb') as process_handle:+        argv = Path(f'/proc/{pid}/cmdline').read_bytes().rstrip(b'\0').split(b'\0')+        if (len(argv) != 5 or Path(os.fsdecode(argv[1])).name != Path(__file__).name+                or argv[2:] != [b'_serve', b'--port', str(port).encode()]):+            raise RuntimeError('Port owner changed before retirement')+        signal.pidfd_send_signal(process_handle.fileno(), signal.SIGTERM)+    for _ in range(50):+        try:+            alive = bool(Path(f'/proc/{pid}/cmdline').read_bytes())+        except FileNotFoundError:+            alive = False+        if not alive:+            return+        time.sleep(.1)+    raise RuntimeError('Previous dashboard did not stop; refusing to force-kill it')+++def ensure_server(port, log):+    url = f'http://127.0.0.1:{port}/'+    try:+        running = server_identity(url)+    except (OSError, ValueError):+        running = None+    if running:+        if running.get('app') != 'adom/antigravity':+            raise RuntimeError('Port is occupied by a different service')+        if running.get('version') == VERSION:+            return url+        old_version = tuple(int(v) for v in running['version'].split('.'))+        if old_version > tuple(int(v) for v in VERSION.split('.')):+            raise RuntimeError('Refusing to replace a newer Antigravity dashboard')+        retire_server(port)+    with (log / 'dashboard.log').open('ab') as out:+        process = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '_serve', '--port', str(port)], stdin=subprocess.DEVNULL, stdout=out, stderr=out, start_new_session=True)+    for _ in range(50):+        if process.poll() is not None:+            raise RuntimeError('Dashboard process exited; inspect ' + str(log / 'dashboard.log'))+        try:+            ready = server_identity(url)+            if ready.get('app') == 'adom/antigravity' and ready.get('version') == VERSION:+                return url+        except (OSError, ValueError):+            pass+        time.sleep(.1)+    raise RuntimeError('Dashboard failed to start; inspect ' + str(log / 'dashboard.log'))++ def main():     p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)     p.add_argument('command', choices=['status', 'context', 'install-extension', 'install-cli', 'open', 'serve', '_serve'])@@ -284,23 +375,10 @@ def main():         r = open_panel(a.new); print(json.dumps(r, indent=2)); sys.exit(0 if r.get('ok') else 1)     if a.command == '_serve':         ThreadingHTTPServer(('127.0.0.1', a.port), Handler).serve_forever(); return-    url = f'http://127.0.0.1:{a.port}/'-    try:-        r = json.load(urllib.request.urlopen(url + 'api/status', timeout=1)); assert r.get('app') == 'adom/antigravity' and r['version'] == VERSION-    except Exception:-        log = Path.home() / '.local/state/adom/antigravity'; log.mkdir(parents=True, exist_ok=True)-        with (log / 'dashboard.log').open('ab') as out:-            subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '_serve', '--port', str(a.port)], stdout=out, stderr=out, start_new_session=True)-        for _ in range(50):-            try:-                ready = json.load(urllib.request.urlopen(url + 'api/status', timeout=.5))-                if ready.get('app') != 'adom/antigravity' or ready.get('version') != VERSION:-                    raise RuntimeError('Port is occupied by a different service')-                break-            except Exception:-                time.sleep(.1)-        else:-            raise RuntimeError('Dashboard failed to start; inspect ' + str(log / 'dashboard.log'))+    log = Path.home() / '.local/state/adom/antigravity'; log.mkdir(parents=True, exist_ok=True)+    with (log / f'launch-{a.port}.lock').open('a') as lock:+        fcntl.flock(lock, fcntl.LOCK_EX)+        url = ensure_server(a.port, log)     print(url, flush=True)  
dockbar.json+2−3
@@ -1,15 +1,14 @@ {   "dock_manifest": "1.2.0",   "webview": { "title": "Antigravity", "favicon": "dashboard/icon.svg" },-  "health": { "url": "/api/status", "poll_seconds": 15 },+  "health": { "url": "/api/health", "poll_seconds": 15 },   "launch": {     "mode": "container_command",     "console": "show",     "steps": [       {         "label": "Installing the Antigravity dashboard",-        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg install adom/antigravity'",-        "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v adom-antigravity'",+        "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg update antigravity || adom-wiki pkg install adom/antigravity'",         "timeout_seconds": 180       },       {
docs/DASHBOARD.md+2−2
@@ -1,9 +1,9 @@ # Antigravity dock dashboard -`dockbar.json` makes this wiki app launchable from Hydrogen's Dock Bar. Its install step resolves the app package (`adom-wiki pkg install adom/antigravity`); its launch step runs `adom-antigravity serve --print-url`, which starts (or reuses) the dashboard on 127.0.0.1:47867 inside the container and prints the URL the dock navigates to.+`dockbar.json` makes this wiki app launchable from Hydrogen's Dock Bar. Its install step checks for package updates with an install fallback; its launch step runs `adom-antigravity serve --print-url`, which starts (or reuses) the dashboard on 127.0.0.1:47867 inside the container and prints the URL the dock navigates to. Launches are serialized per port. An older dashboard is retired only after checking its application identity, loopback socket ownership, user, and process arguments; a newer version is never downgraded.  The dashboard follows the dock-dashboard contract: house header, the shared `AdomNewbiePanel` say strip, an LED pill row (Extension, CLI, Editor, Runtime), then three action cards: install the Google Antigravity VS Code extension into code-server, install the `agy` CLI into ~/.local/bin, open the Antigravity panel or a terminal running `agy`. -Server routes: `GET /api/status` (`/state`), `GET /events`, `POST /api/install-extension`, `POST /api/install-cli`, `POST /api/open {new?}`, `POST /api/reload-editor`, `POST /api/tui`, plus the `/ai-threads/run-prompt` proxy the say strip uses. The browser exposes `adomAntigravity.state / refresh / inspect / selfCheck`.+Server routes: `GET /api/health` (fast server readiness with version and PID), `GET /api/status` (`/state`, full installation diagnostics), `GET /events`, `POST /api/install-extension`, `POST /api/install-cli`, `POST /api/open {new?}`, `POST /api/reload-editor`, `POST /api/tui`, plus the `/ai-threads/run-prompt` proxy the say strip uses. The browser exposes `adomAntigravity.state / refresh / inspect / selfCheck`.  Everything runs inside the container (WSL2 on Windows, Lima on macOS). Nothing is installed on the host OS.
package.json+1−1
@@ -4,7 +4,7 @@   "type": "app",   "title": "Google Antigravity",   "brief": "Google Antigravity in Adom Hydrogen: the editor extension, the agy CLI and a dock dashboard that installs both into your container.",-  "version": "0.1.5",+  "version": "0.1.6",   "description": "Try Google Antigravity inside your Adom workspace. The dock dashboard installs the Google Antigravity VS Code extension into Hydrogen's editor and the agy command line into your WSL2 or Lima container, opens the panel, and reports what is installed and signed in.",   "org": "adom",   "needs_sudo": false,
page.json+2−2
@@ -4,7 +4,7 @@   "type": "app",   "title": "Google Antigravity",   "brief": "Google Antigravity in Adom Hydrogen: the editor extension, the agy CLI and a dock dashboard that installs both into your container.",-  "version": "0.1.5",+  "version": "0.1.6",   "hero": {     "type": "image",     "path": "screenshots/hero.png"@@ -83,4 +83,4 @@     "README.md",     "dockbar.json"   ]-}\ No newline at end of file+}
test_startup.pyadded+150
@@ -0,0 +1,150 @@+"""Launch contract regressions: slow diagnostics must not break the dock."""+import json+import os+from pathlib import Path+import signal+import socket+import subprocess+import sys+import tempfile+import threading+import time+import unittest+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer+from unittest.mock import patch++import adom_antigravity as app+++class LegacyHandler(BaseHTTPRequestHandler):+    def log_message(self, *args):+        pass++    def do_GET(self):+        if self.path != '/api/status':+            self.send_error(404)+            return+        time.sleep(1.6)  # ConfRoomROG; longer than both original startup budgets.+        body = json.dumps({'app': 'adom/antigravity', 'version': app.VERSION}).encode()+        self.send_response(200)+        self.send_header('Content-Length', str(len(body)))+        self.end_headers()+        self.wfile.write(body)+++class StartupTests(unittest.TestCase):+    @staticmethod+    def free_port():+        with socket.socket() as sock:+            sock.bind(('127.0.0.1', 0))+            return sock.getsockname()[1]++    def serve_fixture(self, handler):+        server = ThreadingHTTPServer(('127.0.0.1', 0), handler)+        thread = threading.Thread(target=server.serve_forever, daemon=True)+        thread.start()+        self.addCleanup(server.server_close)+        self.addCleanup(server.shutdown)+        return server.server_port++    def launch(self, port, home):+        return subprocess.run(+            [sys.executable, str(Path(app.__file__)), 'serve', '--print-url', '--port', str(port)],+            env={**os.environ, 'HOME': home}, capture_output=True, text=True, timeout=25)++    def test_legacy_slow_status_is_reused_without_spawning(self):+        port = self.serve_fixture(LegacyHandler)+        with tempfile.TemporaryDirectory() as home:+            result = self.launch(port, home)+            self.assertEqual(result.returncode, 0, result.stderr)+            self.assertEqual(result.stdout.strip(), f'http://127.0.0.1:{port}/')+            self.assertFalse((Path(home) / '.local/state/adom/antigravity/dashboard.log').exists())++    def test_health_does_not_run_external_diagnostics(self):+        port = self.serve_fixture(app.Handler)+        with patch.object(app, 'context', side_effect=AssertionError('diagnostics ran')):+            self.assertEqual(app.server_identity(f'http://127.0.0.1:{port}/')['pid'], os.getpid())++    def test_cold_start_then_relaunch_keeps_same_process(self):+        with socket.socket() as sock:+            sock.bind(('127.0.0.1', 0))+            port = sock.getsockname()[1]+        with tempfile.TemporaryDirectory() as home:+            first = self.launch(port, home)+            self.assertEqual(first.returncode, 0, first.stderr)+            url = first.stdout.strip()+            pid = app.server_identity(url)['pid']+            try:+                second = self.launch(port, home)+                self.assertEqual(second.returncode, 0, second.stderr)+                self.assertEqual(second.stdout.strip(), url)+                self.assertEqual(app.server_identity(url)['pid'], pid)+            finally:+                os.kill(pid, signal.SIGTERM)++    def test_other_service_is_not_terminated(self):+        class OtherHandler(LegacyHandler):+            def do_GET(self):+                body = b'{"app":"another-app","version":"1"}'+                self.send_response(200)+                self.send_header('Content-Length', str(len(body)))+                self.end_headers()+                self.wfile.write(body)+        port = self.serve_fixture(OtherHandler)+        with tempfile.TemporaryDirectory() as home:+            result = self.launch(port, home)+            self.assertNotEqual(result.returncode, 0)+            self.assertEqual(app.server_identity(f'http://127.0.0.1:{port}/')['app'], 'another-app')++    @unittest.skipUnless(os.environ.get('ANTIGRAVITY_PREDECESSOR'), 'requires a registry predecessor')+    def test_registry_predecessor_upgrade_is_scoped_to_port(self):+        self.check_upgrade(os.environ['ANTIGRAVITY_PREDECESSOR'])++    def test_upgrade_of_server_with_current_lifecycle(self):+        with tempfile.TemporaryDirectory() as directory:+            predecessor = Path(directory) / Path(app.__file__).name+            predecessor.write_text(Path(app.__file__).read_text().replace(+                "VERSION = '0.1.6'", "VERSION = '0.1.5'"))+            self.check_upgrade(str(predecessor))++    def check_upgrade(self, predecessor):+        with tempfile.TemporaryDirectory() as home:+            ports = [self.free_port(), self.free_port()]+            old = [subprocess.Popen(+                [sys.executable, predecessor, '_serve', '--port', str(port)],+                env={**os.environ, 'HOME': home}, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+                for port in ports]+            current_pid = None+            try:+                for index, port in enumerate(ports):+                    for _ in range(50):+                        self.assertIsNone(old[index].poll(), 'Predecessor process exited')+                        try:+                            state = app.server_identity(f'http://127.0.0.1:{port}/')+                            self.assertEqual(state['version'], '0.1.5')+                            break+                        except OSError:+                            time.sleep(.1)+                    else:+                        self.fail('Predecessor never started')+                result = self.launch(ports[0], home)+                self.assertEqual(result.returncode, 0, result.stderr)+                state = app.server_identity(result.stdout.strip())+                current_pid = state['pid']+                self.assertEqual(state['version'], app.VERSION)+                old[0].wait(timeout=5)+                self.assertIsNone(old[1].poll(), 'Another port was affected')+                again = self.launch(ports[0], home)+                self.assertEqual(again.returncode, 0, again.stderr)+                self.assertEqual(app.server_identity(again.stdout.strip())['pid'], current_pid)+            finally:+                if current_pid:+                    os.kill(current_pid, signal.SIGTERM)+                for process in old:+                    if process.poll() is None:+                        process.terminate()+                    process.wait(timeout=5)+++if __name__ == '__main__':+    unittest.main()