Skip to content

Commit 98eeff1

Browse files
samuelfajclaude
andauthored
feat(daemon): make --daemon boot-persistent by default (#2)
`--daemon` now installs a per-user autostart so the supervisor restarts on login / system boot: - macOS: LaunchAgent at ~/Library/LaunchAgents/com.lightning-mlx.<id>.plist (RunAtLoad=true, KeepAlive=true), loaded via `launchctl load -w`. - Linux: user unit at ~/.config/systemd/user/lightning-mlx-<id>.service (Restart=always), enabled via `systemctl --user enable --now`. Opt-out with `--daemon=non-persist` (or `=ephemeral`) to keep the original in-session detached behavior. `lightning-mlx kill` uninstalls the plist/unit before signaling so launchd or systemd cannot restart the supervisor we are killing. Install failures unlink the on-disk record and surface a clean DaemonError instead of a traceback. `list_daemons()` shows persistent daemons whose supervisor pid is not yet live as `pending` rather than `stale`. Tests: - tests/test_persistence.py (7): plist/unit content, install + uninstall on darwin/linux, idempotent uninstall, unsupported-platform error. - tests/test_daemon.py: added persistent vs non-persist start paths, install-failure record cleanup, stop-uninstall ordering, list-pending for persistent dead-pid, CLI dispatch for `--daemon=non-persist`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 381d53b commit 98eeff1

6 files changed

Lines changed: 606 additions & 11 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ lightning-mlx serve mlx-community/Qwen3.5-4B-MLX-4bit --daemon
127127

128128
Daemon mode starts a detached supervisor, writes logs under `~/.lightning-mlx/logs/`, and restarts the server if the model process exits unexpectedly.
129129

130+
By default the daemon is **boot-persistent**: lightning-mlx installs a per-user autostart (a `~/Library/LaunchAgents/com.lightning-mlx.<id>.plist` on macOS, or a `~/.config/systemd/user/lightning-mlx-<id>.service` on Linux) so the supervisor restarts at login / system boot. `lightning-mlx kill` removes the autostart and stops the daemon. Use `--daemon=non-persist` to keep the original in-session detached behavior (process dies on reboot).
131+
132+
On Linux, run `loginctl enable-linger $USER` once if you want user services to keep running after logout.
133+
130134
```bash
131135
lightning-mlx status
132136
lightning-mlx tui <PID-or-model-name>

tests/test_daemon.py

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,18 +79,29 @@ def fake_popen(cmd, **kwargs):
7979
port=8000,
8080
)
8181

82+
# Use non-persist to exercise the in-process Popen path; persistent mode is
83+
# covered by a dedicated test that mocks install_autostart.
84+
args.daemon = "non-persist"
8285
record = daemon.start_daemon(
8386
args,
84-
["serve", "mlx-community/Qwen3.5-4B-MLX-4bit", "--daemon", "--port", "8000"],
87+
[
88+
"serve",
89+
"mlx-community/Qwen3.5-4B-MLX-4bit",
90+
"--daemon=non-persist",
91+
"--port",
92+
"8000",
93+
],
8594
)
8695

8796
assert record["supervisor_pid"] == 4321
8897
assert record["base_url"] == "http://127.0.0.1:8000"
8998
assert "--daemon" not in record["command"]
90-
assert record["command"][-3:] == [
99+
assert all(not c.startswith("--daemon=") for c in record["command"])
100+
assert record["command"][-4:] == [
91101
"mlx-community/Qwen3.5-4B-MLX-4bit",
92102
"--port",
93103
"8000",
104+
"--force",
94105
]
95106
assert started["cmd"][:3] == [daemon.sys.executable, "-m", "vllm_mlx.daemon"]
96107
assert started["kwargs"]["start_new_session"] is True
@@ -157,6 +168,137 @@ def test_list_daemons_marks_dead_records_stale(daemon_dirs, monkeypatch):
157168
assert daemon._read_json(path)["state"] == "stale"
158169

159170

171+
def test_list_daemons_keeps_persistent_dead_as_pending(daemon_dirs, monkeypatch):
172+
record = _record("one", supervisor_pid=100)
173+
record["persistent"] = True
174+
path = _write_record(record)
175+
monkeypatch.setattr(daemon, "_pid_alive", lambda pid: False)
176+
177+
matches = daemon.list_daemons()
178+
assert len(matches) == 1
179+
assert matches[0].record["state"] == "pending"
180+
assert daemon._read_json(path)["state"] == "pending"
181+
182+
183+
def test_start_daemon_persistent_installs_autostart_and_skips_popen(daemon_dirs, monkeypatch):
184+
installed = {}
185+
popen_calls = []
186+
187+
def fake_install(record):
188+
installed["record"] = record
189+
190+
def fake_popen(*args, **kwargs):
191+
popen_calls.append(args)
192+
raise AssertionError("Popen must not be called for persistent daemons")
193+
194+
monkeypatch.setattr(daemon, "install_autostart", fake_install)
195+
monkeypatch.setattr(daemon.subprocess, "Popen", fake_popen)
196+
197+
args = Namespace(
198+
model="mlx-community/Qwen3.5-4B-MLX-4bit",
199+
_original_alias=None,
200+
served_model_name=None,
201+
host="127.0.0.1",
202+
port=8000,
203+
daemon="persist",
204+
)
205+
record = daemon.start_daemon(
206+
args,
207+
["serve", "mlx-community/Qwen3.5-4B-MLX-4bit", "--daemon"],
208+
)
209+
210+
assert record["persistent"] is True
211+
assert installed["record"]["id"] == record["id"]
212+
assert popen_calls == []
213+
assert record["supervisor_pid"] is None
214+
215+
216+
def test_start_daemon_non_persist_uses_popen_and_skips_install(daemon_dirs, monkeypatch):
217+
install_calls = []
218+
219+
class FakePopen:
220+
def __init__(self, cmd, **kwargs):
221+
self.pid = 9999
222+
223+
def fake_popen(cmd, **kwargs):
224+
return FakePopen(cmd, **kwargs)
225+
226+
monkeypatch.setattr(daemon, "install_autostart", lambda r: install_calls.append(r))
227+
monkeypatch.setattr(daemon.subprocess, "Popen", fake_popen)
228+
229+
args = Namespace(
230+
model="mlx-community/Qwen3.5-4B-MLX-4bit",
231+
_original_alias=None,
232+
served_model_name=None,
233+
host="127.0.0.1",
234+
port=8000,
235+
daemon="non-persist",
236+
)
237+
record = daemon.start_daemon(
238+
args,
239+
["serve", "mlx-community/Qwen3.5-4B-MLX-4bit", "--daemon=non-persist"],
240+
)
241+
242+
assert record["persistent"] is False
243+
assert record["supervisor_pid"] == 9999
244+
assert install_calls == []
245+
246+
247+
def test_start_daemon_persistent_install_failure_removes_record_and_raises(daemon_dirs, monkeypatch):
248+
def fake_install(record):
249+
raise daemon.PersistenceError("launchctl not found")
250+
251+
def fail_popen(*args, **kwargs):
252+
raise AssertionError("Popen must not be called when install fails")
253+
254+
monkeypatch.setattr(daemon, "install_autostart", fake_install)
255+
monkeypatch.setattr(daemon.subprocess, "Popen", fail_popen)
256+
257+
args = Namespace(
258+
model="mlx-community/Qwen3.5-4B-MLX-4bit",
259+
_original_alias=None,
260+
served_model_name=None,
261+
host="127.0.0.1",
262+
port=8000,
263+
daemon="persist",
264+
)
265+
266+
with pytest.raises(daemon.DaemonError, match="Failed to install autostart"):
267+
daemon.start_daemon(
268+
args,
269+
["serve", "mlx-community/Qwen3.5-4B-MLX-4bit", "--daemon"],
270+
)
271+
272+
# No orphan record files left behind.
273+
assert list(daemon.DAEMON_DIR.glob("*.json")) == []
274+
275+
276+
def test_stop_daemon_uninstalls_autostart_before_killing(daemon_dirs, tmp_path, monkeypatch):
277+
alive = {100}
278+
stop_path = tmp_path / "daemon.stop"
279+
record = _record("one", supervisor_pid=100)
280+
record["stop_path"] = str(stop_path)
281+
record["persistent"] = True
282+
_write_record(record)
283+
284+
order = []
285+
monkeypatch.setattr(daemon, "_pid_alive", lambda pid: int(pid or 0) in alive)
286+
monkeypatch.setattr(daemon, "uninstall_autostart", lambda did: order.append(("uninstall", did)))
287+
288+
def fake_signal(pid, sig):
289+
order.append(("signal", int(pid)))
290+
alive.discard(int(pid))
291+
return True
292+
293+
monkeypatch.setattr(daemon, "_signal_pid", fake_signal)
294+
295+
daemon.stop_daemon("100")
296+
297+
# Uninstall must happen before any signal so launchd/systemd cannot restart.
298+
assert order[0] == ("uninstall", "one")
299+
assert ("signal", 100) in order
300+
301+
160302
def test_supervisor_restarts_failed_child_until_stop_marker(daemon_dirs, tmp_path, monkeypatch):
161303
record = _record("one", supervisor_pid=0)
162304
record["log_path"] = str(tmp_path / "daemon.log")
@@ -204,6 +346,7 @@ def fake_start_daemon(args, raw_args):
204346
"supervisor_pid": 1234,
205347
"base_url": "http://127.0.0.1:8000",
206348
"log_path": "/tmp/lightning.log",
349+
"persistent": True,
207350
}
208351

209352
monkeypatch.setattr(daemon, "start_daemon", fake_start_daemon)
@@ -221,11 +364,46 @@ def fake_start_daemon(args, raw_args):
221364
cli.main()
222365

223366
out = capsys.readouterr().out
224-
assert captured["args"].daemon is True
367+
assert captured["args"].daemon # truthy = enabled
225368
assert captured["raw_args"][-1] == "--daemon"
226369
assert "Started daemon mlx-community/Qwen3.5-4B-MLX-4bit (pid 1234)." in out
227370

228371

372+
def test_cli_serve_daemon_non_persist_dispatches(monkeypatch):
373+
from vllm_mlx import cli
374+
375+
captured = {}
376+
377+
def fake_start_daemon(args, raw_args):
378+
captured["args"] = args
379+
captured["raw_args"] = raw_args
380+
return {
381+
"model": args.model,
382+
"original_alias": None,
383+
"supervisor_pid": 1234,
384+
"base_url": "http://127.0.0.1:8000",
385+
"log_path": "/tmp/lightning.log",
386+
"persistent": False,
387+
}
388+
389+
monkeypatch.setattr(daemon, "start_daemon", fake_start_daemon)
390+
monkeypatch.setattr(
391+
cli.sys,
392+
"argv",
393+
[
394+
"lightning-mlx",
395+
"serve",
396+
"mlx-community/Qwen3.5-4B-MLX-4bit",
397+
"--daemon=non-persist",
398+
],
399+
)
400+
401+
cli.main()
402+
403+
assert captured["args"].daemon == "non-persist"
404+
assert "--daemon=non-persist" in captured["raw_args"]
405+
406+
229407
def test_cli_dispatches_status_kill_and_tui(monkeypatch):
230408
from vllm_mlx import cli
231409

tests/test_persistence.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Tests for boot-persistent daemon autostart (launchd + systemd --user)."""
3+
4+
from __future__ import annotations
5+
6+
import plistlib
7+
import sys
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
from vllm_mlx import persistence
13+
14+
15+
@pytest.fixture()
16+
def darwin_layout(tmp_path, monkeypatch):
17+
monkeypatch.setattr(persistence, "PLATFORM", "darwin")
18+
monkeypatch.setattr(persistence, "LAUNCHAGENTS_DIR", tmp_path / "LaunchAgents")
19+
monkeypatch.setattr(persistence, "SYSTEMD_USER_DIR", tmp_path / "systemd_user_unused")
20+
return tmp_path
21+
22+
23+
@pytest.fixture()
24+
def linux_layout(tmp_path, monkeypatch):
25+
monkeypatch.setattr(persistence, "PLATFORM", "linux")
26+
monkeypatch.setattr(persistence, "LAUNCHAGENTS_DIR", tmp_path / "LaunchAgents_unused")
27+
monkeypatch.setattr(persistence, "SYSTEMD_USER_DIR", tmp_path / "systemd_user")
28+
return tmp_path
29+
30+
31+
def _record(daemon_id: str = "abc123", record_path: str = "/tmp/r.json") -> dict:
32+
return {
33+
"id": daemon_id,
34+
"model": "mlx-community/Qwen3.5-4B-MLX-4bit",
35+
"record_path": record_path,
36+
"log_path": "/tmp/abc123.log",
37+
}
38+
39+
40+
def test_label_for_id_is_stable():
41+
assert persistence.service_label("abc123") == "com.lightning-mlx.abc123"
42+
43+
44+
def test_install_darwin_writes_plist_and_loads_it(darwin_layout, monkeypatch):
45+
calls = []
46+
47+
def fake_run(cmd, check=False, **kwargs):
48+
calls.append(cmd)
49+
50+
class R:
51+
returncode = 0
52+
53+
return R()
54+
55+
monkeypatch.setattr(persistence.subprocess, "run", fake_run)
56+
57+
record = _record(record_path="/tmp/abc.json")
58+
persistence.install_autostart(record)
59+
60+
plist_path = darwin_layout / "LaunchAgents" / "com.lightning-mlx.abc123.plist"
61+
assert plist_path.exists()
62+
data = plistlib.loads(plist_path.read_bytes())
63+
assert data["Label"] == "com.lightning-mlx.abc123"
64+
assert data["RunAtLoad"] is True
65+
assert data["KeepAlive"] is True
66+
assert data["ProgramArguments"][:3] == [sys.executable, "-m", "vllm_mlx.daemon"]
67+
assert data["ProgramArguments"][-2:] == ["supervise", "/tmp/abc.json"]
68+
69+
assert any(cmd[:2] == ["launchctl", "load"] for cmd in calls)
70+
assert any(str(plist_path) in cmd for cmd in calls)
71+
72+
73+
def test_uninstall_darwin_unloads_and_removes_plist(darwin_layout, monkeypatch):
74+
plist_path = darwin_layout / "LaunchAgents" / "com.lightning-mlx.abc123.plist"
75+
plist_path.parent.mkdir(parents=True, exist_ok=True)
76+
plist_path.write_bytes(b"<plist/>")
77+
78+
calls = []
79+
80+
def fake_run(cmd, check=False, **kwargs):
81+
calls.append(cmd)
82+
83+
class R:
84+
returncode = 0
85+
86+
return R()
87+
88+
monkeypatch.setattr(persistence.subprocess, "run", fake_run)
89+
90+
persistence.uninstall_autostart("abc123")
91+
92+
assert not plist_path.exists()
93+
assert any(cmd[:2] == ["launchctl", "unload"] for cmd in calls)
94+
95+
96+
def test_uninstall_darwin_is_idempotent_when_plist_missing(darwin_layout, monkeypatch):
97+
monkeypatch.setattr(persistence.subprocess, "run", lambda *a, **k: None)
98+
persistence.uninstall_autostart("does-not-exist")
99+
100+
101+
def test_install_linux_writes_unit_and_enables_it(linux_layout, monkeypatch):
102+
calls = []
103+
104+
def fake_run(cmd, check=False, **kwargs):
105+
calls.append(cmd)
106+
107+
class R:
108+
returncode = 0
109+
110+
return R()
111+
112+
monkeypatch.setattr(persistence.subprocess, "run", fake_run)
113+
114+
record = _record(record_path="/tmp/abc.json")
115+
persistence.install_autostart(record)
116+
117+
unit_path = linux_layout / "systemd_user" / "lightning-mlx-abc123.service"
118+
assert unit_path.exists()
119+
text = unit_path.read_text(encoding="utf-8")
120+
assert "[Service]" in text
121+
assert f"{sys.executable} -m vllm_mlx.daemon supervise /tmp/abc.json" in text
122+
assert "Restart=always" in text
123+
assert "WantedBy=default.target" in text
124+
125+
cmds = [tuple(c) for c in calls]
126+
assert ("systemctl", "--user", "daemon-reload") in cmds
127+
assert ("systemctl", "--user", "enable", "--now", "lightning-mlx-abc123.service") in cmds
128+
129+
130+
def test_install_on_unsupported_platform_raises(tmp_path, monkeypatch):
131+
monkeypatch.setattr(persistence, "PLATFORM", "win32")
132+
with pytest.raises(persistence.PersistenceError, match="not supported"):
133+
persistence.install_autostart(_record())
134+
135+
136+
def test_uninstall_linux_disables_and_removes_unit(linux_layout, monkeypatch):
137+
unit_path = linux_layout / "systemd_user" / "lightning-mlx-abc123.service"
138+
unit_path.parent.mkdir(parents=True, exist_ok=True)
139+
unit_path.write_text("stub", encoding="utf-8")
140+
141+
calls = []
142+
143+
def fake_run(cmd, check=False, **kwargs):
144+
calls.append(tuple(cmd))
145+
146+
class R:
147+
returncode = 0
148+
149+
return R()
150+
151+
monkeypatch.setattr(persistence.subprocess, "run", fake_run)
152+
153+
persistence.uninstall_autostart("abc123")
154+
155+
assert not unit_path.exists()
156+
assert ("systemctl", "--user", "disable", "--now", "lightning-mlx-abc123.service") in calls
157+
assert ("systemctl", "--user", "daemon-reload") in calls

0 commit comments

Comments
 (0)