Qui tient un port, et qui descend de qui — d'après le système, pas d'après nous.
Ce module existe parce que quatre chemins de démarrage différents posaient la
même question et y répondaient chacun à leur façon : diapason start,
l'application de bureau, serve-service install et quickstart.sh. Trois
d'entre eux ne la posaient même pas, et lançaient un serveur par-dessus un
autre sans qu'aucune erreur ne soit rapportée.
Le principe : ne rien DÉDUIRE. Une liaison qui échoue, un 200 sur un port,
une ligne de commande recollée sont des indices, pas des preuves. On demande au
noyau, et quand il ne répond pas, on le dit au lieu de conclure.
Functions:
run_tool(
argv: list[str], *, timeout: float = 10.0
) -> CompletedProcess | None
Lancer un utilitaire système. None si on n'a PAS PU demander.
La distinction compte : « je n'ai pas pu demander » n'est pas « la réponse
est non ». Confondre les deux faisait effacer le fichier PID d'un serveur
parfaitement vivant, qui devenait alors inarrêtable par la CLI.
Source code in src/diapason/core/ports.py
| def run_tool(
argv: list[str], *, timeout: float = 10.0
) -> subprocess.CompletedProcess | None:
"""Lancer un utilitaire système. None si on n'a PAS PU demander.
La distinction compte : « je n'ai pas pu demander » n'est pas « la réponse
est non ». Confondre les deux faisait effacer le fichier PID d'un serveur
parfaitement vivant, qui devenait alors inarrêtable par la CLI.
"""
try:
return subprocess.run(
argv,
capture_output=True,
text=True,
timeout=timeout,
# LC_ALL=C : on ne compare jamais de la prose traduite.
env={"LC_ALL": "C", "PATH": os.environ.get("PATH", "") + ":" + PATH_SYS},
)
except (OSError, subprocess.SubprocessError):
return None
|
listeners_on
listeners_on(port: int) -> list[tuple[int, str]] | None
Qui ÉCOUTE sur ce port, d'après le NOYAU. None = indéterminé.
Sonder en tentant des liaisons, c'est deviner : quatre adresses littérales
ne couvriront jamais les N adresses d'une machine. MESURÉ — un serveur lié
à 192.168.0.121, exactement ce que « serve-service install --allow-network »
installe pour que le téléphone du maillage atteigne ce Mac, échappait aux
quatre sondes, et « diapason start » en lançait tranquillement un second.
lsof répond pour toutes les adresses à la fois, et donne le PID du
détenteur — ce dont la confirmation d'après-lancement a besoin.
Source code in src/diapason/core/ports.py
| def listeners_on(port: int) -> list[tuple[int, str]] | None:
"""Qui ÉCOUTE sur ce port, d'après le NOYAU. None = indéterminé.
Sonder en tentant des liaisons, c'est deviner : quatre adresses littérales
ne couvriront jamais les N adresses d'une machine. MESURÉ — un serveur lié
à 192.168.0.121, exactement ce que « serve-service install --allow-network »
installe pour que le téléphone du maillage atteigne ce Mac, échappait aux
quatre sondes, et « diapason start » en lançait tranquillement un second.
``lsof`` répond pour toutes les adresses à la fois, et donne le PID du
détenteur — ce dont la confirmation d'après-lancement a besoin.
"""
if sys.platform == "win32":
return listeners_windows(port)
sortie = run_tool(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-F", "pn"])
if sortie is None:
return None
if sortie.returncode not in (0, 1):
return None
if sortie.returncode == 1 and sortie.stdout.strip():
return None # code d'erreur avec du bruit : on ne conclut rien
trouves: list[tuple[int, str]] = []
pid: int | None = None
for ligne in sortie.stdout.splitlines():
if ligne.startswith("p"):
try:
pid = int(ligne[1:])
except ValueError:
pid = None
elif ligne.startswith("n") and pid is not None:
trouves.append((pid, ligne[1:]))
return trouves
|
listeners_windows
listeners_windows(
port: int,
) -> list[tuple[int, str]] | None
Équivalent Windows de lsof, via netstat -ano.
NON VÉRIFIÉ sur cette machine, faute de Windows sous la main : la forme est
documentée, pas mesurée, à la différence du reste de ce fichier. Elle existe
surtout pour que start ne réponde pas « je ne sais pas » à l'infini là
où lsof n'existe pas — un garde qui refuse toujours de démarrer serait une
panne, pas une protection.
Source code in src/diapason/core/ports.py
| def listeners_windows(port: int) -> list[tuple[int, str]] | None:
"""Équivalent Windows de ``lsof``, via ``netstat -ano``.
NON VÉRIFIÉ sur cette machine, faute de Windows sous la main : la forme est
documentée, pas mesurée, à la différence du reste de ce fichier. Elle existe
surtout pour que ``start`` ne réponde pas « je ne sais pas » à l'infini là
où lsof n'existe pas — un garde qui refuse toujours de démarrer serait une
panne, pas une protection.
"""
sortie = run_tool(["netstat", "-ano", "-p", "TCP"])
if sortie is None or sortie.returncode != 0:
return None
trouves: list[tuple[int, str]] = []
for ligne in sortie.stdout.splitlines():
morceaux = ligne.split()
if len(morceaux) < 5 or morceaux[0].upper() != "TCP":
continue
if morceaux[3].upper() != "LISTENING":
continue
locale = morceaux[1]
if not locale.rsplit(":", 1)[-1].isdigit():
continue
if int(locale.rsplit(":", 1)[-1]) != port:
continue
try:
trouves.append((int(morceaux[4]), locale))
except ValueError:
continue
return trouves
|
port_occupe_par_liaison
port_occupe_par_liaison(port: int) -> bool | None
Dernier repli : tenter la liaison soi-même. None si on n'apprend rien.
Ne voit qu'un détenteur sur l'une des quatre formes essayées — c'est
précisément la faiblesse qui a laissé passer un serveur lié à l'adresse du
maillage. On ne s'en sert donc que si le système n'a pas su répondre.
SO_REUSEADDR seulement sur POSIX : il y ignore TIME_WAIT, sans quoi un
port qu'on vient de libérer paraîtrait pris. Sur Windows sa sémantique est
INVERSE — il autorise à se lier par-dessus un socket actif — et le poser
annulerait entièrement le contrôle.
Source code in src/diapason/core/ports.py
| def port_occupe_par_liaison(port: int) -> bool | None:
"""Dernier repli : tenter la liaison soi-même. None si on n'apprend rien.
Ne voit qu'un détenteur sur l'une des quatre formes essayées — c'est
précisément la faiblesse qui a laissé passer un serveur lié à l'adresse du
maillage. On ne s'en sert donc que si le système n'a pas su répondre.
``SO_REUSEADDR`` seulement sur POSIX : il y ignore TIME_WAIT, sans quoi un
port qu'on vient de libérer paraîtrait pris. Sur Windows sa sémantique est
INVERSE — il autorise à se lier par-dessus un socket actif — et le poser
annulerait entièrement le contrôle.
"""
import errno
import socket
formes = (
(socket.AF_INET, "0.0.0.0"),
(socket.AF_INET, "127.0.0.1"),
(socket.AF_INET6, "::"),
(socket.AF_INET6, "::1"),
)
a_pu_tester = False
for famille, adresse in formes:
try:
sonde = socket.socket(famille, socket.SOCK_STREAM)
except OSError:
continue
if sys.platform != "win32":
sonde.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if famille == socket.AF_INET6:
with contextlib.suppress(OSError):
sonde.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
try:
sonde.bind((adresse, port))
a_pu_tester = True
except OSError as erreur:
if erreur.errno == errno.EADDRINUSE:
sonde.close()
return True
finally:
sonde.close()
return False if a_pu_tester else None
|
descendants_of
descendants_of(racine: int) -> set[int]
racine et toute sa descendance, d'après ps.
Un serveur peut être lancé derrière un lanceur (uv run), auquel cas le
processus qui tient le port n'est pas celui qu'on a lancé, mais son enfant.
Source code in src/diapason/core/ports.py
| def descendants_of(racine: int) -> set[int]:
"""``racine`` et toute sa descendance, d'après ``ps``.
Un serveur peut être lancé derrière un lanceur (``uv run``), auquel cas le
processus qui tient le port n'est pas celui qu'on a lancé, mais son enfant.
"""
sortie = run_tool(["ps", "-eo", "pid=,ppid="])
if sortie is None or sortie.returncode != 0:
return {racine}
enfants: dict[int, list[int]] = {}
for ligne in sortie.stdout.splitlines():
morceaux = ligne.split()
if len(morceaux) != 2:
continue
try:
fils, pere = int(morceaux[0]), int(morceaux[1])
except ValueError:
continue
enfants.setdefault(pere, []).append(fils)
vus = {racine}
a_voir = [racine]
while a_voir:
courant = a_voir.pop()
for fils in enfants.get(courant, ()):
if fils not in vus:
vus.add(fils)
a_voir.append(fils)
return vus
|
port_state
port_state(port: int) -> tuple[str, str]
État du port et description lisible du détenteur.
Source code in src/diapason/core/ports.py
| def port_state(port: int) -> tuple[str, str]:
"""État du port et description lisible du détenteur."""
auditeurs = listeners_on(port)
if auditeurs is None:
# Le système n'a pas su répondre. Plutôt que de refuser tout démarrage
# à jamais, on retombe sur la liaison directe — partielle, mais valant
# mieux que rien — et on ne rend INCONNU que si elle échoue aussi.
par_liaison = port_occupe_par_liaison(port)
if par_liaison is True:
return OCCUPE, f"port {port} occupé (détecté par liaison directe)"
if par_liaison is False:
return LIBRE, ""
return INCONNU, "ni lsof ni liaison directe n'ont pu répondre"
if not auditeurs:
return LIBRE, ""
detail = ", ".join(f"PID {p} sur {adresse}" for p, adresse in auditeurs)
return OCCUPE, detail
|