From f7472a36af77f675d74626e05b4dcdbd56594a66 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 2 Sep 2026 08:25:54 -0700 Subject: [PATCH] [eric] outputs: a restart that binds clears the previous bind-timeout verdict instead of reporting boot_failed forever Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R --- backend/apps/outputs/runtime.py | 2 ++ .../tests/test_runtime_boot_failed_resets.py | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 backend/tests/test_runtime_boot_failed_resets.py diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index 5cd042f4..0960878f 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -231,6 +231,8 @@ 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 + # 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: # Acquire the module-level boot lock BEFORE the spawn so only one new-mode workspace is mid-bundle at a time. The lock is released by the bind-poll task the moment vite emits "frontend ready" (or its 180s timeout fires), which is the moment the next workspace can start its own vite without competing for the same CPU. See `p_await_frontend_bind` for the release. p_boot_lock = get_vite_boot_lock() diff --git a/backend/tests/test_runtime_boot_failed_resets.py b/backend/tests/test_runtime_boot_failed_resets.py new file mode 100644 index 00000000..9f5dc7c3 --- /dev/null +++ b/backend/tests/test_runtime_boot_failed_resets.py @@ -0,0 +1,36 @@ +"""A bind timeout stamps boot_failed so the card stops spinning, but the flag lived on the runtime +object forever: after one 180s timeout every later restart of the same runtime kept reporting +boot_failed=True even while ready=True (seen live on an app card, 2026-09-02). start() now clears it.""" + +import pytest + +from backend.apps.outputs.runtime import AppRuntime + + +def p_rt(tmp_path) -> AppRuntime: + ws = tmp_path / "ws" + ws.mkdir(exist_ok=True) + (ws / "run.sh").write_text("#!/bin/bash\n") + return AppRuntime(workspace_id="ws-boot-failed", workspace_path=str(ws)) + + +@pytest.mark.asyncio +async def test_a_restart_that_boots_clears_the_previous_bind_timeout(tmp_path, monkeypatch): + rt = p_rt(tmp_path) + rt.boot_failed = True + + async def p_fake_spawn() -> bool: + return True + + monkeypatch.setattr(rt, "p_start_new_mode", p_fake_spawn) + assert await rt.start() is True + assert rt.boot_failed is False, "a boot that spawned fine must not keep telling the card the previous one failed" + + +@pytest.mark.asyncio +async def test_an_already_running_runtime_keeps_its_verdict(tmp_path, monkeypatch): + rt = p_rt(tmp_path) + rt.boot_failed = True + monkeypatch.setattr(type(rt), "running", property(lambda self: True)) + assert await rt.start() is True + assert rt.boot_failed is True, "start() on a live runtime is a no-op and must not rewrite state"