[eric] apps: the vite boot lock is loop-local here too, and the serve-static spawn test stops mocking a sync method with an async one

This commit is contained in:
ciregenz
2026-08-11 23:36:23 -07:00
parent 29f9e4401e
commit c73264fbdd
4 changed files with 51 additions and 13 deletions
+8 -6
View File
@@ -46,12 +46,13 @@ from backend.apps.outputs.runtime_proc import (
suspend_process_tree,
write_env_value,
)
from backend.config.loop_local import loop_local
from backend.config.paths import AUTH_TOKEN_FILE
logger = logging.getLogger(__name__)
# Module-level lock so only ONE vite optimizeDeps runs at a time; must be acquired before manager.p_lock to avoid deadlock with manager.attach.
p_vite_boot_lock = asyncio.Lock()
get_vite_boot_lock = loop_local(asyncio.Lock)
# How often the manager stats each attached workspace for a restart sentinel; one stat/sec/runtime is noise.
RESTART_SENTINEL_POLL_SECONDS = 1.0
@@ -217,7 +218,7 @@ class AppRuntime:
exists so the Terminal pane can host `[FRONTEND]` lines.
New-mode spawns are serialized through the module-level
`p_vite_boot_lock` (see comment at the lock declaration) so a
`the vite boot lock` (see comment at the lock declaration) so a
burst of "create 3 apps in 5 seconds" doesn't trigger 3 parallel
MUI pre-bundle runs each pegging a core.
"""
@@ -228,7 +229,8 @@ class AppRuntime:
self.p_reset_terminal_log()
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.
await p_vite_boot_lock.acquire()
p_boot_lock = get_vite_boot_lock()
await p_boot_lock.acquire()
# Releasing is the DEFAULT; only an actual spawn hands the lock to its bind-poll task.
# The old shape released on `not ok`, so the serve-static branch (which returns True
# without spawning) held the global lock forever and every later app blocked on the
@@ -239,7 +241,7 @@ class AppRuntime:
return await self.p_start_new_mode()
finally:
if not self.p_boot_lock_handed_off:
p_vite_boot_lock.release()
p_boot_lock.release()
return await self.p_start_old_mode()
async def p_start_new_mode(self) -> bool:
@@ -370,7 +372,7 @@ class AppRuntime:
shows the transition; flips `_frontend_ready` which the
`frontend_url` property reads.
Also responsible for releasing the module-level `p_vite_boot_lock`
Also responsible for releasing the module-level `the vite boot lock`
; every exit path (success, process death, hard timeout) MUST
release exactly once so the next queued workspace can start its
own vite spawn. A try/finally on the lock guarantees that even
@@ -384,7 +386,7 @@ class AppRuntime:
return
lock_released = True
try:
p_vite_boot_lock.release()
get_vite_boot_lock().release()
except RuntimeError:
# Lock already released (e.g. start() failure path released synchronously before spawning the poll task).
pass
+39
View File
@@ -0,0 +1,39 @@
"""One asyncio primitive per event loop, so module-level state can never outlive the loop it used."""
import asyncio
from typing import Callable, Optional, TypeVar
T = TypeVar("T")
def loop_local(factory: Callable[[], T]) -> Callable[[], T]:
"""Wrap an asyncio primitive so it is rebuilt whenever the running event loop changes.
A module-level ``asyncio.Lock()`` outlives the loop that used it. If that loop dies while the
lock is HELD, the flag stays set forever and the next loop waits on a release that can never
come: no error, no log line, just a process that stops. That is what wedged the entire backend
test suite (ENG-219), and after a ``uvicorn --reload`` it is the same silent hang in the app.
A Semaphore loses its count the same way; an Event raises "bound to a different event loop"
and kills whatever loop was driving it.
Pass the CLASS, not an instance, and call the result::
p_boot_lock = loop_local(asyncio.Lock)
async with p_boot_lock():
...
Needs a running loop, which is the whole point: nothing else can say which loop to build for.
"""
held: Optional[T] = None
held_loop: Optional[asyncio.AbstractEventLoop] = None
def get() -> T:
nonlocal held, held_loop
running = asyncio.get_running_loop()
if held is None or held_loop is not running:
held = factory()
held_loop = running
return held
return get
+3 -1
View File
@@ -50,7 +50,9 @@ def test_start_skips_serve_when_edited(tmp_path, monkeypatch):
rt = AppRuntime("ws-t2", ws)
spawned = {"n": 0}
async def p_no_spawn(env):
# Sync on purpose: p_resolve_launch became a plain method, and an async mock here returns a
# never-awaited coroutine whose body (the counter) never runs, failing the assert at 0.
def p_no_spawn(env):
spawned["n"] += 1
return None, ws, "stub"
monkeypatch.setattr(rt, "p_resolve_launch", p_no_spawn)
@@ -11,12 +11,7 @@ each non-spawning exit, which is the invariant, rather than asserting the shape
import asyncio
from typing import Any, List
import pytest
from backend.apps.outputs import runtime as p_runtime
from backend.apps.outputs.runtime import AppRuntime
def get_vite_boot_lock():
return p_runtime.p_vite_boot_lock
from backend.apps.outputs.runtime import AppRuntime, get_vite_boot_lock
def p_make_runtime(tmp_path: Any, name: str = "ws") -> AppRuntime: