Skip to content

Commit 6d6008a

Browse files
Add PID file tracking and unsloth studio stop command (#4598)
* Add PID file tracking and `unsloth studio stop` command On macOS the .app shortcut launches Studio via osascript into a Terminal window, then the launcher script exits. The server process runs outside of the launcher's context with no PID file, so there is no straightforward way to find or stop it. This adds: - PID file at ~/.unsloth/studio/studio.pid, written after the server starts and removed on graceful shutdown or via atexit - `unsloth studio stop` command that reads the PID file and sends SIGTERM (or taskkill on Windows) to shut down the server The PID file is only removed if it still contains the current process ID, avoiding races when a new server instance replaces a crashed one. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move atexit PID cleanup into run_server() The atexit registration was only in the __main__ block, so it did not cover the `unsloth studio` CLI path that calls run_server() directly via studio_default(). Moving it into run_server() ensures the PID file is cleaned up on unexpected exit regardless of entry point. * [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>
1 parent 561f0f3 commit 6d6008a

2 files changed

Lines changed: 97 additions & 0 deletions

File tree

studio/backend/run.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,37 @@ def _find_free_port(host: str, start: int, max_attempts: int = 20) -> int:
158158
)
159159

160160

161+
_PID_FILE = Path.home() / ".unsloth" / "studio" / "studio.pid"
162+
163+
164+
def _write_pid_file():
165+
"""Write the current process PID to the studio PID file."""
166+
try:
167+
_PID_FILE.parent.mkdir(parents = True, exist_ok = True)
168+
_PID_FILE.write_text(str(os.getpid()))
169+
except OSError:
170+
pass
171+
172+
173+
def _remove_pid_file():
174+
"""Remove the PID file if it belongs to this process."""
175+
try:
176+
if _PID_FILE.is_file():
177+
stored = _PID_FILE.read_text().strip()
178+
if stored == str(os.getpid()):
179+
_PID_FILE.unlink(missing_ok = True)
180+
except OSError:
181+
pass
182+
183+
161184
def _graceful_shutdown(server = None):
162185
"""Explicitly shut down all subprocess backends and the uvicorn server.
163186
164187
Called from signal handlers to ensure child processes are cleaned up
165188
before the parent exits. This is critical on Windows where atexit
166189
handlers are unreliable after Ctrl+C.
167190
"""
191+
_remove_pid_file()
168192
logger.info("Graceful shutdown initiated — cleaning up subprocesses...")
169193

170194
# 1. Shut down uvicorn server (releases the listening socket)
@@ -307,6 +331,11 @@ def _run():
307331
thread.start()
308332
time.sleep(3)
309333

334+
_write_pid_file()
335+
import atexit
336+
337+
atexit.register(_remove_pid_file)
338+
310339
if not silent:
311340
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
312341

unsloth_cli/commands/studio.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,74 @@ def studio_default(
166166
typer.echo("\nShutting down...")
167167

168168

169+
# ── unsloth studio stop ───────────────────────────────────────────────
170+
171+
_PID_FILE = STUDIO_HOME / "studio.pid"
172+
173+
174+
@studio_app.command()
175+
def stop():
176+
"""Stop a running Unsloth Studio server.
177+
178+
Reads the PID from ~/.unsloth/studio/studio.pid and sends SIGTERM
179+
(or TerminateProcess on Windows) to shut it down gracefully.
180+
"""
181+
import signal as _signal
182+
183+
if not _PID_FILE.is_file():
184+
typer.echo("No running Studio server found (no PID file).")
185+
raise typer.Exit(0)
186+
187+
pid_text = _PID_FILE.read_text().strip()
188+
if not pid_text.isdigit():
189+
typer.echo(f"Invalid PID file contents: {pid_text}")
190+
_PID_FILE.unlink(missing_ok = True)
191+
raise typer.Exit(1)
192+
193+
pid = int(pid_text)
194+
195+
# Check if the process is still alive
196+
try:
197+
os.kill(pid, 0)
198+
except ProcessLookupError:
199+
typer.echo(
200+
f"Studio server (PID {pid}) is not running. Cleaning up stale PID file."
201+
)
202+
_PID_FILE.unlink(missing_ok = True)
203+
raise typer.Exit(0)
204+
except PermissionError:
205+
pass # process exists but we may not own it; try to signal anyway
206+
207+
# Send SIGTERM (graceful shutdown) or TerminateProcess on Windows
208+
try:
209+
if sys.platform == "win32":
210+
subprocess.run(["taskkill", "/PID", str(pid), "/F"], check = True)
211+
else:
212+
os.kill(pid, _signal.SIGTERM)
213+
typer.echo(f"Sent shutdown signal to Studio server (PID {pid}).")
214+
except ProcessLookupError:
215+
typer.echo(f"Studio server (PID {pid}) already exited.")
216+
_PID_FILE.unlink(missing_ok = True)
217+
raise typer.Exit(0)
218+
except Exception as e:
219+
typer.echo(f"Failed to stop Studio server (PID {pid}): {e}", err = True)
220+
raise typer.Exit(1)
221+
222+
# Wait briefly for the process to exit and clean up
223+
for _ in range(10):
224+
time.sleep(0.5)
225+
try:
226+
os.kill(pid, 0)
227+
except ProcessLookupError:
228+
_PID_FILE.unlink(missing_ok = True)
229+
typer.echo("Studio server stopped.")
230+
raise typer.Exit(0)
231+
except PermissionError:
232+
break
233+
234+
typer.echo("Studio server is shutting down (may take a few seconds).")
235+
236+
169237
# ── unsloth studio setup / update ─────────────────────────────────────
170238

171239

0 commit comments

Comments
 (0)