mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 02:07:45 +02:00
[eric] outputs: serve mode hands a built bundle out from the root of its own loopback server; a deep path under the serve route left every React Router app blank after a reload
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
aadcf45398
commit
35bafaa2f4
@@ -111,6 +111,8 @@ class AppRuntime:
|
||||
self.frontend_port: Optional[int] = None
|
||||
# Serve-mode (ENG-209): a fresh built bundle + no agent editing means NO process at all; the workspace serve route delivers frontend/dist and this flag makes ready/frontend_url say so.
|
||||
self.serve_static: bool = False
|
||||
# Serve mode's one process-less process: the loopback static server that hands the bundle out at `/`.
|
||||
self.p_bundle_server: Optional[object] = None
|
||||
# New-mode only: flips True once something is actually listening on frontend_port (we kick off a background poll task in p_start_new_mode). frontend_url returns null until this flips, so the preview pane doesn't try to navigate to an unbound port and show a "Site can't be reached" error mid-npm-install.
|
||||
self.p_frontend_ready: bool = False
|
||||
# Set when the bind poll gave up; the status payload carries it so the card can stop spinning honestly.
|
||||
@@ -193,10 +195,9 @@ class AppRuntime:
|
||||
def frontend_url(self) -> Optional[str]:
|
||||
# Gated on `_frontend_ready` (set by the background bind-poll task in p_start_new_mode) so the preview pane only switches over once Vite is actually accepting connections. Without this, the editor flashes a "Site can't be reached" error while `npm install` is running. Also gated on `running`: a vite that crashed or got orphaned still has _frontend_ready=True, and handing the webview that dead port is the ERR_FAILED you see on reopen. No live process, no URL. And gated on `not _suspended`: a SIGSTOP'd idle runtime is "running" (returncode is None) but frozen, so its port won't answer.
|
||||
if self.serve_static:
|
||||
# The existing workspace serve route: index injection + token rewrite apply, and the dist's relative assets resolve under the same path.
|
||||
from backend.auth import init_auth_token
|
||||
p_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
return f"http://127.0.0.1:{p_port}/api/outputs/workspace/{self.workspace_id}/serve/frontend/dist/index.html?token={init_auth_token()}"
|
||||
# The app at `/`, the same shape vite gives it; a deep path under the backend's serve route left every React Router app blank.
|
||||
p_server = self.p_bundle_server
|
||||
return getattr(p_server, "url", None) if p_server is not None else None
|
||||
if self.frontend_port and self.p_frontend_ready and self.running and not self.p_suspended:
|
||||
return f"http://127.0.0.1:{self.frontend_port}/"
|
||||
return None
|
||||
@@ -231,6 +232,7 @@ class AppRuntime:
|
||||
self.p_reset_terminal_log()
|
||||
# Every boot re-decides serve mode from scratch; a stale True from the previous boot would make ready/frontend_url claim a processless app is fine after a restart that exists to spawn one.
|
||||
self.serve_static = False
|
||||
self.p_stop_bundle_server()
|
||||
# Same for the bind-timeout verdict: a restart that then binds fine must not keep telling the card the boot failed.
|
||||
self.boot_failed = False
|
||||
if self.is_new_mode:
|
||||
@@ -260,9 +262,17 @@ class AppRuntime:
|
||||
if self.instance == 1 and not p_declares_backend:
|
||||
from backend.apps.outputs.static_serve import static_fresh, workspace_being_edited
|
||||
if static_fresh(self.workspace_path) and not workspace_being_edited(self.workspace_path):
|
||||
self.serve_static = True
|
||||
self.p_broadcast(LogLine("runtime", "[runtime] serving the built bundle (no dev server); editing an app boots vite automatically"))
|
||||
return True
|
||||
from backend.apps.outputs.static_serve import BundleServer
|
||||
p_server = BundleServer(os.path.join(self.workspace_path, "frontend", "dist"))
|
||||
try:
|
||||
p_server.start()
|
||||
except OSError:
|
||||
logger.exception("bundle server failed to bind for %s; booting vite instead", self.workspace_id)
|
||||
else:
|
||||
self.p_bundle_server = p_server
|
||||
self.serve_static = True
|
||||
self.p_broadcast(LogLine("runtime", f"[runtime] serving the built bundle at {p_server.url} (no dev server); editing an app boots vite automatically"))
|
||||
return True
|
||||
# Legacy workspaces (scaffolded pre-multi-instance) ignore the forced ports; self-heal their run.sh so a second instance stops colliding on the primary's ports.
|
||||
ensure_force_port_shim(self.workspace_path)
|
||||
env_path = os.path.join(self.workspace_path, ".env")
|
||||
@@ -495,8 +505,18 @@ class AppRuntime:
|
||||
env["npm_config_ignore_scripts"] = "true"
|
||||
return env
|
||||
|
||||
def p_stop_bundle_server(self) -> None:
|
||||
p_server, self.p_bundle_server = self.p_bundle_server, None
|
||||
if p_server is not None:
|
||||
try:
|
||||
p_server.stop() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
logger.exception("bundle server stop failed for %s", self.workspace_id)
|
||||
|
||||
async def stop(self) -> None:
|
||||
async with self.p_lock:
|
||||
# Serve mode has no process, so this has to run BEFORE the early return below or the server leaks.
|
||||
self.p_stop_bundle_server()
|
||||
if not self.process or self.process.returncode is not None:
|
||||
# Still cancel the bind poller in case stop() races a never-launched runtime; defensive no-op otherwise.
|
||||
if self.p_frontend_ready_task and not self.p_frontend_ready_task.done():
|
||||
|
||||
@@ -7,6 +7,9 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
@@ -14,6 +17,53 @@ from typeguard import typechecked
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class P_BundleHandler(SimpleHTTPRequestHandler):
|
||||
"""Static files out of the dist, with the SPA fallback every router app expects: a path that names
|
||||
no file (and has no extension) is the app's own route, so it gets index.html, not a 404."""
|
||||
|
||||
def send_head(self): # type: ignore[override]
|
||||
path = self.translate_path(self.path)
|
||||
if not os.path.exists(path) and "." not in os.path.basename(self.path.split("?", 1)[0]):
|
||||
self.path = "/"
|
||||
return super().send_head()
|
||||
|
||||
def log_message(self, format, *args): # type: ignore[override]
|
||||
return None
|
||||
|
||||
|
||||
class BundleServer:
|
||||
"""A loopback static server for one built bundle. Serve mode used to hand the app a deep path
|
||||
under the backend's authenticated serve route, and every React Router app matched no route there
|
||||
and rendered nothing (Haik's users, 2026-09-03: "frontend-only apps after reloading do not
|
||||
render"). Vite serves the app at `/`; so does this, so the app cannot tell the two modes apart."""
|
||||
|
||||
def __init__(self, dist_dir: str) -> None:
|
||||
self.dist_dir = dist_dir
|
||||
self.port: Optional[int] = None
|
||||
self.p_server: Optional[ThreadingHTTPServer] = None
|
||||
self.p_thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self) -> int:
|
||||
handler = partial(P_BundleHandler, directory=self.dist_dir)
|
||||
self.p_server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
self.p_server.daemon_threads = True
|
||||
self.port = int(self.p_server.server_address[1])
|
||||
self.p_thread = threading.Thread(target=self.p_server.serve_forever, name=f"bundle-server-{self.port}", daemon=True)
|
||||
self.p_thread.start()
|
||||
return self.port
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"http://127.0.0.1:{self.port}/"
|
||||
|
||||
def stop(self) -> None:
|
||||
server, self.p_server = self.p_server, None
|
||||
if server is not None:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
self.port = None
|
||||
|
||||
|
||||
@typechecked
|
||||
def dist_index(workspace_path: str) -> str:
|
||||
return os.path.join(workspace_path, "frontend", "dist", "index.html")
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Serve mode hands a built bundle out from the root of its own loopback server, the same URL shape
|
||||
vite gives the app. It used to serve index.html at a deep path under the backend's authenticated
|
||||
serve route, and a React Router app matched no route there and rendered nothing (Haik's users,
|
||||
2026-09-03: frontend-only apps went white after a reload; apps with a backend never enter serve mode)."""
|
||||
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from backend.apps.outputs.static_serve import BundleServer
|
||||
|
||||
|
||||
def p_dist(tmp_path):
|
||||
dist = tmp_path / "frontend" / "dist"
|
||||
(dist / "assets").mkdir(parents=True)
|
||||
(dist / "index.html").write_text('<!doctype html><div id="root"></div><script type="module" src="./assets/app.js"></script>')
|
||||
(dist / "assets" / "app.js").write_text("console.log('hi')")
|
||||
return str(dist)
|
||||
|
||||
|
||||
def p_get(url: str):
|
||||
with urllib.request.urlopen(url, timeout=5) as r:
|
||||
return r.status, r.headers.get("content-type", ""), r.read().decode()
|
||||
|
||||
|
||||
def test_the_app_lives_at_the_root_and_its_assets_resolve(tmp_path):
|
||||
server = BundleServer(p_dist(tmp_path))
|
||||
port = server.start()
|
||||
try:
|
||||
assert server.url == f"http://127.0.0.1:{port}/"
|
||||
status, ctype, body = p_get(server.url + "?_d=e30%3D&token=x")
|
||||
assert status == 200 and 'id="root"' in body
|
||||
status, ctype, body = p_get(server.url + "assets/app.js")
|
||||
assert status == 200 and "javascript" in ctype and "console.log" in body
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_a_router_path_with_no_file_gets_index_not_a_404(tmp_path):
|
||||
server = BundleServer(p_dist(tmp_path))
|
||||
server.start()
|
||||
try:
|
||||
status, _ctype, body = p_get(server.url + "settings/profile")
|
||||
assert status == 200 and 'id="root"' in body
|
||||
try:
|
||||
p_get(server.url + "assets/missing.js")
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 404
|
||||
else:
|
||||
raise AssertionError("a missing asset must 404, not become index.html")
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
def test_stop_frees_the_port(tmp_path):
|
||||
server = BundleServer(p_dist(tmp_path))
|
||||
server.start()
|
||||
url = server.url
|
||||
server.stop()
|
||||
try:
|
||||
urllib.request.urlopen(url, timeout=2)
|
||||
except urllib.error.URLError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("the bundle server kept serving after stop()")
|
||||
|
||||
|
||||
def test_the_runtime_owns_the_server_and_every_exit_from_serve_mode_stops_it():
|
||||
src = open(os.path.join(os.path.dirname(__file__), "..", "apps", "outputs", "runtime.py")).read()
|
||||
assert "self.p_bundle_server = p_server" in src and "self.serve_static = True" in src
|
||||
stop_i = src.index(" async def stop(self) -> None:")
|
||||
assert "self.p_stop_bundle_server()" in src[stop_i: stop_i + 400], "stop() must drop the server before its no-process early return"
|
||||
start_i = src.index("self.serve_static = False\n self.p_stop_bundle_server()")
|
||||
assert start_i > 0, "start() must drop a previous server before re-deciding serve mode"
|
||||
assert "/serve/frontend/dist/index.html?token=" not in src, "the deep serve-route URL must not come back"
|
||||
Reference in New Issue
Block a user