Proper error messages on npm and port check failures, avoid leaking asyncio tasks.

This commit is contained in:
2026-09-12 05:50:52 +00:00
parent 4c0be1bfa7
commit 02b3890cd3
3 changed files with 20 additions and 16 deletions
+3 -1
View File
@@ -1212,7 +1212,9 @@ def ensure_python_project(project_dir: Path, *, dry: bool = False) -> bool:
return True return True
def ensure_frontend(project_dir: Path, *, vue_args: list[str] | None = None, dry: bool = False) -> bool: def ensure_frontend(
project_dir: Path, *, vue_args: list[str] | None = None, dry: bool = False
) -> bool:
"""Ensure frontend directory exists with a Vue project, run create-vue if needed.""" """Ensure frontend directory exists with a Vue project, run create-vue if needed."""
frontend_dir = project_dir / "frontend" frontend_dir = project_dir / "frontend"
package_json = frontend_dir / "package.json" package_json = frontend_dir / "package.json"
+4 -4
View File
@@ -41,7 +41,6 @@ async def run_devserver(
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT) viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
backurl, MODULE_NAME = setup_cli("PROJECT_CLI", backend, DEFAULT_DEV_PORT) backurl, MODULE_NAME = setup_cli("PROJECT_CLI", backend, DEFAULT_DEV_PORT)
await check_ports_free(viteurl, backurl)
# Tell everyone via environment (vite proxy and backend devmode use these) # Tell everyone via environment (vite proxy and backend devmode use these)
os.environ["ENVPREFIX_VITE_URL"] = viteurl os.environ["ENVPREFIX_VITE_URL"] = viteurl
@@ -49,6 +48,7 @@ async def run_devserver(
os.environ["ENVPREFIX_DEV"] = "1" os.environ["ENVPREFIX_DEV"] = "1"
async with ProcessGroup() as pg: async with ProcessGroup() as pg:
pg.create_task(check_ports_free(viteurl, backurl))
npm_i = await pg.spawn(*npm_install, cwd=front) npm_i = await pg.spawn(*npm_install, cwd=front)
await pg.spawn(*MODULE_NAME, *(extra_args or []), vital=True) await pg.spawn(*MODULE_NAME, *(extra_args or []), vital=True)
await pg.wait(npm_i, ready(backurl, path=HEALTH)) await pg.wait(npm_i, ready(backurl, path=HEALTH))
@@ -78,9 +78,9 @@ def main() -> None:
try: try:
asyncio.run(run_devserver(args.listen, args.backend, extra_args)) asyncio.run(run_devserver(args.listen, args.backend, extra_args))
except* KeyboardInterrupt: except* KeyboardInterrupt:
pass # user stopped the devserver: exit 0 pass # user stopped the devserver: normal exit
except* subprocess.SubprocessError: except* (subprocess.SubprocessError, RuntimeError):
raise SystemExit(1) from None # error already logged; exit 1 raise SystemExit(1) from None # logged in devutil already; exit 1
HELP_EPILOG = """ HELP_EPILOG = """
+13 -11
View File
@@ -65,7 +65,9 @@ class ProcessGroup(asyncio.TaskGroup):
if not isinstance(w, Process): if not isinstance(w, Process):
return await w return await w
if retcode := await w.wait(): if retcode := await w.wait():
raise CalledProcessError(retcode, self._cmds[w]) cmd = self._cmds[w]
logger.warning("Process %s exited with status %d", Path(cmd[0]).stem, retcode)
raise CalledProcessError(retcode, cmd)
return retcode return retcode
async with asyncio.TaskGroup() as group: async with asyncio.TaskGroup() as group:
@@ -104,22 +106,23 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
async def check_ports_free(*urls: str) -> None: async def check_ports_free(*urls: str) -> None:
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond.""" """Verify URLs are not responding (ports are free).
async def check(url: str) -> None: Meant to run as a task inside a TaskGroup. Logs the conflict and raises
server = await http_get_server(url, timeout=0.1) RuntimeError (handled like a failed process) if any URL responds.
"""
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
for url, server in zip(urls, servers, strict=True):
if server is not None: if server is not None:
logger.warning("Conflicting %s already running at %s", server or "server", url) logger.error("Conflicting %s already running at %s", server or "server", url)
raise SystemExit(1) raise RuntimeError(url)
await asyncio.gather(*[check(url) for url in urls])
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None: async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
"""Wait for the server to be ready by polling an endpoint. """Wait for the server to be ready by polling an endpoint.
Use empty path to disable the check and make this return immediately. Use empty path to disable the check and make this return immediately.
Raises SystemExit(1) if server doesn't start in time. Raises TimeoutError if server doesn't start in time.
""" """
if not path: if not path:
return return
@@ -129,8 +132,7 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
logger.info("✓ Backend ready!") logger.info("✓ Backend ready!")
return return
if attempt == max_attempts - 1: if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time") raise TimeoutError(f"Backend at {url} didn't start in time") # noqa: EM102, TRY003
raise SystemExit(1)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)