Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e5aec0a
Stop every running Unsloth server, and refuse to start a second on a …
NilayYadav Jul 28, 2026
e8e092c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2026
306915c
Check the fallback range, guard PID reuse, and keep writing studio.pid
NilayYadav Jul 28, 2026
a0e4742
Signal each server once when its PID is recorded in more than one file
NilayYadav Jul 28, 2026
2b2861d
Confirm a recorded PID is a Studio server before signalling it
NilayYadav Jul 28, 2026
3fbd60f
Pin PID records to process start time and check every listener on a port
NilayYadav Jul 28, 2026
e897c02
Keep every recorded start time per PID and accept in-process Studio s…
NilayYadav Jul 28, 2026
24b53bc
Match the blocking listener address and stop trusting unverifiable PI…
NilayYadav Jul 28, 2026
dfcb991
Never delete a PID record that cannot be verified
NilayYadav Jul 28, 2026
279afd1
Detect our own server from our own records instead of a psutil listen…
NilayYadav Jul 28, 2026
7db3ee0
Match a pre-upgrade studio.pid to the blocked port before falling back
NilayYadav Jul 28, 2026
bdd0cf3
Never signal PID 0 or 1, and verify a per-port record before trusting it
NilayYadav Jul 28, 2026
07f2f46
Stop unverifiable records instead of skipping them, and record every …
NilayYadav Jul 28, 2026
4d472ed
Drop the command-line guess, fix Windows liveness, and free the PID r…
NilayYadav Jul 29, 2026
5d00689
Merge remote-tracking branch 'origin/main' into pr-7577-merged
shimmyshimmer Jul 29, 2026
02eb535
Studio: harden the per-port PID records against the cases that lose a…
shimmyshimmer Jul 29, 2026
a9ad8b7
Merge remote-tracking branch 'origin/main' into pr-7577-merged
shimmyshimmer Jul 29, 2026
52d671f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 29, 2026
c854b2d
Studio: let a caller that follows the port keep the fallback, and nev…
shimmyshimmer Jul 29, 2026
287eb0c
Studio: hand over the legacy PID pointer, and fail stop on unreadable…
shimmyshimmer Jul 29, 2026
d8f7f2a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 29, 2026
79a9785
Merge remote-tracking branch 'origin/main' into r7577
shimmyshimmer Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 55 additions & 9 deletions studio/backend/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,35 @@ def _find_free_port(

from utils.paths.storage_roots import studio_root as _studio_root

# Legacy single-instance file; still read so `stop` finds an older build's server.
_PID_FILE = _studio_root() / "studio.pid"
PID_FILE_GLOB = "studio-*.pid"


def _pid_file_for_port(port: int) -> Path:
return _studio_root() / f"studio-{port}.pid"
Comment thread
NilayYadav marked this conversation as resolved.
Outdated


def _blocker_is_own_studio(blocker: "tuple[int, str] | None") -> bool:
"""True when the process holding the port is a server we recorded."""
return bool(blocker) and blocker[0] in _recorded_studio_pids()
Comment thread
NilayYadav marked this conversation as resolved.
Outdated


def _recorded_studio_pids() -> "set[int]":
"""PIDs recorded under this STUDIO_HOME."""
pids: "set[int]" = set()
try:
paths = list(_studio_root().glob(PID_FILE_GLOB)) + [_PID_FILE]
except OSError:
return pids
for path in paths:
try:
text = path.read_text(encoding = "utf-8").strip()
except (OSError, UnicodeDecodeError):
continue
if text.isdigit():
pids.add(int(text))
return pids

# Direct backend launches bypass the CLI's env re-export; do it here for
# real custom roots so unsloth-zoo's import-time LLAMA_CPP_DEFAULT_DIR
Expand All @@ -770,22 +798,30 @@ def _find_free_port(
os.environ.setdefault("UNSLOTH_IS_PRESENT", "1")


def _write_pid_file():
"""Write the current process PID to the studio PID file."""
_OWN_PID_FILE: "Path | None" = None


def _write_pid_file(port: int):
"""Record this PID under its own port so `stop` can find every server."""
global _OWN_PID_FILE
path = _pid_file_for_port(port)
try:
_PID_FILE.parent.mkdir(parents = True, exist_ok = True)
_PID_FILE.write_text(str(os.getpid()), encoding = "utf-8")
path.parent.mkdir(parents = True, exist_ok = True)
path.write_text(str(os.getpid()), encoding = "utf-8")
Comment thread
NilayYadav marked this conversation as resolved.
Outdated
except OSError:
pass
return
_OWN_PID_FILE = path


def _remove_pid_file():
"""Remove the PID file if it belongs to this process."""
if _OWN_PID_FILE is None:
return
try:
if _PID_FILE.is_file():
stored = _PID_FILE.read_text(encoding = "utf-8").strip()
if _OWN_PID_FILE.is_file():
stored = _OWN_PID_FILE.read_text(encoding = "utf-8").strip()
if stored == str(os.getpid()):
_PID_FILE.unlink(missing_ok = True)
_OWN_PID_FILE.unlink(missing_ok = True)
except (OSError, UnicodeDecodeError):
pass

Expand Down Expand Up @@ -1533,6 +1569,16 @@ def run_server(
if not _is_port_free(host, port):
original_port = port
blocker = _get_pid_on_port(port)
# Falling back past our own server is what creates the orphan.
if _blocker_is_own_studio(blocker):
Comment thread
NilayYadav marked this conversation as resolved.
Outdated
print(
f"Error: Unsloth Studio is already running on port {port} "
f"(PID {blocker[0]}). Run `unsloth studio stop` first, or start this "
"one on a different --port.",
file = sys.stderr,
flush = True,
)
sys.exit(1)
port = _find_free_port(host, port + 1)
if not silent:
print("")
Expand Down Expand Up @@ -1731,7 +1777,7 @@ def _run():
(time.perf_counter() - boot_started) * 1000,
)

_write_pid_file()
_write_pid_file(port)
import atexit

atexit.register(_remove_pid_file)
Expand Down
92 changes: 92 additions & 0 deletions studio/backend/tests/test_studio_pid_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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

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


@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)
yield


def test_write_pid_file_is_per_port(tmp_path):
run._write_pid_file(8901)

path = tmp_path / "studio-8901.pid"
assert path.read_text(encoding = "utf-8") == str(os.getpid())


def test_second_port_does_not_clobber_the_first(tmp_path):
(tmp_path / "studio-8901.pid").write_text("8550", encoding = "utf-8")

run._write_pid_file(8902)

assert (tmp_path / "studio-8901.pid").read_text(encoding = "utf-8") == "8550"
assert (tmp_path / "studio-8902.pid").read_text(encoding = "utf-8") == str(os.getpid())


def test_remove_pid_file_only_removes_our_own(tmp_path):
run._write_pid_file(8901)
(tmp_path / "studio-8902.pid").write_text("8600", encoding = "utf-8")

run._remove_pid_file()

assert not (tmp_path / "studio-8901.pid").exists()
assert (tmp_path / "studio-8902.pid").exists()


def test_remove_pid_file_leaves_a_reused_entry_alone(tmp_path):
run._write_pid_file(8901)
(tmp_path / "studio-8901.pid").write_text("999999", encoding = "utf-8")

run._remove_pid_file()

assert (tmp_path / "studio-8901.pid").read_text(encoding = "utf-8") == "999999"


def test_recorded_studio_pids_reads_per_port_and_legacy_files(tmp_path):
(tmp_path / "studio-8901.pid").write_text("8550", encoding = "utf-8")
(tmp_path / "studio-8902.pid").write_text("8600", encoding = "utf-8")
(tmp_path / "studio.pid").write_text("4242", encoding = "utf-8")

assert run._recorded_studio_pids() == {8550, 8600, 4242}


def test_recorded_studio_pids_ignores_corrupt_files(tmp_path):
(tmp_path / "studio-8901.pid").write_text("not-a-pid", encoding = "utf-8")

assert run._recorded_studio_pids() == set()


def test_own_studio_blocking_the_port_is_recognised(tmp_path):
(tmp_path / "studio-8901.pid").write_text("8550", encoding = "utf-8")

assert run._blocker_is_own_studio((8550, "python")) is True


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.pid").write_text("8550", encoding = "utf-8")

assert run._blocker_is_own_studio((117, "jupyter-lab")) is False
assert run._blocker_is_own_studio(None) is False
112 changes: 74 additions & 38 deletions unsloth_cli/commands/studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -2394,6 +2394,7 @@ def run(
# ── unsloth studio stop ───────────────────────────────────────────────

_PID_FILE = STUDIO_HOME / "studio.pid"
PID_FILE_GLOB = "studio-*.pid"


def _pid_alive(pid: int) -> bool:
Expand Down Expand Up @@ -2423,58 +2424,93 @@ def _pid_alive(pid: int) -> bool:
return True


@studio_app.command()
def stop():
"""Stop a running Unsloth Studio server.

Reads the PID from ~/.unsloth/studio/studio.pid and sends SIGTERM
(or TerminateProcess on Windows) to shut it down gracefully.
"""
import signal as _signal

if not _PID_FILE.is_file():
typer.echo("No running Unsloth server found (no PID file).")
raise typer.Exit(0)

pid_text = _PID_FILE.read_text(encoding = "utf-8").strip()
if not pid_text.isdigit():
typer.echo(f"Invalid PID file contents: {pid_text}")
_PID_FILE.unlink(missing_ok = True)
raise typer.Exit(1)
def _pid_file_entries() -> "list[tuple[Path, int]]":
"""(path, pid) per recorded server, including the legacy studio.pid."""
entries = []
try:
paths = sorted(STUDIO_HOME.glob(PID_FILE_GLOB)) + [_PID_FILE]
except OSError:
paths = [_PID_FILE]
seen = set()
for path in paths:
if path in seen or not path.is_file():
continue
seen.add(path)
try:
text = path.read_text(encoding = "utf-8").strip()
except (OSError, UnicodeDecodeError):
continue
if text.isdigit():
entries.append((path, int(text)))
Comment thread
NilayYadav marked this conversation as resolved.
Outdated
else:
typer.echo(f"Ignoring invalid PID file {path.name}: {text}")
path.unlink(missing_ok = True)
return entries

pid = int(pid_text)

# Check if still alive (os.kill(pid, 0) is invalid on Windows -- see _pid_alive).
if not _pid_alive(pid):
typer.echo(f"Unsloth server (PID {pid}) is not running. Cleaning up stale PID file.")
_PID_FILE.unlink(missing_ok = True)
raise typer.Exit(0)
def _signal_stop(pid: int) -> "str | None":
"""SIGTERM (or taskkill) the pid. Returns an error string, or None on success."""
import signal as _signal

# Send SIGTERM (graceful shutdown) or TerminateProcess on Windows
try:
if sys.platform == "win32":
# /T also stops llama-server children, which otherwise keep GPU and port.
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check = True)
else:
os.kill(pid, _signal.SIGTERM)
typer.echo(f"Sent shutdown signal to Unsloth server (PID {pid}).")
except ProcessLookupError:
typer.echo(f"Unsloth server (PID {pid}) already exited.")
_PID_FILE.unlink(missing_ok = True)
raise typer.Exit(0)
return None
except Exception as e:
typer.echo(f"Failed to stop Unsloth server (PID {pid}): {e}", err = True)
raise typer.Exit(1)
return str(e)
return None

# Wait briefly for the process to exit and clean up.
for _ in range(10):
time.sleep(0.5)

@studio_app.command()
def stop():
"""Stop every running Unsloth Studio server for this STUDIO_HOME.

The port fallback can leave more than one running, so stop them all.
"""
entries = _pid_file_entries()
if not entries:
typer.echo("No running Unsloth server found (no PID file).")
raise typer.Exit(0)

signalled, failed = [], []
for path, pid in entries:
if not _pid_alive(pid):
_PID_FILE.unlink(missing_ok = True)
typer.echo("Unsloth server stopped.")
raise typer.Exit(0)
path.unlink(missing_ok = True)
continue
error = _signal_stop(pid)
if error is not None:
failed.append((pid, error))
typer.echo(f"Failed to stop Unsloth server (PID {pid}): {error}", err = True)
continue
typer.echo(f"Sent shutdown signal to Unsloth server (PID {pid}).")
signalled.append((path, pid))

typer.echo("Unsloth server is shutting down (may take a few seconds).")
if not signalled and not failed:
typer.echo("No running Unsloth server found (cleaned up stale PID files).")
raise typer.Exit(0)

pending = list(signalled)
for _ in range(10):
if not pending:
break
time.sleep(0.5)
for entry in list(pending):
path, pid = entry
if not _pid_alive(pid):
path.unlink(missing_ok = True)
pending.remove(entry)

stopped = len(signalled) - len(pending)
if stopped:
typer.echo(f"Unsloth server{'s' if stopped > 1 else ''} stopped ({stopped}).")
for _path, pid in pending:
typer.echo(f"Unsloth server (PID {pid}) is shutting down (may take a few seconds).")
if failed:
raise typer.Exit(1)


# ── unsloth studio setup / update ─────────────────────────────────────
Expand Down
Loading
Loading