Match the blocking listener address and stop trusting unverifiable PID records
This commit is contained in:
parent
e897c025c9
commit
24b53bc251
4 changed files with 146 additions and 32 deletions
|
|
@ -689,27 +689,47 @@ def _get_pid_on_port(port: int) -> "tuple[int, str] | None":
|
|||
return None
|
||||
|
||||
|
||||
def _get_pids_on_port(port: int) -> "list[tuple[int, str]]":
|
||||
"""Every listener on *port*. A port can have one per bind address, and
|
||||
checking only the first makes the own-Studio decision arbitrary."""
|
||||
def _get_pids_on_port(port: int) -> "list[tuple[int, str, str]]":
|
||||
"""(pid, name, bind address) for every listener on *port*. A port can have one
|
||||
per address, and checking only the first makes the own-Studio call arbitrary."""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return []
|
||||
found: "dict[int, str]" = {}
|
||||
found: "dict[tuple[int, str], tuple[int, str, str]]" = {}
|
||||
try:
|
||||
for conn in psutil.net_connections(kind = "tcp"):
|
||||
if conn.status != "LISTEN" or conn.laddr.port != port or conn.pid is None:
|
||||
continue
|
||||
if conn.pid in found:
|
||||
key = (conn.pid, conn.laddr.ip)
|
||||
if key in found:
|
||||
continue
|
||||
try:
|
||||
found[conn.pid] = psutil.Process(conn.pid).name()
|
||||
name = psutil.Process(conn.pid).name()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
found[conn.pid] = "<unknown>"
|
||||
name = "<unknown>"
|
||||
found[key] = (conn.pid, name, conn.laddr.ip)
|
||||
except (psutil.AccessDenied, OSError) as e:
|
||||
logger.debug("Failed to scan network connections for port %s: %s", port, e)
|
||||
return list(found.items())
|
||||
return list(found.values())
|
||||
|
||||
|
||||
def _listener_blocks_host(listen_ip: str, host: str) -> bool:
|
||||
"""Would a listener on *listen_ip* block a bind to *host*?
|
||||
|
||||
Wildcards on either side collide with everything; otherwise compare resolved
|
||||
addresses. Unresolvable means assume a collision rather than start a duplicate.
|
||||
"""
|
||||
wildcards = ("0.0.0.0", "::", "")
|
||||
if listen_ip in wildcards or host in wildcards:
|
||||
return True
|
||||
import socket
|
||||
|
||||
try:
|
||||
host_ips = {info[4][0] for info in socket.getaddrinfo(host, None)}
|
||||
except OSError:
|
||||
return True
|
||||
return listen_ip in host_ips
|
||||
|
||||
|
||||
def _is_port_free(host: str, port: int) -> bool:
|
||||
|
|
@ -768,7 +788,7 @@ def _find_free_port(
|
|||
if _is_port_free(host, candidate):
|
||||
return candidate
|
||||
if avoid_own_studio:
|
||||
own = _blocker_is_own_studio(_get_pids_on_port(candidate))
|
||||
own = _blocker_is_own_studio(_get_pids_on_port(candidate), host)
|
||||
if own is not None:
|
||||
_abort_already_running(own, candidate)
|
||||
raise RuntimeError(f"Could not find a free port in range {start}-{start + max_attempts - 1}")
|
||||
|
|
@ -836,22 +856,35 @@ def _pid_is_studio_backend(pid: int, created_times: "Sequence[float | None]" = (
|
|||
in-venv path runs run_server() in-process, so its argv is `unsloth studio ...`.
|
||||
"""
|
||||
known = [c for c in created_times if c is not None]
|
||||
untimed = not created_times or len(known) < len(created_times)
|
||||
if known:
|
||||
actual = _process_create_time(pid)
|
||||
# Unknowable without psutil: trust the record rather than guess.
|
||||
return actual is None or any(abs(actual - c) < 1.0 for c in known)
|
||||
if actual is None:
|
||||
return untimed
|
||||
if any(abs(actual - c) < 1.0 for c in known):
|
||||
return True
|
||||
if not untimed:
|
||||
return False
|
||||
try:
|
||||
import psutil
|
||||
cmdline = " ".join(psutil.Process(pid).cmdline()).lower()
|
||||
except Exception:
|
||||
return True
|
||||
return untimed
|
||||
return "studio" in cmdline and ("run.py" in cmdline or "unsloth" in cmdline)
|
||||
|
||||
|
||||
def _blocker_is_own_studio(blockers: "list[tuple[int, str]]") -> "int | None":
|
||||
"""PID of one of our recorded servers holding the port, if any."""
|
||||
def _blocker_is_own_studio(
|
||||
blockers: "list[tuple[int, str, str]]", host: "str | None" = None
|
||||
) -> "int | None":
|
||||
"""PID of one of our recorded servers actually blocking *host*, if any.
|
||||
|
||||
Address-matched: Jupyter on 127.0.0.1:8889 and our server on ::1:8889 is not a
|
||||
conflict for `-H 127.0.0.1`, and must still fall through to the next port.
|
||||
"""
|
||||
recorded = _recorded_studio_records()
|
||||
for pid, _name in blockers:
|
||||
for pid, _name, listen_ip in blockers:
|
||||
if host is not None and not _listener_blocks_host(listen_ip, host):
|
||||
continue
|
||||
if pid in recorded and _pid_is_studio_backend(pid, recorded[pid]):
|
||||
return pid
|
||||
return None
|
||||
|
|
@ -1691,7 +1724,7 @@ def run_server(
|
|||
original_port = port
|
||||
blocker = _get_pid_on_port(port)
|
||||
# Falling back past our own server is what creates the orphan.
|
||||
own = _blocker_is_own_studio(_get_pids_on_port(port))
|
||||
own = _blocker_is_own_studio(_get_pids_on_port(port), host)
|
||||
if own is not None:
|
||||
_abort_already_running(own, port)
|
||||
port = _find_free_port(host, port + 1, avoid_own_studio = True)
|
||||
|
|
|
|||
|
|
@ -130,15 +130,15 @@ def test_recorded_records_prune_dead_entries(tmp_path, monkeypatch):
|
|||
def test_own_studio_blocking_the_port_is_recognised(tmp_path):
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._blocker_is_own_studio([(8550, "python")]) == 8550
|
||||
assert run._blocker_is_own_studio([(8550, "python", "127.0.0.1")], "127.0.0.1") == 8550
|
||||
|
||||
|
||||
def test_a_foreign_blocker_still_falls_back(tmp_path):
|
||||
# jupyter-lab on 8888 must keep the fallback, not abort the launch.
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._blocker_is_own_studio([(117, "jupyter-lab")]) is None
|
||||
assert run._blocker_is_own_studio([]) is None
|
||||
assert run._blocker_is_own_studio([(117, "jupyter-lab", "127.0.0.1")], "127.0.0.1") is None
|
||||
assert run._blocker_is_own_studio([], "127.0.0.1") is None
|
||||
|
||||
|
||||
def test_our_studio_is_found_behind_a_foreign_listener(tmp_path):
|
||||
|
|
@ -146,7 +146,12 @@ def test_our_studio_is_found_behind_a_foreign_listener(tmp_path):
|
|||
# decision depend on psutil's ordering.
|
||||
(tmp_path / "studio-8889-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._blocker_is_own_studio([(117, "jupyter-lab"), (8550, "python")]) == 8550
|
||||
assert (
|
||||
run._blocker_is_own_studio(
|
||||
[(117, "jupyter-lab", "127.0.0.1"), (8550, "python", "127.0.0.1")], "127.0.0.1"
|
||||
)
|
||||
== 8550
|
||||
)
|
||||
|
||||
|
||||
def test_a_reused_pid_is_not_treated_as_our_studio(tmp_path, monkeypatch):
|
||||
|
|
@ -154,7 +159,7 @@ def test_a_reused_pid_is_not_treated_as_our_studio(tmp_path, monkeypatch):
|
|||
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): False)
|
||||
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._blocker_is_own_studio([(8550, "postgres")]) is None
|
||||
assert run._blocker_is_own_studio([(8550, "postgres", "127.0.0.1")], "127.0.0.1") is None
|
||||
|
||||
|
||||
def test_start_time_mismatch_rejects_a_reused_pid(monkeypatch):
|
||||
|
|
@ -218,6 +223,36 @@ def test_legacy_records_do_not_match_a_training_run(monkeypatch):
|
|||
assert run._pid_is_studio_backend(9999) is False
|
||||
|
||||
|
||||
def test_our_studio_on_another_bind_address_does_not_abort(tmp_path):
|
||||
# Jupyter holds 127.0.0.1:8889, our server holds ::1:8889. Binding 127.0.0.1
|
||||
# conflicts with Jupyter, not with us, so fall through to the next port.
|
||||
(tmp_path / "studio-8889-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
blockers = [(117, "jupyter-lab", "127.0.0.1"), (8550, "python", "::1")]
|
||||
|
||||
assert run._blocker_is_own_studio(blockers, "127.0.0.1") is None
|
||||
assert run._blocker_is_own_studio(blockers, "::1") == 8550
|
||||
|
||||
|
||||
def test_a_wildcard_listener_blocks_any_bind(tmp_path):
|
||||
(tmp_path / "studio-8889-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._blocker_is_own_studio([(8550, "python", "0.0.0.0")], "127.0.0.1") == 8550
|
||||
|
||||
|
||||
def test_a_wildcard_bind_is_blocked_by_any_listener(tmp_path):
|
||||
(tmp_path / "studio-8889-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
||||
assert run._blocker_is_own_studio([(8550, "python", "127.0.0.1")], "0.0.0.0") == 8550
|
||||
|
||||
|
||||
def test_listener_address_matching(monkeypatch):
|
||||
assert run._listener_blocks_host("0.0.0.0", "127.0.0.1") is True
|
||||
assert run._listener_blocks_host("127.0.0.1", "0.0.0.0") is True
|
||||
assert run._listener_blocks_host("127.0.0.1", "127.0.0.1") is True
|
||||
assert run._listener_blocks_host("::1", "127.0.0.1") is False
|
||||
|
||||
|
||||
def test_fallback_aborts_on_our_own_server_further_up_the_range(tmp_path, monkeypatch):
|
||||
# jupyter holds 8888, our server holds 8889: skipping to 8890 is the duplicate.
|
||||
(tmp_path / "studio-8889-8550.pid").write_text("8550", encoding = "utf-8")
|
||||
|
|
@ -225,7 +260,9 @@ def test_fallback_aborts_on_our_own_server_further_up_the_range(tmp_path, monkey
|
|||
monkeypatch.setattr(
|
||||
run,
|
||||
"_get_pids_on_port",
|
||||
lambda p: [(8550, "python")] if p == 8889 else [(117, "jupyter-lab")],
|
||||
lambda p: (
|
||||
[(8550, "python", "127.0.0.1")] if p == 8889 else [(117, "jupyter-lab", "127.0.0.1")]
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
|
|
@ -236,6 +273,6 @@ def test_fallback_aborts_on_our_own_server_further_up_the_range(tmp_path, monkey
|
|||
|
||||
def test_fallback_still_skips_foreign_processes(monkeypatch):
|
||||
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p >= 8890)
|
||||
monkeypatch.setattr(run, "_get_pids_on_port", lambda p: [(117, "jupyter-lab")])
|
||||
monkeypatch.setattr(run, "_get_pids_on_port", lambda p: [(117, "jupyter-lab", "127.0.0.1")])
|
||||
|
||||
assert run._find_free_port("127.0.0.1", 8889, avoid_own_studio = True) == 8890
|
||||
|
|
|
|||
|
|
@ -2472,22 +2472,34 @@ def _pid_file_entries() -> "list[tuple[int, list[float | None], list[Path]]]":
|
|||
|
||||
|
||||
def _pid_is_studio_server(pid: int, created_times: "Sequence[float | None]" = ()) -> bool:
|
||||
"""Guard against PID reuse: a stale record must not get an unrelated process
|
||||
killed. Any recorded start time matching is enough. The cmdline check only
|
||||
covers legacy records -- and the in-venv path runs run_server() in-process, so
|
||||
its argv is `unsloth studio ...` with no run.py."""
|
||||
"""Guard against PID reuse: a stale record must not get an unrelated process killed.
|
||||
|
||||
A start time only exists if psutil was available when the server started, so
|
||||
without psutil now such a record cannot be trusted -- but untimed (legacy)
|
||||
records must keep working, or `stop` stops nothing on a psutil-less install.
|
||||
The cmdline check covers those: the in-venv path runs run_server() in-process,
|
||||
so its argv is `unsloth studio ...` with no run.py.
|
||||
"""
|
||||
known = [c for c in created_times if c is not None]
|
||||
untimed = not created_times or len(known) < len(created_times)
|
||||
try:
|
||||
import psutil
|
||||
|
||||
proc = psutil.Process(pid)
|
||||
if known:
|
||||
except Exception:
|
||||
return untimed
|
||||
if known:
|
||||
try:
|
||||
actual = proc.create_time()
|
||||
return any(abs(actual - c) < 1.0 for c in known)
|
||||
except Exception:
|
||||
return untimed
|
||||
if any(abs(actual - c) < 1.0 for c in known):
|
||||
return True
|
||||
if not untimed:
|
||||
return False
|
||||
try:
|
||||
cmdline = " ".join(proc.cmdline()).lower()
|
||||
except Exception:
|
||||
# Unknowable without psutil: trust the record rather than never stopping.
|
||||
return True
|
||||
return untimed
|
||||
return "studio" in cmdline and ("run.py" in cmdline or "unsloth" in cmdline)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,38 @@ def test_pid_identity_check_accepts_an_in_process_studio(monkeypatch):
|
|||
assert studio_mod._pid_is_studio_server(8550) is True
|
||||
|
||||
|
||||
def test_a_timestamped_record_is_not_trusted_without_psutil(monkeypatch):
|
||||
# psutil is not a base CLI dependency. A start time only exists if psutil was
|
||||
# present when the server started, so without it now the record is unverifiable
|
||||
# and must not get a reused PID killed.
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setitem(sys.modules, "psutil", None)
|
||||
|
||||
assert studio_mod._pid_is_studio_server(8550, [111.5]) is False
|
||||
assert studio_mod._pid_is_studio_server(8550, [None]) is True
|
||||
|
||||
|
||||
def test_a_legacy_record_survives_a_stale_timestamp_for_the_same_pid(monkeypatch):
|
||||
# Stale per-port file + live legacy studio.pid sharing a reused PID: judging
|
||||
# only by the stale timestamp would drop the live server.
|
||||
studio_mod = _studio()
|
||||
|
||||
class _FakeProcess:
|
||||
def __init__(self, pid):
|
||||
self.pid = pid
|
||||
|
||||
def create_time(self):
|
||||
return 999.0
|
||||
|
||||
def cmdline(self):
|
||||
return ["/venv/bin/unsloth", "studio", "-p", "8901"]
|
||||
|
||||
monkeypatch.setitem(sys.modules, "psutil", SimpleNamespace(Process = _FakeProcess))
|
||||
|
||||
assert studio_mod._pid_is_studio_server(8550, [111.5, None]) is True
|
||||
assert studio_mod._pid_is_studio_server(8550, [111.5]) is False
|
||||
|
||||
|
||||
def test_pid_identity_check_trusts_the_record_without_psutil(monkeypatch):
|
||||
# No psutil: fall back to trusting the record rather than never stopping.
|
||||
studio_mod = _studio()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue