unsloth/studio/backend/tests/test_studio_pid_files.py
Nilay ceef4123e6
Studio: Stop every running Unsloth server, not just the last one recorded (#7577)
* Stop every running Unsloth server, and refuse to start a second on a taken port

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Check the fallback range, guard PID reuse, and keep writing studio.pid

* Signal each server once when its PID is recorded in more than one file

* Confirm a recorded PID is a Studio server before signalling it

* Pin PID records to process start time and check every listener on a port

* Keep every recorded start time per PID and accept in-process Studio servers

* Match the blocking listener address and stop trusting unverifiable PID records

* Never delete a PID record that cannot be verified

* Detect our own server from our own records instead of a psutil listener scan

* Match a pre-upgrade studio.pid to the blocked port before falling back

* Never signal PID 0 or 1, and verify a per-port record before trusting it

* Stop unverifiable records instead of skipping them, and record every bind address

* Drop the command-line guess, fix Windows liveness, and free the PID record last

* Studio: harden the per-port PID records against the cases that lose a server

Follow-up on the per-port PID files. Each item below is a case where the new
code either lost a server the old code could still stop, or stopped something
that was not ours. All were reproduced against real Studio servers.

studio/backend/run.py

- Write the per-port record and the legacy studio.pid independently. They shared
  one try, so a studio root that could not take a new directory entry left the
  server recorded nowhere at all and unstoppable from the CLI; the old code
  still recorded it in studio.pid, which is an overwrite of an existing path and
  can still succeed. _remove_pid_file now also checks studio.pid when the
  per-port write failed.
- Write the record through a temp file and os.replace. `stop` reads these
  concurrently and treats a truncated read as a corrupt record.
- A failed Windows tasklist probe now means "alive", matching the CLI. Treating
  it as dead pruned a live server's record and let the next launch fall back
  past it, which is the orphan this work exists to fix.
- Guard the unlink in _own_studio_on_port. Pruning is a courtesy and must not
  abort startup.
- Extract _resolve_port so the requested-port abort is reachable from a test.
  Deleting that abort previously left the whole suite green.
- Keep the plain fallback for api-only callers. The desktop app hardcodes 8888
  and documents its reliance on the 8888-8908 range, and it reports a non-zero
  backend exit to the user as "Server stopped unexpectedly". It reads the bound
  port back from TAURI_PORT, as `studio run` does from app.state.server_port, so
  a fallback there is harmless and both servers are still recorded and
  stoppable. The interactive path prints the requested port, so it still aborts.
- isdigit() is not enough to gate int(): a superscript two passes it and the
  ValueError escaped into every caller of _read_pid_record.

unsloth_cli/commands/studio.py

- An untimed record no longer cancels a timed one for the same PID. Every
  current server writes both a timed per-port record and an untimed studio.pid,
  so the start-time check was inert exactly where it mattered, and after a crash
  plus a PID reuse `stop` sent SIGTERM to whatever unrelated process had
  inherited the PID.
- Distinguish an unreadable record from an invalid one. A root-owned record, or
  one caught mid-write, still belongs to a live server, and deleting it stranded
  that server.
- Route every PID-file removal through _unlink_quietly. One undeletable record
  raised PermissionError and left the remaining live servers running.
- Same isdigit()/int() guard as the backend.

Tests

- The requested-port abort, the recorded bind address, and the api-only
  fallback are now covered; all three previously survived deletion.
- tests/studio/test_studio_pid_file_contract.py pins run.py's filename scheme to
  the CLI's glob and keeps studio.pid parseable by an older CLI. It lives under
  tests/studio because unsloth_cli/tests is not run by any workflow.
- test_cli_studio_stop_windows.py now checks _signal_stop as well as stop. The
  kill moved into _signal_stop, so the os.kill(pid, 0) guard passed vacuously.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: let a caller that follows the port keep the fallback, and never take studio.pid from a live server

Two problems with keying the own-server abort on api_only.

`unsloth studio run` is not the bare-banner path: it stores `app = run_server(...)`
and reads `app.state.server_port` back, then uses it for the health wait, the
model load and the printed base URL. Gating on api_only aborted it, so starting
a second model while the first was up stopped working, where before it landed on
the next port and printed the right URL. Replace the proxy with an explicit
abort_if_own_studio, defaulting to the old api_only behaviour so the exec'd
`run.py` path is unchanged, and have `studio run` opt out.

The api_only exemption also reopened the orphan from the other side.
_write_pid_file overwrote studio.pid unconditionally, and a pre-upgrade server
is recorded there and nowhere else, so an exempt launch falling back past one
erased its only record. Take the file over only when it is free, already ours,
or held by a dead PID.

Also resync _pid_is_studio_backend with the CLI copy: an untimed record next to
a timed one carried no information but cancelled the start-time check, which is
what let a reused PID be treated as ours.

Tests: 51 backend, 26 CLI, 9 under tests/studio. Real Studio servers still abort
the bare same-port relaunch, still fall back past a foreign listener, and one
`unsloth studio stop` still stops every server in all five scenarios.

* Studio: hand over the legacy PID pointer, and fail stop on unreadable records

Two follow-ups from review of the previous commit.

Only one backend owns studio.pid at a time. When that server exited it
deleted the file, so an older CLI, which reads nothing else, could no
longer stop a sibling that was still serving. _remove_pid_file now hands
the pointer to a live sibling instead of dropping it.

_pid_file_entries skipped records it could not read, for instance one
written by a server started under sudo. When that was the only record,
stop printed "No running Unsloth server found" and exited 0 while the
server kept serving. Unreadable records are now reported and make stop
exit 1, so a partial stop is never mistaken for a complete one.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <unslothai@gmail.com>
2026-07-29 01:56:13 -07:00

568 lines
23 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Per-port PID files, so `unsloth studio stop` can find every server.
Imports run.py directly, so run under the Unsloth venv.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
_BACKEND = Path(__file__).resolve().parents[1]
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
import run # noqa: E402
# Captured before the autouse fixture stubs them, for the tests that exercise them.
_REAL_IS_STUDIO_BACKEND = run._pid_is_studio_backend
_REAL_PID_ALIVE = run._pid_alive
@pytest.fixture(autouse = True)
def isolated_root(tmp_path, monkeypatch):
monkeypatch.setattr(run, "_studio_root", lambda: tmp_path)
monkeypatch.setattr(run, "_PID_FILE", tmp_path / "studio.pid")
monkeypatch.setattr(run, "_OWN_PID_FILE", None)
monkeypatch.setattr(run, "_pid_alive", lambda pid: True)
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): True)
yield
def _files(tmp_path):
return sorted(p.name for p in tmp_path.glob("studio-*.pid"))
def _pid_of(path):
return path.read_text(encoding = "utf-8").splitlines()[0]
def test_write_pid_file_records_port_and_pid(tmp_path):
run._write_pid_file(8901)
assert _files(tmp_path) == [f"studio-8901-{os.getpid()}.pid"]
assert _pid_of(tmp_path / f"studio-8901-{os.getpid()}.pid") == str(os.getpid())
def test_write_pid_file_records_the_start_time(tmp_path):
# Pins the record to this process, so a reused PID isn't mistaken for it.
run._write_pid_file(8901)
record = run._read_pid_record(tmp_path / f"studio-8901-{os.getpid()}.pid")
assert record[0] == os.getpid()
assert record[1] == pytest.approx(run._process_create_time(os.getpid()))
def test_write_pid_file_keeps_the_legacy_file_a_bare_pid(tmp_path):
# An older CLI's `stop` reads studio.pid and expects only digits.
run._write_pid_file(8901)
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == str(os.getpid())
def test_second_port_does_not_clobber_the_first(tmp_path):
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
run._write_pid_file(8902)
assert _pid_of(tmp_path / "studio-8901-8550.pid") == "8550"
assert (tmp_path / f"studio-8902-{os.getpid()}.pid").exists()
def test_same_port_on_two_binds_does_not_clobber(tmp_path):
# 127.0.0.1:8888 and ::1:8888 can both listen; one file per port would lose one.
(tmp_path / "studio-8888-8550.pid").write_text("8550", encoding = "utf-8")
run._write_pid_file(8888)
assert len(_files(tmp_path)) == 2
def test_remove_pid_file_only_removes_our_own(tmp_path, monkeypatch):
run._write_pid_file(8901)
(tmp_path / "studio-8902-8600.pid").write_text("8600", encoding = "utf-8")
# Nothing to hand the legacy pointer to, so it goes away with us.
monkeypatch.setattr(run, "_pid_alive", lambda pid: pid == os.getpid())
run._remove_pid_file()
assert _files(tmp_path) == ["studio-8902-8600.pid"]
assert not (tmp_path / "studio.pid").exists()
def test_the_legacy_pointer_moves_to_a_live_sibling(tmp_path):
# Only one server owns studio.pid. Deleting it on our way out would leave an
# older CLI, which reads nothing else, unable to stop the sibling still up.
run._write_pid_file(8901)
(tmp_path / "studio-8902-8600.pid").write_text("8600", encoding = "utf-8")
run._remove_pid_file()
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8").strip() == "8600"
def test_the_legacy_pointer_is_not_handed_to_a_dead_sibling(tmp_path, monkeypatch):
run._write_pid_file(8901)
(tmp_path / "studio-8902-8600.pid").write_text("8600", encoding = "utf-8")
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): False)
run._remove_pid_file()
assert not (tmp_path / "studio.pid").exists()
def test_remove_pid_file_leaves_a_reused_entry_alone(tmp_path):
run._write_pid_file(8901)
own = tmp_path / f"studio-8901-{os.getpid()}.pid"
own.write_text("999999", encoding = "utf-8")
run._remove_pid_file()
assert own.read_text(encoding = "utf-8") == "999999"
def test_windows_liveness_does_not_call_every_pid_alive(monkeypatch):
# os.kill(pid, 0) raises OSError for every pid on Windows, so without the
# tasklist fallback a stale record would block its port forever.
import subprocess
monkeypatch.setattr(run, "_pid_alive", _REAL_PID_ALIVE)
monkeypatch.setitem(sys.modules, "psutil", None)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(
subprocess, "run", lambda *a, **k: SimpleNamespace(stdout = '"python.exe","8550",...')
)
assert run._pid_alive(8550) is True
assert run._pid_alive(9999) is False
def test_windows_liveness_keeps_the_record_when_tasklist_fails(monkeypatch):
# Unconfirmed must mean keep, matching the CLI's _pid_alive. Pruning a live
# server's record lets the next launch fall back past it and strand it, which
# is the bug this file exists to fix; a stale record costs one clear abort.
import subprocess
def _boom(*a, **k):
raise OSError("tasklist missing")
monkeypatch.setattr(run, "_pid_alive", _REAL_PID_ALIVE)
monkeypatch.setitem(sys.modules, "psutil", None)
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setattr(subprocess, "run", _boom)
assert run._pid_alive(8550) is True
def test_read_pid_record_parses_pid_time_and_address(tmp_path):
(tmp_path / "r.pid").write_text("8550\n111.5\n127.0.0.1", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "r.pid") == (8550, 111.5, "127.0.0.1")
def test_read_pid_record_tolerates_a_bare_pid(tmp_path):
(tmp_path / "r.pid").write_text("8550", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "r.pid") == (8550, None, None)
def test_read_pid_record_rejects_pid_zero_and_init(tmp_path):
# kill(0) signals our whole process group.
(tmp_path / "zero.pid").write_text("0", encoding = "utf-8")
(tmp_path / "init.pid").write_text("1", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "zero.pid") is None
assert run._read_pid_record(tmp_path / "init.pid") is None
def test_read_pid_record_rejects_a_corrupt_file(tmp_path):
(tmp_path / "r.pid").write_text("not-a-pid", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "r.pid") is None
def test_graceful_shutdown_drops_the_record_last(monkeypatch):
# Cleanup can take seconds while the server is still alive. Dropping the record
# first leaves a retried `stop` or a new launch unable to find it.
order = []
monkeypatch.setattr(run, "_remove_pid_file", lambda: order.append("remove_record"))
class _Server:
def __setattr__(self, name, value):
order.append("release_socket")
run._graceful_shutdown(_Server())
assert order == ["release_socket", "remove_record"]
def test_own_studio_on_port_is_found_without_psutil(tmp_path, monkeypatch):
# psutil is optional; a listener scan finds nothing without it, so detection
# must come from our own records or we silently start a duplicate.
monkeypatch.setitem(sys.modules, "psutil", None)
(tmp_path / "studio-8901-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_no_record_for_the_port_means_no_own_studio(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._own_studio_on_port(8888, "127.0.0.1") is None
def test_own_studio_on_port_prunes_a_dead_record(tmp_path, monkeypatch):
monkeypatch.setattr(run, "_pid_alive", lambda pid: False)
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") is None
assert not (tmp_path / "studio-8901-8550.pid").exists()
def test_a_reused_pid_is_not_treated_as_our_studio(tmp_path, monkeypatch):
# Stale record + the OS handing that PID to something else must not abort.
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._own_studio_on_port(8901, "127.0.0.1") is None
def test_an_unverifiable_record_still_blocks_a_duplicate(tmp_path, monkeypatch):
# Can't tell: refusing with a clear message beats a silent second instance.
monkeypatch.setattr(run, "_pid_is_studio_backend", lambda pid, created_times = (): True)
(tmp_path / "studio-8901-8550.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_start_time_mismatch_rejects_a_reused_pid(monkeypatch):
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(8550, [111.5]) is False
assert run._pid_is_studio_backend(8550, [999.0]) is True
def test_a_stale_record_does_not_veto_a_live_server_sharing_the_pid(monkeypatch):
# Crash leaves studio-8888-1234.pid, the OS reuses 1234 for a new server on
# another port. Keeping only the first timestamp would reject the live one.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(1234, [111.5, 999.0]) is True
assert run._pid_is_studio_backend(1234, [111.5, 222.5]) is False
def test_a_stale_record_on_another_port_does_not_hide_a_live_server(tmp_path, monkeypatch):
# 1234 was reused: the stale 8888 record must not stop us seeing 9000.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
(tmp_path / "studio-8888-1234.pid").write_text("1234\n111.5\n", encoding = "utf-8")
(tmp_path / "studio-9000-1234.pid").write_text("1234\n999.0\n", encoding = "utf-8")
assert run._own_studio_on_port(8888, "127.0.0.1") is None
assert run._own_studio_on_port(9000, "127.0.0.1") == 1234
def test_a_start_time_is_the_only_thing_that_disproves_a_record(monkeypatch):
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(8550, [999.0]) is True
assert run._pid_is_studio_backend(8550, [111.5]) is False
def test_a_bare_run_py_command_line_is_not_rejected(monkeypatch):
# `cd studio/backend && python run.py --port 8901` has no "studio" or "unsloth"
# in argv. Guessing from the command line called that "not ours".
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
class _FakeProcess:
def __init__(self, pid):
self.pid = pid
def cmdline(self):
return ["python", "run.py", "--port", "8901"]
def create_time(self):
return 111.5
monkeypatch.setitem(sys.modules, "psutil", SimpleNamespace(Process = _FakeProcess))
assert run._pid_is_studio_backend(8550) is True
def test_an_untimed_legacy_record_is_trusted(monkeypatch):
# `python run.py --port 8901` has no telltale argv, so guessing from the
# command line rejected real servers. Only a start time can disprove one.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(8550) is True
assert run._pid_is_studio_backend(8550, [None]) is True
def test_the_untimed_legacy_record_does_not_cancel_a_timed_one(monkeypatch):
# Mirrors _pid_is_studio_server in the CLI. An untimed record carries no
# information, so it must not overrule a start time that says "not ours" --
# every current server writes one of each, which made the check inert.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
assert run._pid_is_studio_backend(8550, [111.5, None]) is False
assert run._pid_is_studio_backend(8550, [111.5, 999.0]) is True
def test_a_legacy_server_on_the_port_is_recognised(tmp_path, monkeypatch):
# Pre-upgrade servers wrote only studio.pid. Falling back past one strands it
# and then overwrites its record.
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (8550, "python"))
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_a_legacy_record_for_a_different_listener_falls_back(tmp_path, monkeypatch):
# jupyter holds the port; the legacy server is elsewhere. Keep falling back.
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (117, "jupyter-lab"))
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") is None
def test_an_unknowable_listener_treats_the_legacy_record_as_ours(tmp_path, monkeypatch):
# No psutil: _get_pid_on_port can't say. Refusing beats a silent duplicate.
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: None)
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_a_dead_legacy_record_falls_back(tmp_path, monkeypatch):
monkeypatch.setattr(run, "_pid_alive", lambda pid: False)
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: None)
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") is None
def test_a_stale_per_port_record_does_not_mask_a_legacy_server(tmp_path, monkeypatch):
# Crashed current build left studio-8901-8550.pid; 8550 was then reused by a
# pre-upgrade server recorded only in studio.pid. The stale record must not
# count as "port already known" and send us falling back past the live one.
monkeypatch.setattr(run, "_pid_is_studio_backend", _REAL_IS_STUDIO_BACKEND)
monkeypatch.setattr(run, "_process_create_time", lambda pid: 999.0)
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (8550, "python"))
(tmp_path / "studio-8901-8550.pid").write_text("8550\n111.5\n127.0.0.1", encoding = "utf-8")
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8550
def test_a_current_server_elsewhere_does_not_block_a_foreign_port(tmp_path, monkeypatch):
# Current builds write studio.pid too. Without psutil the legacy check can't
# see the listener, so it must not claim our 8901 server holds jupyter's 8888.
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: None)
(tmp_path / "studio-8901-5000.pid").write_text("5000\n\n127.0.0.1", encoding = "utf-8")
(tmp_path / "studio.pid").write_text("5000", encoding = "utf-8")
assert run._own_studio_on_port(8888, "127.0.0.1") is None
def test_a_per_port_record_is_preferred_over_the_legacy_one(tmp_path, monkeypatch):
monkeypatch.setattr(run, "_get_pid_on_port", lambda p: (8550, "python"))
(tmp_path / "studio-8901-8600.pid").write_text("8600\n\n127.0.0.1", encoding = "utf-8")
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
assert run._own_studio_on_port(8901, "127.0.0.1") == 8600
def test_our_studio_on_another_bind_address_does_not_abort(tmp_path):
# Our server holds ::1:8889; binding 127.0.0.1:8889 is not a conflict with us,
# so fall through to the next port instead of refusing.
(tmp_path / "studio-8889-8550.pid").write_text("8550\n\n::1", encoding = "utf-8")
assert run._own_studio_on_port(8889, "127.0.0.1") is None
assert run._own_studio_on_port(8889, "::1") == 8550
def test_address_matching(tmp_path):
assert run._addresses_collide("0.0.0.0", "127.0.0.1", 8889) is True
assert run._addresses_collide("127.0.0.1", "0.0.0.0", 8889) is True
assert run._addresses_collide("127.0.0.1", "127.0.0.1", 8889) is True
assert run._addresses_collide("::1", "127.0.0.1", 8889) is False
# An unrecorded address is unknown, so assume a conflict.
assert run._addresses_collide(None, "127.0.0.1", 8889) is True
def test_a_hostname_resolves_the_same_way_the_bind_does(tmp_path):
# `localhost` and the address _is_port_free actually binds must agree, or a
# recorded server is missed and a duplicate starts.
recorded = ",".join(sorted(run._bind_addresses("localhost", 8889)))
assert run._addresses_collide(recorded, "localhost", 8889) is True
def test_a_hostname_records_every_address_it_resolves_to(tmp_path):
# `localhost` binds 127.0.0.1 AND ::1. Recording only the first lets a later
# launch on the other literal miss us and start a duplicate.
addrs = run._bind_addresses("localhost", 8889)
recorded = ",".join(sorted(addrs))
for literal in addrs:
assert run._addresses_collide(recorded, literal, 8889) is True
def test_a_multi_address_record_matches_either_literal(tmp_path):
recorded = "127.0.0.1,::1"
assert run._addresses_collide(recorded, "127.0.0.1", 8889) is True
assert run._addresses_collide(recorded, "::1", 8889) is True
assert run._addresses_collide("127.0.0.1", "::1", 8889) 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\n\n127.0.0.1", encoding = "utf-8")
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p >= 8890)
with pytest.raises(SystemExit) as excinfo:
run._find_free_port("127.0.0.1", 8889, avoid_own_studio = True)
assert excinfo.value.code == 1
def test_fallback_still_skips_foreign_processes(tmp_path, monkeypatch):
# No record for 8889, so the blocker is not ours: keep falling back.
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p >= 8890)
assert run._find_free_port("127.0.0.1", 8889, avoid_own_studio = True) == 8890
def test_the_requested_port_is_kept_when_it_is_free(monkeypatch):
monkeypatch.setattr(run, "_is_port_free", lambda host, p: True)
assert run._resolve_port("127.0.0.1", 8888) == 8888
def test_our_own_server_on_the_requested_port_aborts_rather_than_falling_back(
tmp_path, monkeypatch
):
# The reported bug: 8888 is ours, so falling back to 8889 is the duplicate
# that leaves 8888 serving with nothing recording it.
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p != 8888)
(tmp_path / "studio-8888-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
with pytest.raises(SystemExit) as excinfo:
run._resolve_port("127.0.0.1", 8888)
assert excinfo.value.code == 1
def test_a_foreign_process_on_the_requested_port_still_falls_back(monkeypatch):
# jupyter-lab on 8888 must not stop Unsloth starting on 8889.
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p != 8888)
assert run._resolve_port("127.0.0.1", 8888) == 8889
def test_a_caller_that_reads_the_port_back_keeps_the_plain_fallback(tmp_path, monkeypatch):
# api-only callers (the desktop app via TAURI_PORT, `studio run` via
# app.state.server_port) follow us to the new port, so aborting there only
# turns a working launch into a crash the desktop app reports as "stopped
# unexpectedly". Both servers are still recorded, so `stop` finds them.
monkeypatch.setattr(run, "_is_port_free", lambda host, p: p != 8888)
(tmp_path / "studio-8888-8550.pid").write_text("8550\n\n127.0.0.1", encoding = "utf-8")
assert run._resolve_port("127.0.0.1", 8888, avoid_own_studio = False) == 8889
def test_the_recorded_address_is_every_address_the_bind_resolves_to(tmp_path):
# The only test that runs the writer with a real host. Recording `host`
# verbatim, or dropping the line, passes every other test here and silently
# stops matching a launch that spells the same interface differently.
run._write_pid_file(8901, "localhost")
record = run._read_pid_record(tmp_path / f"studio-8901-{os.getpid()}.pid")
assert record[2] is not None, "no bind address recorded"
assert set(record[2].split(",")) == run._bind_addresses("localhost", 8901)
def test_a_server_started_on_a_hostname_is_found_again_by_ip(tmp_path):
run._write_pid_file(8901, "localhost")
for literal in run._bind_addresses("localhost", 8901):
assert run._own_studio_on_port(8901, literal) == os.getpid()
def test_bind_addresses_keeps_every_family_a_hostname_resolves_to(monkeypatch):
# Independent oracle: the sibling test derives its expectation from this
# function's own output, so dropping a family would pass it.
import socket
monkeypatch.setattr(
socket,
"getaddrinfo",
lambda *a, **k: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 8889)),
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 8889, 0, 0)),
],
)
assert run._bind_addresses("localhost", 8889) == {"127.0.0.1", "::1"}
def test_the_legacy_file_is_written_even_when_the_per_port_record_fails(tmp_path, monkeypatch):
# A studio root that cannot take a new entry used to leave the server
# recorded nowhere at all, so the CLI could not stop it. studio.pid is an
# overwrite of an existing path, so it can still succeed and must be tried.
blocked = tmp_path / "not-a-directory"
blocked.write_text("", encoding = "utf-8")
monkeypatch.setattr(
run, "_pid_file_for_port", lambda port: blocked / f"studio-{port}-{os.getpid()}.pid"
)
run._write_pid_file(8901, "127.0.0.1")
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == str(os.getpid())
assert run._OWN_PID_FILE is None
def test_a_record_whose_pid_is_not_ascii_digits_is_discarded(tmp_path):
# A superscript two passes isdigit() but int() rejects it, so that gate alone
# let a ValueError escape into every caller of _read_pid_record.
(tmp_path / "r.pid").write_text("²", encoding = "utf-8")
assert run._read_pid_record(tmp_path / "r.pid") is None
def test_the_legacy_file_is_not_taken_from_a_live_server(tmp_path):
# A pre-upgrade server is recorded in studio.pid and nowhere else, so a
# second launch overwriting it is exactly what strands it. That is the
# orphan this file exists to prevent, reached from the other direction.
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
run._write_pid_file(8902, "127.0.0.1")
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == "8550"
assert (tmp_path / f"studio-8902-{os.getpid()}.pid").exists()
def test_the_legacy_file_is_taken_over_from_a_dead_server(tmp_path, monkeypatch):
# A stale record must not keep the pointer forever, or an older CLI could
# never stop anything again.
monkeypatch.setattr(run, "_pid_alive", lambda pid: False)
(tmp_path / "studio.pid").write_text("8550", encoding = "utf-8")
run._write_pid_file(8902, "127.0.0.1")
assert (tmp_path / "studio.pid").read_text(encoding = "utf-8") == str(os.getpid())