Añade launchers de macOS/Linux con un click y hace runtime_control cross-platform
Equivalentes .command (doble-clic en Finder) de los .bat de Windows: - setup_mac.command: bootstrap con un click (detecta Python 3.10+, crea .venv, instala requirements + Chromium de Playwright, copia .env.example -> .env). - start/stop/restart/start_persistent_profile.command: espejo de los .bat, lanzan el server con nohup usando el python del .venv. - mp_common.sh: helper compartido (raíz, venv, banners). runtime_control.py ahora es cross-platform (IS_WINDOWS): lsof/ps/pgrep/kill en POSIX, netstat/PowerShell/taskkill en Windows. _kill_tree_posix mata el árbol padre+worker de uvicorn con SIGTERM. .venv/ añadido a .gitignore. Docs actualizadas (CLAUDE.md, AGENTS.md, PLAYWRIGHT_SESSION.md). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+118
-25
@@ -7,9 +7,10 @@ Centraliza la lógica de:
|
||||
* apagar el servidor de forma segura (sin matar procesos ajenos),
|
||||
* esperar a que el server termine de arrancar (server_info.json escrito).
|
||||
|
||||
Pensado para que los batch files (`start.bat`, `stop.bat`, `restart.bat`) lo
|
||||
invoquen como CLI sin tener que hacer parsing de JSON o validaciones complejas
|
||||
en lenguaje batch.
|
||||
Pensado para que los launchers (Windows: `start.bat`/`stop.bat`/`restart.bat`;
|
||||
macOS/Linux: `start.command`/`stop.command`/`restart.command`) lo invoquen como
|
||||
CLI sin tener que hacer parsing de JSON o validaciones complejas en shell. La
|
||||
lógica de OS (puertos, PIDs, kill) es cross-platform: ver `IS_WINDOWS`.
|
||||
|
||||
Uso CLI:
|
||||
python runtime_control.py status # imprime estado actual
|
||||
@@ -22,6 +23,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -35,6 +37,11 @@ import paths
|
||||
# proyecto Python). Lo buscamos en la línea de comando del proceso.
|
||||
PROJECT_MARKER = os.path.normcase(paths.BASE_DIR)
|
||||
|
||||
# Las primitivas de OS (descubrir quién ocupa un puerto, leer la línea de comando
|
||||
# de un PID, matarlo) difieren entre Windows y POSIX (macOS/Linux). El resto del
|
||||
# módulo es agnóstico: dispatcha según esta bandera.
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
|
||||
|
||||
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
@@ -47,6 +54,12 @@ def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
||||
|
||||
def get_pid_listening_on(port: int) -> Optional[int]:
|
||||
"""Devuelve el PID que escucha en `port` (LISTENING), o None si nadie."""
|
||||
if IS_WINDOWS:
|
||||
return _get_pid_listening_on_windows(port)
|
||||
return _get_pid_listening_on_posix(port)
|
||||
|
||||
|
||||
def _get_pid_listening_on_windows(port: int) -> Optional[int]:
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["netstat", "-ano"], text=True, stderr=subprocess.DEVNULL
|
||||
@@ -65,6 +78,27 @@ def get_pid_listening_on(port: int) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _get_pid_listening_on_posix(port: int) -> Optional[int]:
|
||||
"""macOS/Linux: `lsof` lista el/los PID que escuchan en el puerto.
|
||||
|
||||
`-sTCP:LISTEN` acota a sockets en estado LISTEN, `-t` devuelve solo PIDs.
|
||||
Si hay varios (p.ej. el reloader de uvicorn y su worker comparten socket),
|
||||
devolvemos el primero; `_kill_tree` se encarga del árbol completo.
|
||||
"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
for line in out.split():
|
||||
if line.strip().isdigit():
|
||||
return int(line.strip())
|
||||
return None
|
||||
|
||||
|
||||
def _run_powershell(ps_command: str) -> Optional[str]:
|
||||
"""Ejecuta un comando PowerShell y devuelve stdout (stripped) o None si falla.
|
||||
|
||||
@@ -85,18 +119,43 @@ def _run_powershell(ps_command: str) -> Optional[str]:
|
||||
|
||||
|
||||
def get_process_cmdline(pid: int) -> Optional[str]:
|
||||
"""Devuelve la línea de comando de un PID en Windows, o None si no existe."""
|
||||
return _run_powershell(
|
||||
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).CommandLine"
|
||||
)
|
||||
"""Devuelve la línea de comando completa de un PID, o None si no existe."""
|
||||
if IS_WINDOWS:
|
||||
return _run_powershell(
|
||||
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).CommandLine"
|
||||
)
|
||||
# POSIX (macOS/Linux): `ps -o command=` imprime la línea de comando completa
|
||||
# sin encabezado. En macOS el límite por defecto es amplio; suficiente para
|
||||
# contener la ruta absoluta a main.py que es lo que buscamos.
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["ps", "-p", str(pid), "-o", "command="],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
return out.strip() or None
|
||||
|
||||
|
||||
def pid_is_alive(pid: int) -> bool:
|
||||
"""True si el PID existe. Más confiable que parsear tasklist."""
|
||||
result = _run_powershell(
|
||||
f"if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}"
|
||||
)
|
||||
return result == "yes"
|
||||
"""True si el PID existe."""
|
||||
if IS_WINDOWS:
|
||||
result = _run_powershell(
|
||||
f"if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}"
|
||||
)
|
||||
return result == "yes"
|
||||
# POSIX: señal 0 no mata; solo verifica existencia/permisos del proceso.
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
# Existe pero es de otro usuario; para nuestros fines, está vivo.
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def pid_belongs_to_mp_manager(pid: int) -> bool:
|
||||
@@ -106,7 +165,7 @@ def pid_belongs_to_mp_manager(pid: int) -> bool:
|
||||
registró este PID. Como ese archivo SOLO lo escribe `main.py` de este proyecto,
|
||||
el match es prueba inequívoca de identidad.
|
||||
|
||||
Fuente secundaria (cmdline): si los .bat lanzan `python "<ruta absoluta>/main.py"`,
|
||||
Fuente secundaria (cmdline): si los launchers lanzan `python "<ruta absoluta>/main.py"`,
|
||||
el path del proyecto queda embebido en la línea de comando del proceso y podemos
|
||||
reconocerlo aunque el server_info.json esté ausente o stale.
|
||||
"""
|
||||
@@ -130,11 +189,16 @@ def get_process_cwd(pid: int) -> Optional[str]:
|
||||
informativo es la línea de comando completa, así que esta función es
|
||||
secundaria.
|
||||
"""
|
||||
out = _run_powershell(
|
||||
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).ExecutablePath"
|
||||
)
|
||||
if out:
|
||||
return os.path.dirname(out)
|
||||
if IS_WINDOWS:
|
||||
out = _run_powershell(
|
||||
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).ExecutablePath"
|
||||
)
|
||||
if out:
|
||||
return os.path.dirname(out)
|
||||
return None
|
||||
# POSIX: no hay un cwd fiable sin /proc (Linux) y macOS no lo expone vía ps.
|
||||
# La línea de comando completa es la pista útil para nosotros, así que esta
|
||||
# función queda como best-effort y devuelve None.
|
||||
return None
|
||||
|
||||
|
||||
@@ -239,11 +303,40 @@ def stop_server(force: bool = False) -> int:
|
||||
|
||||
|
||||
def _kill_tree(pid: int) -> None:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if IS_WINDOWS:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return
|
||||
_kill_tree_posix(pid)
|
||||
|
||||
|
||||
def _kill_tree_posix(pid: int) -> None:
|
||||
"""Mata recursivamente el proceso y sus hijos en macOS/Linux.
|
||||
|
||||
uvicorn arranca con `reload=True`, lo que crea un proceso reloader padre y un
|
||||
worker hijo. El PID registrado en server_info.json es el del padre, así que
|
||||
hay que descender por el árbol (`pgrep -P`) para no dejar al worker zombie
|
||||
aferrado al puerto. Enviamos SIGTERM (apagado limpio); el padre propaga la
|
||||
señal a uvicorn que cierra el socket ordenadamente.
|
||||
"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["pgrep", "-P", str(pid)], text=True, stderr=subprocess.DEVNULL
|
||||
)
|
||||
children = [int(c) for c in out.split() if c.strip().isdigit()]
|
||||
except Exception:
|
||||
children = []
|
||||
|
||||
for child in children:
|
||||
_kill_tree_posix(child)
|
||||
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def wait_ready(timeout_sec: float = 30.0) -> int:
|
||||
@@ -271,14 +364,14 @@ def preflight_check() -> int:
|
||||
if info and info.get("pid") and pid_is_alive(int(info["pid"])):
|
||||
port = info.get("port", 8000)
|
||||
print(f"[INFO] MP Manager ya esta corriendo en puerto {port} (PID {info['pid']}).")
|
||||
print(f" Abre http://127.0.0.1:{port}/ o ejecuta stop.bat primero.")
|
||||
print(f" Abre http://127.0.0.1:{port}/ o detén primero (stop.bat / stop.command).")
|
||||
return 1
|
||||
|
||||
port_pid = get_pid_listening_on(8000)
|
||||
if port_pid:
|
||||
if pid_belongs_to_mp_manager(port_pid):
|
||||
print(f"[INFO] Hay un MP Manager huerfano en puerto 8000 (PID {port_pid}).")
|
||||
print(f" Ejecuta stop.bat o usa: python runtime_control.py stop --force")
|
||||
print(f" Detén (stop.bat / stop.command) o usa: python runtime_control.py stop --force")
|
||||
return 1
|
||||
cmd = get_process_cmdline(port_pid) or "(desconocido)"
|
||||
print(f"[ADVERTENCIA] Puerto 8000 ocupado por OTRO proyecto (PID {port_pid}):")
|
||||
|
||||
Reference in New Issue
Block a user