stackhelx 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stackhelx/__init__.py +2 -0
- stackhelx/__main__.py +4 -0
- stackhelx/browse.py +160 -0
- stackhelx/cli.py +1165 -0
- stackhelx/config.py +448 -0
- stackhelx/detect.py +1053 -0
- stackhelx/docker.py +202 -0
- stackhelx/doctor.py +356 -0
- stackhelx/guardrails.py +42 -0
- stackhelx/history.py +86 -0
- stackhelx/mcp.py +467 -0
- stackhelx/ports.py +392 -0
- stackhelx/registry.py +356 -0
- stackhelx/runner.py +877 -0
- stackhelx/scripts.py +123 -0
- stackhelx/server.py +1786 -0
- stackhelx/tunnel.py +207 -0
- stackhelx/web/app.css +1729 -0
- stackhelx/web/app.js +2425 -0
- stackhelx/web/index.html +378 -0
- stackhelx/web/tokens.css +104 -0
- stackhelx-1.0.0.dist-info/METADATA +323 -0
- stackhelx-1.0.0.dist-info/RECORD +26 -0
- stackhelx-1.0.0.dist-info/WHEEL +4 -0
- stackhelx-1.0.0.dist-info/entry_points.txt +3 -0
- stackhelx-1.0.0.dist-info/licenses/LICENSE +21 -0
stackhelx/runner.py
ADDED
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
"""Arranque secuenciado de los servicios de un stack.
|
|
2
|
+
|
|
3
|
+
Cada servicio corre en su propio proceso; un hilo por servicio bombea su salida
|
|
4
|
+
hacia una cola que el hilo principal drena e imprime con prefijo de color.
|
|
5
|
+
|
|
6
|
+
ponytail: no hay dashboard con `rich.live`. Los logs prefijados son el 90% del
|
|
7
|
+
valor y no pelean con el scroll de la terminal. Un panel fijo se agrega si
|
|
8
|
+
alguien lo pide con un caso concreto.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import queue
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from collections import deque
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import psutil
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
|
|
28
|
+
from . import config, detect, ports
|
|
29
|
+
from .config import Service, Stack
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_env(service: Service, extra_env: dict[str, str] | None = None) -> dict[str, str]:
|
|
33
|
+
"""Construye el entorno de ejecucion con precedencia clara:
|
|
34
|
+
1. os.environ
|
|
35
|
+
2. ~/.portmaster/env.global (si existe)
|
|
36
|
+
3. service.env_file (en orden)
|
|
37
|
+
4. service.env (declarado explicito)
|
|
38
|
+
5. extra_env (e.g. desde --env-file en CLI)
|
|
39
|
+
"""
|
|
40
|
+
env = dict(os.environ)
|
|
41
|
+
global_env = Path.home() / ".portmaster" / "env.global"
|
|
42
|
+
if global_env.is_file():
|
|
43
|
+
env.update(config.parse_env_file(global_env))
|
|
44
|
+
for env_path in service.env_file:
|
|
45
|
+
if env_path.is_file():
|
|
46
|
+
env.update(config.parse_env_file(env_path))
|
|
47
|
+
env.update(service.env)
|
|
48
|
+
if extra_env:
|
|
49
|
+
env.update(extra_env)
|
|
50
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
51
|
+
env["FORCE_COLOR"] = "1"
|
|
52
|
+
return env
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def service_url(service: Service, extra_env: dict[str, str] | None = None) -> str | None:
|
|
56
|
+
"""Adonde lleva "Abrir" para este servicio, o None si no se puede saber.
|
|
57
|
+
|
|
58
|
+
Sin `url:` devuelve None y el que llama arma el default de siempre con el
|
|
59
|
+
puerto. Con `url:`, expande `${VAR}` y `${VAR:-default}` desde el entorno
|
|
60
|
+
que `build_env` ya compone, que es el mismo con el que corre el servicio: si
|
|
61
|
+
la URL necesita un token, es el token que el proceso recibio.
|
|
62
|
+
|
|
63
|
+
Una variable sin valor tambien devuelve None. Abrir el navegador en una URL
|
|
64
|
+
con un `${TOKEN}` literal adentro es peor que no ofrecer el boton: parece
|
|
65
|
+
que funciono.
|
|
66
|
+
|
|
67
|
+
Toca disco (`build_env` lee `env.global` y cada `env_file`), asi que no se
|
|
68
|
+
llama en el sondeo de la interfaz salvo para servicios ya abribles.
|
|
69
|
+
"""
|
|
70
|
+
if not service.url:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
env = build_env(service, extra_env=extra_env)
|
|
74
|
+
faltante = False
|
|
75
|
+
|
|
76
|
+
def resolve(match: re.Match) -> str:
|
|
77
|
+
nonlocal faltante
|
|
78
|
+
valor = env.get(match.group(1))
|
|
79
|
+
if valor:
|
|
80
|
+
return valor
|
|
81
|
+
if match.group(2) is not None:
|
|
82
|
+
return match.group(2)
|
|
83
|
+
faltante = True
|
|
84
|
+
return match.group(0)
|
|
85
|
+
|
|
86
|
+
expandida = detect.VARIABLE.sub(resolve, service.url)
|
|
87
|
+
return None if faltante else expandida
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
COLORS = ("cyan", "magenta", "green", "yellow", "blue", "bright_red")
|
|
91
|
+
SHUTDOWN_TIMEOUT = 5
|
|
92
|
+
POLL = 0.15
|
|
93
|
+
|
|
94
|
+
# Un comando detached tiene su propio presupuesto, mucho mas largo que el del
|
|
95
|
+
# healthcheck: la primera vez que corre, `docker compose up -d` construye la
|
|
96
|
+
# imagen y eso tarda minutos sin que nada este mal.
|
|
97
|
+
DETACHED_TIMEOUT = 900
|
|
98
|
+
|
|
99
|
+
# Lo que se espera entre fijar la linea base de CPU de un proceso nuevo y leerla.
|
|
100
|
+
# Solo se paga la primera vez que un servicio aparece, y una vez por lote.
|
|
101
|
+
CPU_MUESTRA = 0.1
|
|
102
|
+
|
|
103
|
+
# `docker compose stop` le da su gracia a cada contenedor antes de matarlo, y con
|
|
104
|
+
# varios no entra en los 5s del apagado del arbol.
|
|
105
|
+
STOP_TIMEOUT = 90
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class StartupError(Exception):
|
|
109
|
+
"""Un servicio no arranco o no llego a estar listo."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Proc:
|
|
114
|
+
service: Service
|
|
115
|
+
popen: subprocess.Popen
|
|
116
|
+
color: str
|
|
117
|
+
ready: bool = False
|
|
118
|
+
matched_log: bool = False
|
|
119
|
+
port: int | None = None # descubierto, para los servicios con ready: listen
|
|
120
|
+
http: bool = False # el puerto contesta HTTP, o sea que se puede abrir
|
|
121
|
+
# El puerto ya aceptaba conexiones antes de arrancar. Ver _spawn_proc.
|
|
122
|
+
port_taken: bool = False
|
|
123
|
+
# Ultimas lineas de salida, para que el error diga la causa y no solo el
|
|
124
|
+
# codigo: "fallo con codigo 1" sin el motivo obliga a abrir los logs.
|
|
125
|
+
tail: deque[str] = field(default_factory=lambda: deque(maxlen=5))
|
|
126
|
+
# Ya lo reclamo alguien para apagarlo. Ver Runner._stop_one.
|
|
127
|
+
claimed: bool = False
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def known_port(self) -> int | None:
|
|
131
|
+
return self.service.port or self.port
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class Runner:
|
|
136
|
+
stack: Stack
|
|
137
|
+
console: Console = field(default_factory=Console)
|
|
138
|
+
timeout: float = 60.0
|
|
139
|
+
procs: list[Proc] = field(default_factory=list)
|
|
140
|
+
extra_env: dict[str, str] = field(default_factory=dict)
|
|
141
|
+
_logs: queue.Queue = field(default_factory=queue.Queue)
|
|
142
|
+
_width: int = 8
|
|
143
|
+
_cancel: threading.Event = field(default_factory=threading.Event)
|
|
144
|
+
_down: bool = False
|
|
145
|
+
restarting: bool = False
|
|
146
|
+
_retries: dict[str, int] = field(default_factory=dict)
|
|
147
|
+
# psutil.Process por pid. Vive entre llamadas porque cpu_percent mide
|
|
148
|
+
# el delta contra la lectura anterior del mismo objeto.
|
|
149
|
+
_ps_cache: dict[int, psutil.Process] = field(default_factory=dict)
|
|
150
|
+
# Los hooks sincronos (pre_start, post_start) que estan corriendo ahora.
|
|
151
|
+
# Sin esto, `down` no tenia a quien matar y el hook sobrevivia al apagado.
|
|
152
|
+
_hooks: set[subprocess.Popen] = field(default_factory=set)
|
|
153
|
+
# Protege `procs`, `_down` y `_hooks` entre los hilos de un mismo nivel y el
|
|
154
|
+
# apagado.
|
|
155
|
+
_procs_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
156
|
+
|
|
157
|
+
def up(self, profile: str | None = None) -> None:
|
|
158
|
+
"""Arranca por niveles. Si algo falla, apaga lo ya levantado.
|
|
159
|
+
|
|
160
|
+
Los servicios que no dependen entre si arrancan juntos: el stack tarda
|
|
161
|
+
el healthcheck mas lento de cada nivel en vez de la suma de todos.
|
|
162
|
+
"""
|
|
163
|
+
services = self.stack.resolve(profile)
|
|
164
|
+
self._width = max(len(s.name) for s in services)
|
|
165
|
+
# Color por posicion, antes de arrancar nada. `COLORS[len(self.procs) %
|
|
166
|
+
# ...]` era un read-modify-write: dos servicios del mismo nivel podian
|
|
167
|
+
# salir del mismo color, que es justo lo que hace legibles los logs
|
|
168
|
+
# cuando se entreveran.
|
|
169
|
+
colors = {s.name: COLORS[i % len(COLORS)] for i, s in enumerate(services)}
|
|
170
|
+
try:
|
|
171
|
+
for level in _levels(services):
|
|
172
|
+
self._abort_if_cancelled()
|
|
173
|
+
self._start_level(level, colors)
|
|
174
|
+
except BaseException:
|
|
175
|
+
self.down()
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
def _start_level(self, level: list[Service], colors: dict[str, str]) -> None:
|
|
179
|
+
if len(level) == 1:
|
|
180
|
+
self._launch(level[0], colors[level[0].name])
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
fallos: list[BaseException] = []
|
|
184
|
+
threads = [
|
|
185
|
+
threading.Thread(target=self._launch_capturing, args=(s, colors[s.name], fallos))
|
|
186
|
+
for s in level
|
|
187
|
+
]
|
|
188
|
+
for thread in threads:
|
|
189
|
+
thread.start()
|
|
190
|
+
# Se esperan todos aunque uno falle: cortar antes dejaria a los hermanos
|
|
191
|
+
# arrancando detras del apagado, que es el mismo bug que `cancel` vino a
|
|
192
|
+
# resolver para el arranque entero.
|
|
193
|
+
for thread in threads:
|
|
194
|
+
thread.join()
|
|
195
|
+
|
|
196
|
+
if fallos:
|
|
197
|
+
if len(fallos) > 1:
|
|
198
|
+
otros = ", ".join(str(f) for f in fallos[1:])
|
|
199
|
+
raise StartupError(f"{fallos[0]} (y ademas: {otros})") from fallos[0]
|
|
200
|
+
raise fallos[0]
|
|
201
|
+
|
|
202
|
+
def _launch_capturing(
|
|
203
|
+
self, service: Service, color: str, fallos: list[BaseException]
|
|
204
|
+
) -> None:
|
|
205
|
+
try:
|
|
206
|
+
self._launch(service, color)
|
|
207
|
+
except BaseException as exc: # el hilo no debe morir en silencio
|
|
208
|
+
fallos.append(exc)
|
|
209
|
+
|
|
210
|
+
def _launch(self, service: Service, color: str) -> None:
|
|
211
|
+
proc = self._spawn_proc(service, color)
|
|
212
|
+
if not self._register(proc):
|
|
213
|
+
# El apagado ya paso por la lista: este proceso no lo va a ver nadie
|
|
214
|
+
# mas, asi que lo baja quien lo arranco.
|
|
215
|
+
self._stop_one(proc)
|
|
216
|
+
raise StartupError("apagado pedido durante el arranque")
|
|
217
|
+
self._wait_ready(proc)
|
|
218
|
+
|
|
219
|
+
def _register(self, proc: Proc) -> bool:
|
|
220
|
+
with self._procs_lock:
|
|
221
|
+
if self._down:
|
|
222
|
+
return False
|
|
223
|
+
self.procs.append(proc)
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def cancel(self) -> None:
|
|
227
|
+
"""Aborta un arranque en curso desde otro hilo.
|
|
228
|
+
|
|
229
|
+
Sin esto, apagar mientras arranca no apaga nada: la lista de procesos
|
|
230
|
+
todavia esta vacia y el arranque sigue levantando servicios detras del
|
|
231
|
+
apagado. El propio `up` es el que baja lo que alcanzo a levantar.
|
|
232
|
+
|
|
233
|
+
Tambien corta `follow`, para que el hilo termine y se lo pueda esperar
|
|
234
|
+
sin importar en que fase estaba.
|
|
235
|
+
"""
|
|
236
|
+
self._cancel.set()
|
|
237
|
+
# `_abort_if_cancelled` solo mira entre niveles y en la espera, asi que
|
|
238
|
+
# un hook en curso no se enteraria hasta terminar.
|
|
239
|
+
self._matar_hooks()
|
|
240
|
+
|
|
241
|
+
def _abort_if_cancelled(self) -> None:
|
|
242
|
+
if self._cancel.is_set():
|
|
243
|
+
raise StartupError("apagado pedido durante el arranque")
|
|
244
|
+
|
|
245
|
+
def follow(self) -> None:
|
|
246
|
+
"""Sigue imprimiendo logs hasta Ctrl-C o hasta que no quede nada vivo."""
|
|
247
|
+
while not self._cancel.is_set():
|
|
248
|
+
with self._procs_lock:
|
|
249
|
+
if self._down:
|
|
250
|
+
break
|
|
251
|
+
# Watchdog de reinicio para servicios caidos
|
|
252
|
+
restarted_any = False
|
|
253
|
+
for p in list(self.procs):
|
|
254
|
+
with self._procs_lock:
|
|
255
|
+
if self._down or self._cancel.is_set():
|
|
256
|
+
break
|
|
257
|
+
if p.popen.poll() is not None and not p.service.detached:
|
|
258
|
+
retries = self._retries.get(p.service.name, 0)
|
|
259
|
+
if p.service.restart in ("on-failure", "always") and retries < p.service.max_retries:
|
|
260
|
+
exit_code = p.popen.poll()
|
|
261
|
+
if p.service.restart == "always" or exit_code != 0:
|
|
262
|
+
with self._procs_lock:
|
|
263
|
+
if self._down or self._cancel.is_set():
|
|
264
|
+
break
|
|
265
|
+
self._retries[p.service.name] = retries + 1
|
|
266
|
+
self._say(
|
|
267
|
+
p,
|
|
268
|
+
f"proceso terminado con codigo {exit_code}. Reiniciando automaticamente (intento {retries + 1}/{p.service.max_retries})...",
|
|
269
|
+
)
|
|
270
|
+
try:
|
|
271
|
+
self.restart(p.service.name)
|
|
272
|
+
restarted_any = True
|
|
273
|
+
except Exception as exc:
|
|
274
|
+
self._say(p, f"reintento fallo: {exc}")
|
|
275
|
+
|
|
276
|
+
with self._procs_lock:
|
|
277
|
+
if self._down or self._cancel.is_set():
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
# Si nadie esta vivo, no estamos reiniciando y no se disparo ningun reinicio, salimos
|
|
281
|
+
if not self.restarting and not restarted_any and not any(p.popen.poll() is None for p in list(self.procs)):
|
|
282
|
+
break
|
|
283
|
+
|
|
284
|
+
if not self._drain():
|
|
285
|
+
time.sleep(POLL)
|
|
286
|
+
self._drain()
|
|
287
|
+
|
|
288
|
+
def restart(self, name: str) -> None:
|
|
289
|
+
"""Reinicia un servicio sin tocar el resto del stack."""
|
|
290
|
+
with self._procs_lock:
|
|
291
|
+
if self._down or self._cancel.is_set():
|
|
292
|
+
raise StartupError("apagado en curso, no se puede reiniciar")
|
|
293
|
+
index = next((i for i, p in enumerate(self.procs) if p.service.name == name), None)
|
|
294
|
+
if index is None:
|
|
295
|
+
raise StartupError(f"{name} no esta corriendo en este stack")
|
|
296
|
+
old = self.procs[index]
|
|
297
|
+
self.restarting = True
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
self._stop_one(old)
|
|
301
|
+
# `_spawn_proc` corre `pre_start`, y su presupuesto es
|
|
302
|
+
# DETACHED_TIMEOUT: 900s. Con el lock tomado, un apagado pedido
|
|
303
|
+
# mientras tanto se quedaba esperando ese comando, porque `down`
|
|
304
|
+
# necesita el mismo lock para copiar la lista.
|
|
305
|
+
proc = self._spawn_proc(old.service, old.color)
|
|
306
|
+
with self._procs_lock:
|
|
307
|
+
tarde = self._down or self._cancel.is_set()
|
|
308
|
+
if not tarde:
|
|
309
|
+
self.procs[index] = proc
|
|
310
|
+
if tarde:
|
|
311
|
+
# El apagado ya paso por la lista: a este proceso no lo va a ver
|
|
312
|
+
# nadie mas, asi que lo baja quien lo arranco. Mismo trato que
|
|
313
|
+
# en `_launch` cuando `_register` devuelve False.
|
|
314
|
+
self._stop_one(proc)
|
|
315
|
+
raise StartupError("apagado en curso, no se puede reiniciar")
|
|
316
|
+
finally:
|
|
317
|
+
self.restarting = False
|
|
318
|
+
self._wait_ready(proc)
|
|
319
|
+
|
|
320
|
+
def resource_stats(self) -> dict[str, dict[str, float]]:
|
|
321
|
+
"""Calcula el uso de recursos (CPU % y Memoria RSS en MB) de cada servicio activo."""
|
|
322
|
+
# `cpu_percent(interval=None)` mide contra la lectura anterior del
|
|
323
|
+
# *mismo* objeto Process. Recrearlo en cada llamada devolvia 0.0 para
|
|
324
|
+
# siempre: no habia lectura anterior contra la cual restar. Por eso el
|
|
325
|
+
# cache, y por eso el respiro de abajo.
|
|
326
|
+
arboles: dict[str, list[psutil.Process]] = {}
|
|
327
|
+
estrenados = False
|
|
328
|
+
for p in list(self.procs):
|
|
329
|
+
if p.popen.poll() is not None:
|
|
330
|
+
continue
|
|
331
|
+
arbol = []
|
|
332
|
+
for pid in self._pids_del_arbol(p.popen.pid):
|
|
333
|
+
proc = self._ps_cache.get(pid)
|
|
334
|
+
if proc is None:
|
|
335
|
+
try:
|
|
336
|
+
proc = psutil.Process(pid)
|
|
337
|
+
# Fija la linea base y descarta el 0.0 obligado.
|
|
338
|
+
proc.cpu_percent(interval=None)
|
|
339
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
340
|
+
continue
|
|
341
|
+
self._ps_cache[pid] = proc
|
|
342
|
+
estrenados = True
|
|
343
|
+
arbol.append(proc)
|
|
344
|
+
arboles[p.service.name] = arbol
|
|
345
|
+
|
|
346
|
+
if estrenados:
|
|
347
|
+
# Un solo respiro para todo el lote, no uno por proceso: sin esto,
|
|
348
|
+
# la primera lectura de un servicio recien arrancado seria 0.0 y
|
|
349
|
+
# `portmaster stats`, que hace una sola consulta, nunca mediria nada.
|
|
350
|
+
time.sleep(CPU_MUESTRA)
|
|
351
|
+
|
|
352
|
+
vivos = {proc.pid for arbol in arboles.values() for proc in arbol}
|
|
353
|
+
for pid in list(self._ps_cache):
|
|
354
|
+
if pid not in vivos:
|
|
355
|
+
# pop y no del: /api/state y /metrics pueden podar a la vez.
|
|
356
|
+
self._ps_cache.pop(pid, None)
|
|
357
|
+
|
|
358
|
+
stats: dict[str, dict[str, float]] = {}
|
|
359
|
+
for p in list(self.procs):
|
|
360
|
+
total_cpu = 0.0
|
|
361
|
+
total_rss = 0
|
|
362
|
+
for proc in arboles.get(p.service.name, ()):
|
|
363
|
+
try:
|
|
364
|
+
total_cpu += proc.cpu_percent(interval=None)
|
|
365
|
+
total_rss += proc.memory_info().rss
|
|
366
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
367
|
+
continue
|
|
368
|
+
stats[p.service.name] = {
|
|
369
|
+
"cpu_percent": round(total_cpu, 1),
|
|
370
|
+
"memory_mb": round(total_rss / (1024 * 1024), 1),
|
|
371
|
+
"pid": p.popen.pid,
|
|
372
|
+
}
|
|
373
|
+
return stats
|
|
374
|
+
|
|
375
|
+
def _pids_del_arbol(self, pid: int) -> list[int]:
|
|
376
|
+
"""El pid del servicio y los de su descendencia.
|
|
377
|
+
|
|
378
|
+
Con `shell=True` el hijo directo es el shell y el servidor de verdad es
|
|
379
|
+
un nieto, asi que sumar solo el padre daria una memoria de juguete.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
parent = psutil.Process(pid)
|
|
383
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
384
|
+
return []
|
|
385
|
+
pids = [pid]
|
|
386
|
+
try:
|
|
387
|
+
pids.extend(hijo.pid for hijo in parent.children(recursive=True))
|
|
388
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
389
|
+
pass
|
|
390
|
+
return pids
|
|
391
|
+
|
|
392
|
+
def down(self) -> None:
|
|
393
|
+
"""Apaga en orden inverso al de arranque. Correrlo dos veces no hace nada."""
|
|
394
|
+
with self._procs_lock:
|
|
395
|
+
if self._down:
|
|
396
|
+
return
|
|
397
|
+
self._down = True
|
|
398
|
+
# Copia bajo el lock: si un hilo del nivel appendea mientras iteramos,
|
|
399
|
+
# ese proc queda fuera de la lista y lo baja `_launch`, que ve el
|
|
400
|
+
# `_down` y no lo registra.
|
|
401
|
+
pendientes = list(reversed(self.procs))
|
|
402
|
+
# Antes que los servicios: un hook en curso puede estar levantando algo
|
|
403
|
+
# que el apagado ya paso a buscar.
|
|
404
|
+
self._matar_hooks()
|
|
405
|
+
for proc in pendientes:
|
|
406
|
+
self._stop_one(proc)
|
|
407
|
+
self._drain()
|
|
408
|
+
|
|
409
|
+
def _stop_one(self, proc: Proc) -> None:
|
|
410
|
+
# Un solo responsable por proceso. `restart` baja el viejo con el lock
|
|
411
|
+
# suelto, y en esa ventana `down` puede copiar la lista y encontrarlo
|
|
412
|
+
# todavia ahi: los dos corrian el `stop:` del servicio a la vez. La
|
|
413
|
+
# guarda va aca y no en `restart` porque el que llama son cuatro.
|
|
414
|
+
with self._procs_lock:
|
|
415
|
+
if proc.claimed:
|
|
416
|
+
return
|
|
417
|
+
proc.claimed = True
|
|
418
|
+
if proc.service.stop:
|
|
419
|
+
self._stop_command(proc)
|
|
420
|
+
if proc.popen.poll() is None:
|
|
421
|
+
self._say(proc, "apagando")
|
|
422
|
+
_terminate_tree(proc.popen)
|
|
423
|
+
|
|
424
|
+
def _stop_command(self, proc: Proc) -> None:
|
|
425
|
+
"""Apagado propio del servicio.
|
|
426
|
+
|
|
427
|
+
Matar el arbol no alcanza cuando lo que quedo vivo no es hijo nuestro:
|
|
428
|
+
`docker compose up -d` termina enseguida y los contenedores siguen
|
|
429
|
+
corriendo. Sin este comando, "apagar" no apaga nada.
|
|
430
|
+
"""
|
|
431
|
+
self._say(proc, f"$ {proc.service.stop}")
|
|
432
|
+
done = run_stop(proc.service)
|
|
433
|
+
if done is None:
|
|
434
|
+
self._say(proc, f"el apagado no termino en {STOP_TIMEOUT}s")
|
|
435
|
+
return
|
|
436
|
+
for line in (done.stdout or "").splitlines():
|
|
437
|
+
self._write(proc, line.rstrip())
|
|
438
|
+
if done.returncode != 0:
|
|
439
|
+
self._say(proc, f"el apagado fallo con codigo {done.returncode}")
|
|
440
|
+
|
|
441
|
+
# arranque -------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
def _spawn_proc(self, service: Service, color: str) -> Proc:
|
|
444
|
+
# Una sola muestra, antes de arrancar. Con `ready: port` el servicio se
|
|
445
|
+
# declara listo apenas alguien acepte en el puerto, y si ya habia alguien
|
|
446
|
+
# ahi el verde puede estar señalando a un proceso ajeno. Saber quien es
|
|
447
|
+
# el dueño costaria un recorrido de todos los procesos por sondeo, y en
|
|
448
|
+
# macOS la tabla de conexiones pide root; saber si el puerto ya estaba
|
|
449
|
+
# tomado cuesta un connect y contesta lo mismo para el que mira.
|
|
450
|
+
#
|
|
451
|
+
# No es un error: `docker compose up -d` sobre un contenedor que ya esta
|
|
452
|
+
# arriba cae aca y es el caso legitimo. Por eso avisa y no cancela.
|
|
453
|
+
if service.pre_start:
|
|
454
|
+
self._say_raw(service.name, color, f"$ pre_start: {service.pre_start}")
|
|
455
|
+
res = self._run_hook(service, service.pre_start, "pre_start")
|
|
456
|
+
for line in (res.stdout or "").splitlines():
|
|
457
|
+
self._write_raw(service.name, color, line)
|
|
458
|
+
if res.returncode != 0:
|
|
459
|
+
raise StartupError(f"{service.name} pre_start fallo con codigo {res.returncode}")
|
|
460
|
+
|
|
461
|
+
taken = service.ready == "port" and ports.accepts(service.port)
|
|
462
|
+
proc = Proc(service, self._spawn(service), color, port_taken=taken)
|
|
463
|
+
self._say(proc, f"$ {service.command}")
|
|
464
|
+
threading.Thread(target=self._pump, args=(proc,), daemon=True).start()
|
|
465
|
+
return proc
|
|
466
|
+
|
|
467
|
+
def _run_hook(self, service: Service, comando: str, etiqueta: str) -> subprocess.CompletedProcess:
|
|
468
|
+
"""Corre un hook sincrono sin perderle el rastro.
|
|
469
|
+
|
|
470
|
+
Con `subprocess.run` no queda handle, asi que un apagado pedido mientras
|
|
471
|
+
el hook corria volvia en el acto y lo dejaba vivo hasta su presupuesto
|
|
472
|
+
de DETACHED_TIMEOUT: 900s de `npm run build` huerfano. Es el mismo
|
|
473
|
+
agujero que dejaba tuneles publicando el puerto, y por eso `CLAUDE.md`
|
|
474
|
+
pide que todo lo que se lance con `shell=True` se baje con
|
|
475
|
+
`_terminate_tree`.
|
|
476
|
+
"""
|
|
477
|
+
proc = subprocess.Popen(
|
|
478
|
+
comando,
|
|
479
|
+
shell=True,
|
|
480
|
+
cwd=service.cwd,
|
|
481
|
+
env=build_env(service, extra_env=self.extra_env),
|
|
482
|
+
stdout=subprocess.PIPE,
|
|
483
|
+
stderr=subprocess.STDOUT,
|
|
484
|
+
text=True,
|
|
485
|
+
errors="replace",
|
|
486
|
+
)
|
|
487
|
+
with self._procs_lock:
|
|
488
|
+
tarde = self._down or self._cancel.is_set()
|
|
489
|
+
if not tarde:
|
|
490
|
+
self._hooks.add(proc)
|
|
491
|
+
if tarde:
|
|
492
|
+
# El apagado ya paso por la lista de hooks: a este lo baja quien lo
|
|
493
|
+
# arranco, igual que en `_launch` y en `restart`.
|
|
494
|
+
_terminate_tree(proc)
|
|
495
|
+
proc.communicate()
|
|
496
|
+
raise StartupError("apagado pedido durante el arranque")
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
salida, _ = proc.communicate(timeout=DETACHED_TIMEOUT)
|
|
500
|
+
except subprocess.TimeoutExpired:
|
|
501
|
+
_terminate_tree(proc)
|
|
502
|
+
salida, _ = proc.communicate()
|
|
503
|
+
# Sin esto el timeout salia crudo. Un `npm run build` colgado rompia
|
|
504
|
+
# el arranque con un traceback en vez de decir que servicio y que
|
|
505
|
+
# hook se quedaron esperando, que es lo unico que hace falta para
|
|
506
|
+
# saber donde mirar.
|
|
507
|
+
raise StartupError(
|
|
508
|
+
f"{service.name} {etiqueta} no termino en {DETACHED_TIMEOUT:.0f}s"
|
|
509
|
+
) from None
|
|
510
|
+
finally:
|
|
511
|
+
with self._procs_lock:
|
|
512
|
+
self._hooks.discard(proc)
|
|
513
|
+
return subprocess.CompletedProcess(comando, proc.returncode, salida, None)
|
|
514
|
+
|
|
515
|
+
def _matar_hooks(self) -> None:
|
|
516
|
+
with self._procs_lock:
|
|
517
|
+
pendientes = list(self._hooks)
|
|
518
|
+
self._hooks.clear()
|
|
519
|
+
for hook in pendientes:
|
|
520
|
+
_terminate_tree(hook)
|
|
521
|
+
|
|
522
|
+
def _spawn(self, service: Service) -> subprocess.Popen:
|
|
523
|
+
# shell=True es deliberado: `npm run dev` y `docker compose up -d` no son
|
|
524
|
+
# ejecutables, y stack.yaml ya es codigo ejecutable por diseño. El README
|
|
525
|
+
# documenta el modelo de confianza.
|
|
526
|
+
return subprocess.Popen(
|
|
527
|
+
service.command,
|
|
528
|
+
shell=True,
|
|
529
|
+
cwd=service.cwd,
|
|
530
|
+
env=build_env(service, extra_env=self.extra_env),
|
|
531
|
+
stdout=subprocess.PIPE,
|
|
532
|
+
stderr=subprocess.STDOUT,
|
|
533
|
+
text=True,
|
|
534
|
+
bufsize=1,
|
|
535
|
+
errors="replace",
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def _pump(self, proc: Proc) -> None:
|
|
539
|
+
assert proc.popen.stdout is not None
|
|
540
|
+
for line in proc.popen.stdout:
|
|
541
|
+
self._logs.put((proc, line.rstrip()))
|
|
542
|
+
proc.popen.stdout.close()
|
|
543
|
+
|
|
544
|
+
# espera ---------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
def _wait_ready(self, proc: Proc) -> None:
|
|
547
|
+
service = proc.service
|
|
548
|
+
|
|
549
|
+
if service.detached:
|
|
550
|
+
self._await_exit(proc)
|
|
551
|
+
|
|
552
|
+
deadline = time.monotonic() + self.timeout
|
|
553
|
+
while True:
|
|
554
|
+
self._abort_if_cancelled()
|
|
555
|
+
self._drain()
|
|
556
|
+
if self._is_ready(proc):
|
|
557
|
+
proc.ready = True
|
|
558
|
+
port = proc.known_port
|
|
559
|
+
if port:
|
|
560
|
+
proc.http = speaks_http(port)
|
|
561
|
+
detail = f" ({port})" if port else ""
|
|
562
|
+
if proc.http:
|
|
563
|
+
detail += f" · http://localhost:{port}"
|
|
564
|
+
if proc.port_taken:
|
|
565
|
+
detail += " · el puerto ya estaba ocupado antes de arrancar"
|
|
566
|
+
|
|
567
|
+
if service.post_start:
|
|
568
|
+
self._say(proc, f"$ post_start: {service.post_start}")
|
|
569
|
+
res = self._run_hook(service, service.post_start, "post_start")
|
|
570
|
+
for line in (res.stdout or "").splitlines():
|
|
571
|
+
self._write(proc, line)
|
|
572
|
+
if res.returncode != 0:
|
|
573
|
+
raise StartupError(f"{service.name} post_start fallo con codigo {res.returncode}")
|
|
574
|
+
|
|
575
|
+
self._say(proc, "listo" + detail)
|
|
576
|
+
return
|
|
577
|
+
if not service.detached and proc.popen.poll() is not None:
|
|
578
|
+
raise StartupError(
|
|
579
|
+
f"{service.name} termino con codigo {proc.popen.returncode} "
|
|
580
|
+
f"antes de estar listo{_why(proc)}"
|
|
581
|
+
)
|
|
582
|
+
if time.monotonic() > deadline:
|
|
583
|
+
raise StartupError(
|
|
584
|
+
f"{service.name} no estuvo listo en {self.timeout:.0f}s "
|
|
585
|
+
f"(ready: {service.ready}){_port_hint(proc)}{_why(proc)}"
|
|
586
|
+
)
|
|
587
|
+
time.sleep(POLL)
|
|
588
|
+
|
|
589
|
+
def _await_exit(self, proc: Proc) -> None:
|
|
590
|
+
"""Un servicio detached corre un comando que termina y deja algo vivo."""
|
|
591
|
+
budget = max(self.timeout, DETACHED_TIMEOUT)
|
|
592
|
+
try:
|
|
593
|
+
code = proc.popen.wait(budget)
|
|
594
|
+
except subprocess.TimeoutExpired:
|
|
595
|
+
raise StartupError(
|
|
596
|
+
f"{proc.service.name} es detached pero no termino en {budget:.0f}s"
|
|
597
|
+
) from None
|
|
598
|
+
self._drain()
|
|
599
|
+
if code != 0:
|
|
600
|
+
raise StartupError(f"{proc.service.name} fallo con codigo {code}{_why(proc)}")
|
|
601
|
+
|
|
602
|
+
def _is_ready(self, proc: Proc) -> bool:
|
|
603
|
+
ready = proc.service.ready
|
|
604
|
+
if ready == "none":
|
|
605
|
+
return True
|
|
606
|
+
if ready == "port":
|
|
607
|
+
# `accepts` y no `not is_free`: is_free tambien cuenta como ocupado
|
|
608
|
+
# un socket bindeado que todavia no llamo a listen(), y ahi el
|
|
609
|
+
# servicio se declaraba listo antes de aceptar a nadie. El sondeo
|
|
610
|
+
# HTTP que sigue se comia un connection refused y el servicio
|
|
611
|
+
# perdia el boton Abrir. Ver ports.accepts.
|
|
612
|
+
return ports.accepts(proc.service.port)
|
|
613
|
+
if ready == "listen":
|
|
614
|
+
proc.port = ports.listening(proc.popen.pid)
|
|
615
|
+
return proc.port is not None
|
|
616
|
+
if ready.startswith("log:"):
|
|
617
|
+
return proc.matched_log
|
|
618
|
+
return _http_ok(ready)
|
|
619
|
+
|
|
620
|
+
# salida ---------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
def _drain(self) -> bool:
|
|
623
|
+
"""Imprime lo que haya en la cola. Devuelve si imprimio algo."""
|
|
624
|
+
printed = False
|
|
625
|
+
while True:
|
|
626
|
+
try:
|
|
627
|
+
proc, line = self._logs.get_nowait()
|
|
628
|
+
except queue.Empty:
|
|
629
|
+
return printed
|
|
630
|
+
marker = proc.service.ready
|
|
631
|
+
if marker.startswith("log:") and marker[4:] in line:
|
|
632
|
+
proc.matched_log = True
|
|
633
|
+
proc.tail.append(line)
|
|
634
|
+
self._write(proc, line)
|
|
635
|
+
printed = True
|
|
636
|
+
|
|
637
|
+
def _say(self, proc: Proc, message: str) -> None:
|
|
638
|
+
self._say_raw(proc.service.name, proc.color, message)
|
|
639
|
+
|
|
640
|
+
def _write(self, proc: Proc, text: str) -> None:
|
|
641
|
+
self._write_raw(proc.service.name, proc.color, text)
|
|
642
|
+
|
|
643
|
+
def _say_raw(self, name: str, color: str, message: str) -> None:
|
|
644
|
+
self._write_raw(name, color, f"[dim]{message}[/]")
|
|
645
|
+
|
|
646
|
+
def _write_raw(self, name: str, color: str, text: str) -> None:
|
|
647
|
+
padded = name.ljust(self._width)
|
|
648
|
+
self.console.print(f"[{color}]{padded}[/] [dim]|[/] {text}", highlight=False)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _levels(services: list[Service]) -> list[list[Service]]:
|
|
652
|
+
"""Agrupa el orden de arranque en tandas que pueden arrancar juntas.
|
|
653
|
+
|
|
654
|
+
`services` ya viene en orden topologico, asi que cuando se mira un servicio
|
|
655
|
+
todas sus dependencias tienen nivel asignado y alcanza con una pasada.
|
|
656
|
+
"""
|
|
657
|
+
nivel_de: dict[str, int] = {}
|
|
658
|
+
levels: list[list[Service]] = []
|
|
659
|
+
for service in services:
|
|
660
|
+
nivel = max((nivel_de[d] + 1 for d in service.needs if d in nivel_de), default=0)
|
|
661
|
+
nivel_de[service.name] = nivel
|
|
662
|
+
while len(levels) <= nivel:
|
|
663
|
+
levels.append([])
|
|
664
|
+
levels[nivel].append(service)
|
|
665
|
+
return levels
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def dependency_graph(stack: Stack, profile: str | None = None) -> dict:
|
|
669
|
+
"""Calcula nodos, aristas y niveles de arranque topológico del stack."""
|
|
670
|
+
services = stack.resolve(profile)
|
|
671
|
+
levels = _levels(services)
|
|
672
|
+
nivel_de = {s.name: lvl_idx for lvl_idx, lvl in enumerate(levels) for s in lvl}
|
|
673
|
+
return {
|
|
674
|
+
"levels": [[s.name for s in lvl] for lvl in levels],
|
|
675
|
+
"nodes": [
|
|
676
|
+
{
|
|
677
|
+
"name": s.name,
|
|
678
|
+
"level": nivel_de.get(s.name, 0),
|
|
679
|
+
"needs": list(s.needs),
|
|
680
|
+
"port": s.port,
|
|
681
|
+
}
|
|
682
|
+
for s in services
|
|
683
|
+
],
|
|
684
|
+
"edges": [
|
|
685
|
+
{"from": dep, "to": s.name}
|
|
686
|
+
for s in services
|
|
687
|
+
for dep in s.needs
|
|
688
|
+
],
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def run_stop(service: Service, extra_env: dict[str, str] | None = None) -> subprocess.CompletedProcess | None:
|
|
693
|
+
"""Corre el `stop:` de un servicio. None si no termino a tiempo.
|
|
694
|
+
|
|
695
|
+
Vive afuera del `Runner` porque `portmaster down` tiene que poder apagar un
|
|
696
|
+
stack que arranco otro proceso: los contenedores de un `docker compose up -d`
|
|
697
|
+
sobreviven a la terminal que los levanto, y ahi no hay ningun `Proc` vivo del
|
|
698
|
+
que colgarse.
|
|
699
|
+
"""
|
|
700
|
+
assert service.stop
|
|
701
|
+
try:
|
|
702
|
+
return subprocess.run(
|
|
703
|
+
service.stop,
|
|
704
|
+
shell=True,
|
|
705
|
+
cwd=service.cwd,
|
|
706
|
+
env=build_env(service, extra_env=extra_env),
|
|
707
|
+
stdout=subprocess.PIPE,
|
|
708
|
+
stderr=subprocess.STDOUT,
|
|
709
|
+
text=True,
|
|
710
|
+
errors="replace",
|
|
711
|
+
timeout=STOP_TIMEOUT,
|
|
712
|
+
)
|
|
713
|
+
except subprocess.TimeoutExpired:
|
|
714
|
+
return None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
WHY_MAX = 200
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def clean_error_message(text: str) -> str:
|
|
721
|
+
"""Simplifica mensajes de error extensos o tecnicos como fallas de Docker daemon."""
|
|
722
|
+
lower = text.lower()
|
|
723
|
+
if any(k in lower for k in ("docker_engine", "docker daemon", "cannot connect to the docker", "is the docker daemon running", "docker.sock")):
|
|
724
|
+
return "Docker no está en ejecución (abrí Docker Desktop)"
|
|
725
|
+
if "address already in use" in lower or "wsaeaddrinuse" in lower:
|
|
726
|
+
return "El puerto ya está ocupado por otro proceso"
|
|
727
|
+
if "permission denied" in lower or "access is denied" in lower:
|
|
728
|
+
return "Permiso denegado al ejecutar el comando"
|
|
729
|
+
return text
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _port_hint(proc: Proc) -> str:
|
|
733
|
+
"""Que paso con el puerto declarado, cuando el healthcheck ya se agoto.
|
|
734
|
+
|
|
735
|
+
Un dev server que encuentra su puerto ocupado se corre al siguiente sin
|
|
736
|
+
fallar: vite salta de 5177 a 5178 y sigue como si nada. El arranque queda
|
|
737
|
+
esperando en el puerto viejo hasta el timeout y el error no dice por que.
|
|
738
|
+
Solo corre en el camino del fallo, asi que los escaneos no los paga nadie
|
|
739
|
+
que este arrancando bien.
|
|
740
|
+
"""
|
|
741
|
+
declarado = proc.service.port
|
|
742
|
+
if not declarado:
|
|
743
|
+
return ""
|
|
744
|
+
|
|
745
|
+
abiertos = ports.opened_by(proc.popen.pid)
|
|
746
|
+
otros = [p for p in abiertos if p != declarado]
|
|
747
|
+
if otros:
|
|
748
|
+
return (
|
|
749
|
+
f"; declaraste el puerto {declarado} pero abrio {', '.join(map(str, otros))}"
|
|
750
|
+
f" (arrancalo con el puerto fijo, o cambia el port: del stack.yaml)"
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
dueno = ports.scan(declarado)
|
|
754
|
+
if not dueno.free and dueno.pid is not None:
|
|
755
|
+
quien = dueno.name or f"pid {dueno.pid}"
|
|
756
|
+
return (
|
|
757
|
+
f"; el puerto {declarado} ya lo tenia {quien} (pid {dueno.pid}):"
|
|
758
|
+
f" liberalo con `portmaster free {declarado}` y reintenta"
|
|
759
|
+
)
|
|
760
|
+
return ""
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _why(proc: Proc) -> str:
|
|
764
|
+
"""Ultima linea con contenido de la salida del servicio.
|
|
765
|
+
|
|
766
|
+
Es la diferencia entre "postgres fallo con codigo 1" y saber que el daemon de
|
|
767
|
+
Docker no esta corriendo. Los avisos de compose sobre variables sin definir se
|
|
768
|
+
saltean: son ruido y tapan la linea que importa.
|
|
769
|
+
"""
|
|
770
|
+
for line in reversed(proc.tail):
|
|
771
|
+
text = line.strip()
|
|
772
|
+
if text and "level=warning" not in text:
|
|
773
|
+
cleaned = clean_error_message(text)
|
|
774
|
+
if len(cleaned) > WHY_MAX:
|
|
775
|
+
cleaned = cleaned[: WHY_MAX - 1].rstrip() + "…"
|
|
776
|
+
return f": {cleaned}"
|
|
777
|
+
return ""
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# Solo se agota cuando alguien acepta la conexion y no contesta: un puerto
|
|
781
|
+
# cerrado corta en el acto y no cuesta nada. Ese caso es justo el de un servidor
|
|
782
|
+
# que acaba de bindear y todavia no entro a su bucle de atencion, y con 1s
|
|
783
|
+
# bastaba una maquina cargada para darlo por mudo.
|
|
784
|
+
HTTP_PROBE_TIMEOUT = 3.0
|
|
785
|
+
HTTP_PROBE_FIRST_TIMEOUT = 1.0
|
|
786
|
+
HTTP_PROBE_RETRY_DELAY = 0.1
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def speaks_http(port: int) -> bool:
|
|
790
|
+
"""Si el puerto contesta HTTP, y por lo tanto se puede abrir en el navegador.
|
|
791
|
+
|
|
792
|
+
Un postgres listo no es algo que abrir, y saber cual servicio es el frontend
|
|
793
|
+
por el nombre es adivinar. Se le pregunta al puerto, igual que el puerto se le
|
|
794
|
+
pregunta al proceso.
|
|
795
|
+
|
|
796
|
+
Un 404 cuenta: la mayoria de las APIs no sirven nada en la raiz y siguen
|
|
797
|
+
siendo HTTP. Lo que descarta al servicio es que no conteste.
|
|
798
|
+
|
|
799
|
+
Dos intentos cortos, no uno solo ni muchos: reintentar sin limite costaria
|
|
800
|
+
el timeout entero por cada puerto que no habla HTTP, y lo pagaria el
|
|
801
|
+
arranque. Un puerto cerrado corta con ECONNREFUSED en el acto, asi que los
|
|
802
|
+
dos intentos ahi no cuestan mas que el sleep del medio.
|
|
803
|
+
|
|
804
|
+
El segundo intento es cinturon y tirantes desde que `ready: port` pregunta
|
|
805
|
+
por `ports.accepts`: la carrera que cubria, sondear un socket que bindeo y
|
|
806
|
+
todavia no llamo a listen(), ya no llega hasta aca.
|
|
807
|
+
|
|
808
|
+
Al servidor que contesta tarde porque compila en la primera peticion, como
|
|
809
|
+
el modo dev de Next, lo recupera `Session._probe_late_http` desde su propio
|
|
810
|
+
hilo.
|
|
811
|
+
"""
|
|
812
|
+
timeouts = (HTTP_PROBE_FIRST_TIMEOUT, HTTP_PROBE_TIMEOUT - HTTP_PROBE_FIRST_TIMEOUT)
|
|
813
|
+
for i, timeout in enumerate(timeouts):
|
|
814
|
+
try:
|
|
815
|
+
# `localhost` y no 127.0.0.1: un dev server de Node escucha solo en
|
|
816
|
+
# ::1, y preguntando por IPv4 se lo daba por mudo y se quedaba sin
|
|
817
|
+
# boton Abrir. Por nombre, urllib prueba las dos, igual que hace el
|
|
818
|
+
# navegador con el enlace que vamos a ofrecer. Ver ports.LOOPBACK.
|
|
819
|
+
with urllib.request.urlopen(f"http://localhost:{port}", timeout=timeout):
|
|
820
|
+
return True
|
|
821
|
+
except urllib.error.HTTPError:
|
|
822
|
+
return True
|
|
823
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
824
|
+
if i == len(timeouts) - 1:
|
|
825
|
+
return False
|
|
826
|
+
time.sleep(HTTP_PROBE_RETRY_DELAY)
|
|
827
|
+
return False
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _http_ok(url: str) -> bool:
|
|
831
|
+
try:
|
|
832
|
+
with urllib.request.urlopen(url, timeout=2) as response:
|
|
833
|
+
return response.status < 400
|
|
834
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
835
|
+
return False
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _terminate_tree(popen: subprocess.Popen, timeout: float = SHUTDOWN_TIMEOUT) -> None:
|
|
839
|
+
"""Cierra el proceso y sus descendientes.
|
|
840
|
+
|
|
841
|
+
Con shell=True el hijo directo es el shell, y matarlo solo a el deja
|
|
842
|
+
huerfano al servidor de verdad. Por eso se apaga el arbol entero.
|
|
843
|
+
|
|
844
|
+
Recibe el `Popen` y no el pid, y esa es la guarda. `psutil` verifica el
|
|
845
|
+
reciclado de PID en cada llamada, pero contra la identidad que capturo al
|
|
846
|
+
construir el `Process`: si el pid ya se habia reciclado *antes* de esa
|
|
847
|
+
linea, psutil adopta la identidad del intruso y lo termina sin quejarse.
|
|
848
|
+
Recibiendo un entero suelto no habia forma de saberlo. Con el `Popen` si:
|
|
849
|
+
mientras `poll()` da None el hijo esta vivo y sin cosechar, o sea que el
|
|
850
|
+
sistema todavia no puede darle ese pid a nadie mas. Los hooks eran el
|
|
851
|
+
agujero concreto, que llamaban con un pid pelado y sin ningun poll previo.
|
|
852
|
+
|
|
853
|
+
ponytail: queda la ventana entre el `poll()` y el `psutil.Process`, de
|
|
854
|
+
microsegundos. Cerrarla del todo es guardar el `psutil.Process` en el
|
|
855
|
+
momento del spawn y arrastrarlo por `Proc` y por `_hooks`; se hace si
|
|
856
|
+
alguna vez aparece un sintoma, no antes.
|
|
857
|
+
"""
|
|
858
|
+
if popen.poll() is not None:
|
|
859
|
+
return # ya murio: su pid puede ser de otro, y ese otro no es nuestro
|
|
860
|
+
try:
|
|
861
|
+
parent = psutil.Process(popen.pid)
|
|
862
|
+
victims = parent.children(recursive=True) + [parent]
|
|
863
|
+
except psutil.NoSuchProcess:
|
|
864
|
+
return
|
|
865
|
+
for victim in victims:
|
|
866
|
+
try:
|
|
867
|
+
victim.terminate()
|
|
868
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
869
|
+
pass
|
|
870
|
+
|
|
871
|
+
_, alive = psutil.wait_procs(victims, timeout=timeout)
|
|
872
|
+
for victim in alive:
|
|
873
|
+
try:
|
|
874
|
+
victim.kill()
|
|
875
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
876
|
+
pass
|
|
877
|
+
psutil.wait_procs(alive, timeout=min(2.0, timeout))
|