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/ports.py ADDED
@@ -0,0 +1,392 @@
1
+ """Deteccion de puertos ocupados y cierre seguro del proceso que los usa.
2
+
3
+ Sin dependencias de terminal a proposito: el CLI y la futura API local
4
+ consumen las mismas funciones.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import socket
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+
13
+ import psutil
14
+
15
+ # System Idle Process y System en Windows (0, 4), e init/systemd/launchd en POSIX (1). Matarlos no es una opcion.
16
+ PROTECTED_PIDS = {0, 1, 4}
17
+
18
+ # Un puerto publicado por un contenedor no lo escucha el contenedor: lo escucha
19
+ # un proxy que Docker o WSL comparten entre todos. En esta maquina un unico
20
+ # com.docker.backend.exe servia 3000 y 3100, y un unico wslrelay.exe servia 5432
21
+ # y 5433. "Liberar el puerto 5432" terminaba en un terminate() sobre ese proxy,
22
+ # que apaga Docker Desktop entero con todos sus contenedores.
23
+ PROXY_NAMES = {
24
+ "com.docker.backend.exe": "Docker Desktop",
25
+ "com.docker.proxy.exe": "Docker Desktop",
26
+ "docker desktop.exe": "Docker Desktop",
27
+ "vpnkit.exe": "Docker Desktop",
28
+ "dockerd.exe": "Docker",
29
+ "dockerd": "Docker",
30
+ "docker-proxy": "Docker",
31
+ "wslrelay.exe": "WSL",
32
+ "wslhost.exe": "WSL",
33
+ "wslservice.exe": "WSL",
34
+ }
35
+
36
+ DOCKER_PS_TIMEOUT = 5
37
+
38
+ TERMINATE_TIMEOUT = 5
39
+
40
+
41
+ class KillRefused(Exception):
42
+ """El proceso existe pero PortMaster se niega a matarlo."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PortStatus:
47
+ port: int
48
+ free: bool
49
+ pid: int | None = None
50
+ name: str | None = None
51
+ cmdline: str | None = None
52
+ create_time: float | None = None
53
+
54
+ @property
55
+ def owner_unknown(self) -> bool:
56
+ """Ocupado pero sin permiso para saber por quien."""
57
+ return not self.free and self.pid is None
58
+
59
+
60
+ def check_port(port: int) -> int:
61
+ """Rechaza lo que no es un puerto TCP. Publica porque no la usa solo este
62
+ modulo: `server.share_port` valida antes de tocar el candado de tuneles, y
63
+ ahi no hay ningun `scan` que la traiga de arrastre."""
64
+ if not isinstance(port, int) or isinstance(port, bool):
65
+ raise ValueError(f"puerto invalido: {port!r}")
66
+ if not 1 <= port <= 65535:
67
+ raise ValueError(f"puerto fuera de rango 1-65535: {port}")
68
+ return port
69
+
70
+
71
+ def _bind_free(port: int) -> bool:
72
+ exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None)
73
+ hosts = [("0.0.0.0", socket.AF_INET), ("127.0.0.1", socket.AF_INET)]
74
+ if getattr(socket, "has_ipv6", False):
75
+ hosts.append(("::1", socket.AF_INET6))
76
+
77
+ for host, family in hosts:
78
+ try:
79
+ with socket.socket(family, socket.SOCK_STREAM) as sock:
80
+ if exclusive is not None and family == socket.AF_INET:
81
+ sock.setsockopt(socket.SOL_SOCKET, exclusive, 1)
82
+ sock.bind((host, port))
83
+ except OSError:
84
+ return False
85
+ return True
86
+
87
+
88
+ def _pid_by_process_scan(port: int, cache: dict[int, int] | None = None) -> int | None:
89
+ """Barrido proceso por proceso. Solo cuando la tabla no dio el dueno.
90
+
91
+ Si se provee un cache del escaneo batch, consulta directamente el dict.
92
+ De lo contrario, construye una tabla en una sola pasada sobre process_iter.
93
+ """
94
+ if cache is not None:
95
+ return cache.get(port)
96
+ return _process_listeners_cache().get(port)
97
+
98
+
99
+ def _process_listeners_cache() -> dict[int, int]:
100
+ """Mapea cada puerto LISTEN a su PID haciendo una sola pasada por process_iter()."""
101
+ table: dict[int, int] = {}
102
+ for proc in psutil.process_iter():
103
+ try:
104
+ for conn in proc.net_connections(kind="tcp"):
105
+ if conn.laddr and conn.status == psutil.CONN_LISTEN:
106
+ table[conn.laddr.port] = proc.pid
107
+ except (psutil.AccessDenied, psutil.NoSuchProcess):
108
+ continue
109
+ return table
110
+
111
+
112
+ def is_free(port: int) -> bool:
113
+ """Libre solo si nadie escucha en la tabla y ademas el bind funciona."""
114
+ check_port(port)
115
+ return port not in _listeners({port}) and _bind_free(port)
116
+
117
+
118
+ # Un connect() a algo que escucha entra al backlog en el acto; contra un puerto
119
+ # cerrado el kernel corta con ECONNREFUSED sin esperar. Solo se agota si hay un
120
+ # firewall tragando paquetes, que en loopback no pasa.
121
+ ACCEPT_TIMEOUT = 0.5
122
+
123
+ # Las dos familias, y no solo IPv4. Node moderno resuelve `localhost` a `::1`,
124
+ # asi que un `vite` o un `next` en Windows escucha solo en IPv6: preguntar por
125
+ # 127.0.0.1 daba que no hay nadie, el servicio no quedaba listo nunca, y el
126
+ # error terminaba acusando de intruso al proceso que acabamos de arrancar. El
127
+ # navegador tampoco pregunta por una sola: resuelve `localhost` y prueba las que
128
+ # le devuelvan. Explicitas y no por nombre, para no depender del DNS ni del
129
+ # archivo hosts en el camino caliente del arranque.
130
+ LOOPBACK = ("127.0.0.1", "::1")
131
+
132
+
133
+ def accepts(port: int, timeout: float = ACCEPT_TIMEOUT) -> bool:
134
+ """Si algo acepta conexiones en el puerto, aca y ahora.
135
+
136
+ Distinto de `not is_free(port)`: un socket bindeado y todavia sin `listen()`
137
+ hace fallar el bind, asi que cuenta como ocupado, pero no acepta a nadie.
138
+ Esa ventana existe de verdad. En `HTTPServer.__init__` de CPython:
139
+
140
+ server_bind() # bind() y despues socket.getfqdn(host)
141
+ server_activate() # listen()
142
+
143
+ `getfqdn` es una resolucion inversa de DNS, y en macOS sin resolver rapido
144
+ tarda segundos. Un arranque que se declara listo ahi ve el puerto ocupado
145
+ por su propio socket a medio abrir, y despues no consigue hablarle.
146
+
147
+ Para "el servicio ya esta arriba" se pregunta esto. Para "puedo usar este
148
+ puerto" se sigue preguntando `is_free`, donde un bindeado si es un no.
149
+
150
+ Prueba IPv4 y despues IPv6: ver LOOPBACK.
151
+ """
152
+ check_port(port)
153
+ # El presupuesto es total y se reparte, no uno por direccion: con el timeout
154
+ # entero para cada una, un puerto que no contesta costaba el doble de lo que
155
+ # promete el parametro, y esto lo llama el sondeo del arranque cada 150ms.
156
+ #
157
+ # Repartido y no un deadline que la primera se pueda comer entero: si IPv4
158
+ # se cuelga, IPv6 tiene que conservar su turno. Justo el servicio que solo
159
+ # escucha ahi es el que este chequeo vino a encontrar.
160
+ cada = timeout / len(LOOPBACK)
161
+ for host in LOOPBACK:
162
+ try:
163
+ with socket.create_connection((host, port), timeout=cada):
164
+ return True
165
+ except OSError:
166
+ continue
167
+ return False
168
+
169
+
170
+ def _quiet(getter):
171
+ try:
172
+ return getter()
173
+ except (psutil.AccessDenied, psutil.NoSuchProcess):
174
+ return None
175
+
176
+
177
+ def _describe(port: int, pid: int | None, cache: dict[int, int] | None = None) -> PortStatus:
178
+ if pid is None:
179
+ pid = _pid_by_process_scan(port, cache)
180
+ if pid is None:
181
+ return PortStatus(port=port, free=False)
182
+
183
+ try:
184
+ proc = psutil.Process(pid)
185
+ with proc.oneshot():
186
+ name = _quiet(proc.name)
187
+ cmdline = _quiet(lambda: " ".join(proc.cmdline()))
188
+ created = _quiet(proc.create_time)
189
+ except psutil.NoSuchProcess:
190
+ return PortStatus(port=port, free=False)
191
+
192
+ return PortStatus(port, False, pid, name, cmdline or None, created)
193
+
194
+
195
+ def scan(port: int) -> PortStatus:
196
+ """Estado del puerto, con el dueno si se puede averiguar."""
197
+ return scan_many([port])[port]
198
+
199
+
200
+ def scan_many(wanted: list[int]) -> dict[int, PortStatus]:
201
+ """Igual que scan(), pero con una sola lectura de la tabla del sistema.
202
+
203
+ La UI escanea los puertos de todos los proyectos cada pocos segundos; hacer
204
+ una llamada al SO por puerto se nota.
205
+ """
206
+ for port in wanted:
207
+ check_port(port)
208
+
209
+ listeners = _listeners(set(wanted))
210
+ proc_cache: dict[int, int] | None = None
211
+ result = {}
212
+ for port in wanted:
213
+ if port not in listeners and _bind_free(port):
214
+ result[port] = PortStatus(port=port, free=True)
215
+ else:
216
+ pid = listeners.get(port)
217
+ if pid is None:
218
+ if proc_cache is None:
219
+ proc_cache = _process_listeners_cache()
220
+ pid = proc_cache.get(port)
221
+ result[port] = _describe(port, pid, proc_cache)
222
+ return result
223
+
224
+
225
+ def _listeners(wanted: set[int]) -> dict[int, int | None]:
226
+ """Puertos de `wanted` con listener, mapeados a su PID si es visible."""
227
+ found: dict[int, int | None] = {}
228
+ try:
229
+ for conn in psutil.net_connections(kind="tcp"):
230
+ if not conn.laddr or conn.status != psutil.CONN_LISTEN:
231
+ continue
232
+ port = conn.laddr.port
233
+ if port in wanted and (conn.pid or port not in found):
234
+ found[port] = conn.pid
235
+ except (psutil.AccessDenied, PermissionError):
236
+ pass # macOS sin root: queda todo en manos de la sonda de bind
237
+ return found
238
+
239
+
240
+ def opened_by(pid: int) -> list[int]:
241
+ """Puertos en LISTEN del proceso pid y de sus descendientes, ordenados.
242
+
243
+ El arbol entero porque con shell=True el hijo directo es el shell, y quien
244
+ escucha es un nieto.
245
+ """
246
+ try:
247
+ parent = psutil.Process(pid)
248
+ tree = [parent, *parent.children(recursive=True)]
249
+ except psutil.NoSuchProcess:
250
+ return []
251
+
252
+ found = set()
253
+ for process in tree:
254
+ try:
255
+ for conn in process.net_connections(kind="inet"):
256
+ if conn.status == psutil.CONN_LISTEN and conn.laddr:
257
+ found.add(conn.laddr.port)
258
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
259
+ continue
260
+ return sorted(found)
261
+
262
+
263
+ def listening(pid: int) -> int | None:
264
+ """Primer puerto en LISTEN del proceso pid o de sus descendientes.
265
+
266
+ Para servicios cuyo puerto no esta declarado: en vez de adivinarlo parseando
267
+ la config del framework, se arranca el proceso y se le pregunta.
268
+ """
269
+ found = opened_by(pid)
270
+ return found[0] if found else None
271
+
272
+
273
+ def next_free(start: int, limit: int = 20) -> int:
274
+ """Primer puerto libre desde start (incluido)."""
275
+ check_port(start)
276
+ candidates = range(start, min(start + limit, 65536))
277
+ taken = _listeners(set(candidates))
278
+ for port in candidates:
279
+ if port not in taken and _bind_free(port):
280
+ return port
281
+ raise RuntimeError(f"sin puerto libre entre {start} y {start + limit - 1}")
282
+
283
+
284
+ def suggest_alternative(port: int, exclude: set[int] | None = None) -> int:
285
+ """Sugiere el siguiente puerto libre cercano a `port`.
286
+
287
+ Si el puerto original ya está libre y no está en `exclude`, lo devuelve.
288
+ De lo contrario, busca secuencialmente desde `port + 1` descartando
289
+ los puertos de `exclude` y los que no estén libres en el sistema.
290
+ """
291
+ check_port(port)
292
+ forbidden = set(exclude) if exclude else set()
293
+ if port not in forbidden and is_free(port):
294
+ return port
295
+
296
+ arriba = range(port + 1, min(port + 100, 65536))
297
+ abajo = range(max(1024, port - 100), port)
298
+ candidatos = [c for c in (*arriba, *abajo) if c not in forbidden]
299
+ taken = _listeners(set(candidatos))
300
+ for candidate in candidatos:
301
+ if candidate not in taken and _bind_free(candidate):
302
+ return candidate
303
+
304
+ raise RuntimeError(f"no hay puertos libres alternativos cerca de {port}")
305
+
306
+
307
+ def proxy_owner(status: PortStatus) -> str | None:
308
+ """Motor que publica el puerto, si el dueno es un proxy de Docker o WSL.
309
+
310
+ Un puerto en manos de ese proxy no es algo que liberar: el contenedor ya
311
+ esta publicado, y `docker compose up -d` sobre uno que ya corre no hace
312
+ nada. Sin esto, arrancar un stack a medio levantar se cancela solo.
313
+ """
314
+ return PROXY_NAMES.get((status.name or "").lower())
315
+
316
+
317
+ def containers_on(port: int) -> list[str]:
318
+ """Contenedores que publican `port`, vacio si docker no contesta.
319
+
320
+ Solo para armar el mensaje de un kill rechazado, nunca en el camino
321
+ caliente: cuesta un subproceso.
322
+ """
323
+ try:
324
+ done = subprocess.run(
325
+ ["docker", "ps", "--filter", f"publish={port}", "--format", "{{.Names}}"],
326
+ capture_output=True,
327
+ text=True,
328
+ timeout=DOCKER_PS_TIMEOUT,
329
+ )
330
+ except (OSError, subprocess.SubprocessError):
331
+ return []
332
+ return done.stdout.split()
333
+
334
+
335
+ def _proxy_message(nombre: str, motor: str, port: int | None) -> str:
336
+ base = (
337
+ f"ese puerto lo publica {motor} a traves de {nombre}, un proxy compartido"
338
+ f" por todos los contenedores: cerrarlo apagaria {motor} entero"
339
+ )
340
+ nombres = containers_on(port) if port is not None else []
341
+ if nombres:
342
+ return f"{base}. Para el contenedor con: docker stop {' '.join(nombres)}"
343
+ return f"{base}. Pararlo desde Docker, no desde el puerto"
344
+
345
+
346
+ def kill(
347
+ pid: int, create_time: float | None = None, force: bool = False, port: int | None = None
348
+ ) -> None:
349
+ """Cierra el proceso pid. terminate() primero, kill() solo con force.
350
+
351
+ create_time es el valor visto por scan(); si no coincide, el PID fue
352
+ reciclado por otro proceso y se aborta. port solo enriquece el mensaje
353
+ cuando el dueno resulta ser un proxy de Docker o WSL.
354
+
355
+ Lanza KillRefused si el proceso esta protegido, psutil.NoSuchProcess si ya
356
+ no existe, y psutil.AccessDenied si faltan permisos.
357
+ """
358
+ # psutil.Process(None) es el proceso actual: sin esto, un scan que no vio al
359
+ # dueno del puerto termina en PortMaster matandose a si mismo.
360
+ if pid is None:
361
+ raise KillRefused("no hay PID que cerrar")
362
+ if pid in PROTECTED_PIDS:
363
+ raise KillRefused(f"PID {pid} es un proceso del sistema")
364
+
365
+ me = psutil.Process()
366
+ if pid == me.pid:
367
+ raise KillRefused("ese PID es PortMaster")
368
+ if pid in {ancestor.pid for ancestor in me.parents()}:
369
+ raise KillRefused(f"PID {pid} es un proceso padre de PortMaster (tu terminal)")
370
+
371
+ proc = psutil.Process(pid)
372
+ if create_time is not None and proc.create_time() != create_time:
373
+ raise KillRefused(f"el PID {pid} ya no es el proceso que se escaneo")
374
+
375
+ nombre = _quiet(proc.name) or ""
376
+ motor = PROXY_NAMES.get(nombre.lower())
377
+ if motor is not None:
378
+ raise KillRefused(_proxy_message(nombre, motor, port))
379
+
380
+ proc.terminate()
381
+ try:
382
+ proc.wait(TERMINATE_TIMEOUT)
383
+ return
384
+ except psutil.TimeoutExpired:
385
+ pass
386
+
387
+ if not force:
388
+ raise KillRefused(
389
+ f"PID {pid} ignoro terminate tras {TERMINATE_TIMEOUT}s; usa --force"
390
+ )
391
+ proc.kill()
392
+ proc.wait(TERMINATE_TIMEOUT)