← Commit history

Fix dashboard startup when editor discovery exceeds readiness timeout

John Lauer ·cf1d911a3e ·1mo ago ·parent a140889
3 files changed +117−4
adom_antigravity.py+21−3
@@ -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,18 @@ 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 main():     p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)     p.add_argument('command', choices=['status', 'context', 'install-extension', 'install-cli', 'open', 'serve', '_serve'])@@ -286,14 +302,16 @@ def main():         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+        r = server_identity(url); 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)+            process = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '_serve', '--port', str(a.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 = json.load(urllib.request.urlopen(url + 'api/status', timeout=.5))+                ready = server_identity(url)                 if ready.get('app') != 'adom/antigravity' or ready.get('version') != VERSION:                     raise RuntimeError('Port is occupied by a different service')                 break
dockbar.json+1−1
@@ -1,7 +1,7 @@ {   "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",
test_startup.pyadded+95
@@ -0,0 +1,95 @@+"""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):+    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').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')+++if __name__ == '__main__':+    unittest.main()