From 3a56b99b0b77beaf7133c6bddca010d964e7c1b1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 16 Aug 2026 11:20:28 -0700 Subject: [PATCH] [eric] outputs: serve-static apps' WS status frame no longer nulls frontend_url on a process that never exists; the Starting-preview wedge for built apps --- backend/apps/help/changelog.py | 1 + backend/main.py | 3 ++- .../tests/test_serve_static_status_frame.py | 26 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_serve_static_status_frame.py diff --git a/backend/apps/help/changelog.py b/backend/apps/help/changelog.py index 8e6309dd..f93cf7ec 100644 --- a/backend/apps/help/changelog.py +++ b/backend/apps/help/changelog.py @@ -92,6 +92,7 @@ P_RELEASES: List[ReleaseNote] = [ "Workflow run history shows each run's workflow name instead of the word \"Workflow\" on every row. The name now travels with the run, so it survives renames and deleted workflows.", "An expired provider login heals itself mid-chat: the first failure rebuilds the connection and retries your message with zero clicks, and only a second failure asks you to reconnect. It used to take six manual steps every time a token aged out.", "A newly connected ChatGPT or Gemini subscription works immediately. The routing layer restarts itself the moment a connect completes, so new subscriptions no longer sit dead behind rate-limit errors until you restart the app.", + "Finished apps opened from the dock no longer sit on \"Starting preview\" forever. Apps served straight from their built files have no server process by design, and the preview was waiting for one that would never exist.", "Heavy sessions no longer vanish without a trace. When memory climbs past the safe line the app now sheds weight itself: preview thumbnails pause and refetchable caches drop, instead of growing until the operating system kills it mid-task.", ], ), diff --git a/backend/main.py b/backend/main.py index f39a2ffa..6826eec4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -312,7 +312,8 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str, instan "port": rt.port, "backend_url": f"http://127.0.0.1:{rt.port}" if rt.running and rt.port else None, "frontend_port": rt.frontend_port, - "frontend_url": rt.frontend_url if rt.running else None, + # The property is the one honest gate (crashed/suspended vite -> None, static -> serve URL); a duplicate running check here nulled every serve-static app's URL, whose card then waited forever on a process that never exists (the "Starting preview" wedge). + "frontend_url": rt.frontend_url, "is_new_mode": rt.is_new_mode, }, } diff --git a/backend/tests/test_serve_static_status_frame.py b/backend/tests/test_serve_static_status_frame.py new file mode 100644 index 00000000..ac90707b --- /dev/null +++ b/backend/tests/test_serve_static_status_frame.py @@ -0,0 +1,26 @@ +"""A serve-static app's card sat on "Starting preview" forever (found live 2026-08-16 on packaged +exp.9: runtime HTTP status said ready and its URL served 200/10.8KB, while the runtime-logs WS +status frame carried frontend_url null). Mechanism: the WS frame re-gated frontend_url on +rt.running, but serve-static runtimes have NO process by design, so running is False forever, and +with no process there are no log lines to trigger a status re-push: structurally stuck. The +frontend_url property is the one honest gate (crashed/suspended vite -> None, static -> serve +URL); these pin that the WS frame trusts it. +""" +import re + + +def test_ws_status_frame_uses_the_property_not_a_running_gate(): + src = open("backend/main.py").read() + frame = src[src.index("def p_build_status_frame"):src.index("Initial status frame")] + assert '"frontend_url": rt.frontend_url,' in frame, "the property is the single source of truth" + assert "frontend_url if rt.running" not in frame, "the duplicate running gate is the Starting-preview wedge" + + +def test_property_still_refuses_dead_and_frozen_vite(): + # Both directions: dropping the WS gate must not resurrect the dead-port ERR_FAILED class the + # gate was imitating; the property's own conditions are what protect that. + src = open("backend/apps/outputs/runtime.py").read() + prop = src[src.index("def frontend_url"):src.index("async def start")] + assert re.search(r"self\.frontend_port and self\.p_frontend_ready and self\.running and not self\.p_suspended", prop), \ + "vite URLs must still require a live, unfrozen process" + assert "serve_static" in prop, "static apps get their serve URL with no process at all"