Primer commit

This commit is contained in:
2026-05-30 14:31:19 -06:00
commit a35d26fac0
277 changed files with 265240 additions and 0 deletions
+339
View File
@@ -0,0 +1,339 @@
"""Utilidades de control de runtime del servidor MP Manager.
Centraliza la lógica de:
* leer/validar `generated/runtime/server_info.json`,
* confirmar que un PID es realmente MP Manager (y no otro proyecto Python),
* detectar quién ocupa un puerto,
* 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.
Uso CLI:
python runtime_control.py status # imprime estado actual
python runtime_control.py stop # apaga MP Manager (sin tocar otros)
python runtime_control.py wait-ready # espera a que server_info.json aparezca, imprime el puerto
python runtime_control.py port-owner P # imprime quién ocupa el puerto P
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import time
from typing import Optional
import paths
# Indicador inequívoco de que un proceso es MP Manager (vs Transcriptor u otro
# proyecto Python). Lo buscamos en la línea de comando del proceso.
PROJECT_MARKER = os.path.normcase(paths.BASE_DIR)
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:
try:
s.bind((host, port))
return False
except OSError:
return True
def get_pid_listening_on(port: int) -> Optional[int]:
"""Devuelve el PID que escucha en `port` (LISTENING), o None si nadie."""
try:
out = subprocess.check_output(
["netstat", "-ano"], text=True, stderr=subprocess.DEVNULL
)
except Exception:
return None
needle = f":{port}"
for line in out.splitlines():
if needle in line and "LISTENING" in line:
parts = line.split()
if parts and parts[-1].isdigit():
# Asegurar que el match es del puerto exacto (no de un sufijo)
local = parts[1] if len(parts) >= 2 else ""
if local.endswith(needle):
return int(parts[-1])
return None
def _run_powershell(ps_command: str) -> Optional[str]:
"""Ejecuta un comando PowerShell y devuelve stdout (stripped) o None si falla.
Usamos PowerShell porque `wmic` ya no viene en Windows 11. `Get-CimInstance`
es el reemplazo oficial y siempre está disponible.
"""
try:
out = subprocess.check_output(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_command],
text=True,
stderr=subprocess.DEVNULL,
encoding="utf-8",
errors="replace",
)
except Exception:
return None
return out.strip() or None
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"
)
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"
def pid_belongs_to_mp_manager(pid: int) -> bool:
"""True si el PID es ciertamente MP Manager. Conservador: ante la duda, False.
Fuente primaria (más fuerte): `generated/runtime/server_info.json` del proyecto
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"`,
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.
"""
info = read_server_info()
if info and info.get("pid") == pid:
return True
cmd = get_process_cmdline(pid)
if not cmd:
return False
norm = os.path.normcase(cmd)
if PROJECT_MARKER in norm:
return True
return False
def get_process_cwd(pid: int) -> Optional[str]:
"""Best-effort: obtener el directorio del ejecutable del proceso.
Win32_Process no expone el cwd real; el ExecutablePath sirve como pista
cuando el ejecutable vive dentro del proyecto. Para nuestro caso, lo más
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)
return None
def read_server_info() -> Optional[dict]:
if not os.path.exists(paths.SERVER_INFO):
return None
try:
# utf-8-sig tolera BOM por si alguien escribe el archivo con PowerShell
# u otra herramienta que añada  al inicio.
with open(paths.SERVER_INFO, "r", encoding="utf-8-sig") as f:
return json.load(f)
except Exception:
return None
def clear_stale_server_info() -> bool:
"""Si server_info.json apunta a un PID muerto, lo borra. True si limpió."""
info = read_server_info()
if not info:
return False
pid = info.get("pid")
if pid and pid_is_alive(int(pid)):
return False
try:
os.remove(paths.SERVER_INFO)
return True
except OSError:
return False
def status() -> dict:
"""Estado actual: ¿hay MP Manager corriendo? ¿quién ocupa el puerto?"""
info = read_server_info()
result = {
"registered": info,
"registered_alive": False,
"registered_is_ours": False,
"port_8000_pid": None,
"port_8000_is_ours": False,
"port_8000_cmdline": None,
}
if info and info.get("pid"):
pid = int(info["pid"])
if pid_is_alive(pid):
result["registered_alive"] = True
result["registered_is_ours"] = pid_belongs_to_mp_manager(pid)
port_pid = get_pid_listening_on(8000)
if port_pid:
result["port_8000_pid"] = port_pid
result["port_8000_is_ours"] = pid_belongs_to_mp_manager(port_pid)
result["port_8000_cmdline"] = get_process_cmdline(port_pid)
return result
def stop_server(force: bool = False) -> int:
"""Apaga MP Manager si está corriendo. Devuelve exit code (0 = OK, 1 = no hay).
- Si server_info.json existe y el PID es MP Manager → mata el árbol.
- Si server_info.json existe pero el PID NO es MP Manager → limpia el archivo, no mata nada.
- Si server_info.json no existe → mira si el puerto 8000 lo tiene MP Manager (otra
instancia sin registro). Solo lo mata si pasa `force=True`.
"""
info = read_server_info()
killed_any = False
if info and info.get("pid"):
pid = int(info["pid"])
if pid_is_alive(pid):
if pid_belongs_to_mp_manager(pid):
_kill_tree(pid)
killed_any = True
print(f"[SISTEMA] Detenido MP Manager (PID {pid}, puerto {info.get('port')}).")
else:
cmd = get_process_cmdline(pid) or "(desconocido)"
print(f"[ADVERTENCIA] server_info apunta al PID {pid} pero NO es MP Manager:")
print(f" {cmd[:200]}")
print(f" No se mata. Limpiando server_info.json.")
else:
print(f"[SISTEMA] server_info apuntaba al PID {pid} pero ya estaba muerto. Limpiando registro.")
try:
os.remove(paths.SERVER_INFO)
except OSError:
pass
if killed_any:
return 0
# Sin registro: buscar instancias sin server_info.json (raro)
port_pid = get_pid_listening_on(8000)
if port_pid and pid_belongs_to_mp_manager(port_pid):
if force:
_kill_tree(port_pid)
print(f"[SISTEMA] Detenida instancia huérfana de MP Manager en puerto 8000 (PID {port_pid}).")
return 0
print(f"[SISTEMA] Hay un MP Manager huérfano en puerto 8000 (PID {port_pid}) sin server_info.json.")
print(f" Para apagarlo: python runtime_control.py stop --force")
return 1
print("[SISTEMA] No hay servidor MP Manager activo corriendo.")
return 1
def _kill_tree(pid: int) -> None:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def wait_ready(timeout_sec: float = 30.0) -> int:
"""Espera a que server_info.json aparezca con un PID vivo. Imprime el puerto."""
deadline = time.time() + timeout_sec
while time.time() < deadline:
info = read_server_info()
if info and info.get("pid") and pid_is_alive(int(info["pid"])):
port = info.get("port", 8000)
print(port)
return 0
time.sleep(0.5)
print("[ERROR] Timeout esperando a que MP Manager arranque.", file=sys.stderr)
return 2
def preflight_check() -> int:
"""Validación pre-arranque. Devuelve:
0 = OK, puede lanzarse
1 = ya hay MP Manager corriendo (no relanzar)
2 = puerto 8000 ocupado por otro proceso (advertir pero lanzar — main.py salta al siguiente puerto libre)
"""
clear_stale_server_info()
info = read_server_info()
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.")
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")
return 1
cmd = get_process_cmdline(port_pid) or "(desconocido)"
print(f"[ADVERTENCIA] Puerto 8000 ocupado por OTRO proyecto (PID {port_pid}):")
print(f" {cmd[:150]}")
print(f" MP Manager arrancara en el siguiente puerto libre.")
return 2
print("[OK] Puerto 8000 libre. Listo para arrancar.")
return 0
def main(argv: list) -> int:
if not argv:
print(__doc__)
return 0
cmd = argv[0]
if cmd == "status":
s = status()
print(json.dumps(s, indent=2, ensure_ascii=False))
return 0
if cmd == "stop":
force = "--force" in argv[1:]
return stop_server(force=force)
if cmd == "wait-ready":
timeout = 30.0
for arg in argv[1:]:
if arg.startswith("--timeout="):
try:
timeout = float(arg.split("=", 1)[1])
except ValueError:
pass
return wait_ready(timeout_sec=timeout)
if cmd == "preflight":
return preflight_check()
if cmd == "port-owner":
if len(argv) < 2:
print("uso: port-owner <puerto>", file=sys.stderr)
return 2
try:
p = int(argv[1])
except ValueError:
print("puerto invalido", file=sys.stderr)
return 2
pid = get_pid_listening_on(p)
if pid is None:
print(f"puerto {p} libre")
return 0
cmd_line = get_process_cmdline(pid) or "(desconocido)"
ours = pid_belongs_to_mp_manager(pid)
print(f"PID {pid} (es MP Manager: {ours})")
print(f" {cmd_line}")
return 0
print(f"comando desconocido: {cmd}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))