Files
OpenJarvis/tests/cli/test_daemon_cmd.py
T
Jaiydaan RajandClaude Opus 5 9fc5b875d1 fix: detach the daemon from its console on Windows
``jarvis start`` spawned the server with ``start_new_session=True``. That is
POSIX-only — CPython's Windows ``_execute_child`` names the parameter
``unused_start_new_session`` and ignores it — so on Windows the server
inherited the launching console instead of detaching from it.

Closing that console, or logging off, therefore delivered CTRL_CLOSE_EVENT
to the server. Observed in the wild as the daemon dying overnight, with

    forrtl: error (200): program aborting due to window-CLOSE event

in server.log (the Fortran runtime under NumPy handles the event and
aborts). ``jarvis start`` looked like it worked: it printed a PID, wrote the
pid file and exited 0, and the server ran for as long as the console stayed
open. Registered as a log-on scheduled task, this means the machine comes
back up with no backend.

Pass DETACHED_PROCESS on Windows so the child gets no console at all, plus
CREATE_NEW_PROCESS_GROUP so a Ctrl-C in the parent console cannot reach it.
POSIX keeps start_new_session.

Verified by attaching to each spawned process with AttachConsole():
start_new_session=True attaches successfully (the child shares a console);
DETACHED_PROCESS fails with ERROR_INVALID_HANDLE (no console exists).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:17:06 +08:00

138 lines
5.6 KiB
Python

"""Tests for ``jarvis start|stop|restart|status`` daemon management commands."""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from openjarvis.cli import cli
from openjarvis.cli.daemon_cmd import _read_pid, _write_pid
class TestDaemonCommands:
"""Core daemon CLI tests."""
def test_start_command_exists(self) -> None:
"""``jarvis start --help`` succeeds."""
result = CliRunner().invoke(cli, ["start", "--help"])
assert result.exit_code == 0
out = result.output.lower()
assert "daemon" in out or "start" in out or "background" in out
def test_stop_no_server(self) -> None:
"""``jarvis stop`` when no PID file shows 'not running'."""
with patch("openjarvis.cli.daemon_cmd._read_pid", return_value=None):
result = CliRunner().invoke(cli, ["stop"])
assert result.exit_code != 0
assert "No running server" in result.output
def test_status_no_server(self) -> None:
"""``jarvis status`` when no PID file shows 'not running'."""
with patch("openjarvis.cli.daemon_cmd._read_pid", return_value=None):
result = CliRunner().invoke(cli, ["status"])
assert result.exit_code == 0
assert "not running" in result.output
def test_read_pid_no_file(self, tmp_path: Path) -> None:
"""``_read_pid()`` returns None when no PID file exists."""
with patch(
"openjarvis.cli.daemon_cmd._PID_FILE",
tmp_path / "nonexistent.pid",
):
assert _read_pid() is None
def test_write_and_read_pid(self, tmp_path: Path) -> None:
"""Write a PID, then read it back (mock os.kill to succeed)."""
pid_file = tmp_path / "server.pid"
with (
patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file),
patch("openjarvis.cli.daemon_cmd.DEFAULT_CONFIG_DIR", tmp_path),
patch("os.kill", return_value=None),
):
_write_pid(12345)
assert pid_file.exists()
assert _read_pid() == 12345
def test_status_shows_running(self) -> None:
"""``jarvis status`` shows running info when PID exists."""
mock_config = MagicMock()
mock_config.server.host = "127.0.0.1"
mock_config.server.port = 8000
with (
patch("openjarvis.cli.daemon_cmd._read_pid", return_value=9999),
patch(
"openjarvis.cli.daemon_cmd.load_config",
return_value=mock_config,
),
):
result = CliRunner().invoke(cli, ["status"])
assert result.exit_code == 0
assert "running" in result.output
assert "9999" in result.output
def test_start_already_running(self) -> None:
"""``jarvis start`` exits with error when a server is already running."""
with patch("openjarvis.cli.daemon_cmd._read_pid", return_value=42):
result = CliRunner().invoke(cli, ["start"])
assert result.exit_code != 0
assert "already running" in result.output
class TestDaemonDetachment:
"""The spawned server must outlive the console that started it.
``start_new_session`` is POSIX-only — CPython's Windows ``_execute_child``
names the parameter ``unused_start_new_session``. Relying on it there leaves
the server sharing its parent's console, so closing that console (or logging
off) delivers CTRL_CLOSE_EVENT and kills the daemon.
"""
@staticmethod
def _spawn_kwargs(platform: str) -> dict:
"""Return the kwargs ``start`` passes to Popen when spawning the server.
``load_config`` is stubbed because it shells out for GPU detection —
patching Popen wholesale would otherwise break config loading before
the spawn is reached.
"""
with (
patch("openjarvis.cli.daemon_cmd._read_pid", return_value=None),
patch("openjarvis.cli.daemon_cmd._write_pid"),
patch("openjarvis.cli.daemon_cmd.load_config"),
patch("openjarvis.cli.daemon_cmd.sys.platform", platform),
patch("openjarvis.cli.daemon_cmd.subprocess.Popen") as popen,
patch("builtins.open", MagicMock()),
):
popen.return_value = MagicMock(pid=4321)
result = CliRunner().invoke(cli, ["start"])
assert result.exit_code == 0, result.output
spawns = [
c for c in popen.call_args_list if c.args and "serve" in c.args[0]
]
assert spawns, f"start did not spawn the server: {popen.call_args_list}"
return spawns[-1].kwargs
def test_windows_spawn_is_detached_from_the_console(self) -> None:
kwargs = self._spawn_kwargs("win32")
flags = kwargs.get("creationflags", 0)
assert flags & subprocess.DETACHED_PROCESS, (
"server must be spawned with DETACHED_PROCESS on Windows, otherwise "
"closing the launching console kills it"
)
assert flags & subprocess.CREATE_NEW_PROCESS_GROUP, (
"server must be in its own process group so Ctrl-C in the parent "
"console does not propagate to it"
)
assert not kwargs.get("start_new_session"), (
"start_new_session is ignored on Windows; it must not be relied on"
)
def test_posix_spawn_still_uses_start_new_session(self) -> None:
kwargs = self._spawn_kwargs("linux")
assert kwargs.get("start_new_session") is True
assert "creationflags" not in kwargs or kwargs["creationflags"] == 0