Skip to content

daemon_cmd

daemon_cmd

diapason start|stop|restart|status — daemon management commands.

Functions:

daemon

daemon() -> None

Manage the Diapason server daemon.

Source code in src/diapason/cli/daemon_cmd.py
@click.group()
def daemon() -> None:
    """Manage the Diapason server daemon."""

start

start(
    host: str | None,
    port: int | None,
    engine_key: str | None,
    model_name: str | None,
    agent_name: str | None,
) -> None

Start the Diapason server as a background daemon.

Source code in src/diapason/cli/daemon_cmd.py
@daemon.command()
@click.option("--host", default=None, help="Bind address.")
@click.option("--port", default=None, type=int, help="Port number.")
@click.option("-e", "--engine", "engine_key", default=None, help="Engine backend.")
@click.option("-m", "--model", "model_name", default=None, help="Default model.")
@click.option("-a", "--agent", "agent_name", default=None, help="Agent type.")
def start(
    host: str | None,
    port: int | None,
    engine_key: str | None,
    model_name: str | None,
    agent_name: str | None,
) -> None:
    """Start the Diapason server as a background daemon."""
    console = Console(stderr=True)

    config = load_config()
    bind_host = host or config.server.host
    bind_port = port or config.server.port

    # Un verrou de démarrage, tenu du contrôle jusqu'à la confirmation.
    #
    # Entre « le port est libre » et « mon enfant l'a pris », il s'écoule des
    # secondes — le seul import du paquet en coûte presque une, et un premier
    # démarrage charge un modèle. Deux `diapason start` lancés dans cet
    # intervalle voyaient tous deux le port libre. Le verrou ne protège QUE
    # cette commande manuelle : launchd n'y passe pas, donc il ne peut pas
    # empêcher un service supervisé de démarrer.
    verrou = None
    if sys.platform != "win32":
        import fcntl

        DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        try:
            verrou = os.open(
                str(DEFAULT_CONFIG_DIR / "start.lock"), os.O_RDWR | os.O_CREAT, 0o600
            )
            fcntl.flock(verrou, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            if verrou is not None:
                os.close(verrou)
            console.print(
                "[yellow]Another 'diapason start' is already in progress.[/yellow]\n"
                "  Wait for it to finish, then check with 'diapason status'."
            )
            sys.exit(1)

    try:
        existing = _read_pid(bind_port)
        if existing is not None:
            console.print(f"[yellow]Server already running (PID {existing}).[/yellow]")
            console.print(
                "Use 'diapason stop' to stop it first, or 'diapason restart'."
            )
            sys.exit(1)

        # Le fichier PID ne connaît que nos propres démarrages ; le noyau, lui,
        # voit aussi ceux de launchd, du bureau et des terminaux — sur TOUTES
        # les adresses, y compris celle du maillage. On laisse quelques
        # secondes à un port qu'on vient d'arrêter : sans cela, `restart`
        # arrêtait le serveur puis refusait de le relancer.
        etat, detail = ports.port_state(bind_port)
        attente = time.time() + 5.0
        while etat == ports.OCCUPE and time.time() < attente:
            time.sleep(0.4)
            etat, detail = ports.port_state(bind_port)

        if etat == ports.OCCUPE:
            console.print(f"[yellow]Port {bind_port} is already in use.[/yellow]")
            console.print(f"  Held by: {detail}")
            console.print(
                "Something is already serving there — launchd, the desktop app, "
                "or another terminal. Starting a second server would not report "
                "an error: it would quietly lose the race for the port."
            )
            sys.exit(1)
        if etat == ports.INCONNU:
            # Fail-closed : ne pas lancer sur une ignorance. Un doublon
            # silencieux coûte plus cher qu'un démarrage refusé.
            console.print(
                f"[yellow]Cannot verify whether port {bind_port} is free "
                f"({detail}).[/yellow]"
            )
            console.print(
                "Refusing to start rather than risk a second, invisible server.\n"
                f"  Check by hand:  lsof -nP -iTCP:{bind_port} -sTCP:LISTEN"
            )
            sys.exit(1)

        # Build command to run diapason serve
        cmd = [sys.executable, "-m", "diapason.cli", "serve"]
        if host:
            cmd.extend(["--host", host])
        if port:
            cmd.extend(["--port", str(port)])
        if engine_key:
            cmd.extend(["--engine", engine_key])
        if model_name:
            cmd.extend(["--model", model_name])
        if agent_name:
            cmd.extend(["--agent", agent_name])

        # Start as background process, fully detached from the launching
        # terminal.
        #
        # ``start_new_session`` is POSIX-only: CPython's Windows
        # ``_execute_child`` names the parameter ``unused_start_new_session``
        # and ignores it. 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. DETACHED_PROCESS
        # gives it no console at all; the new process group additionally stops
        # a Ctrl-C in the parent reaching it.
        DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        log_fh = open(_LOG_FILE, "a")  # noqa: SIM115
        spawn_kwargs: dict = {}
        if sys.platform == "win32":
            spawn_kwargs["creationflags"] = (
                subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
            )
        else:
            spawn_kwargs["start_new_session"] = True
        proc = subprocess.Popen(
            cmd,
            stdout=log_fh,
            stderr=log_fh,
            **spawn_kwargs,
        )
        _write_pid(proc.pid)

        # Vérifier plutôt qu'annoncer, et vérifier la bonne chose : que le
        # processus LANCÉ serve, pas seulement que le port réponde.
        issue = _wait_until_serving(proc, bind_host, bind_port)

        if issue == DEAD:
            _PID_FILE.unlink(missing_ok=True)
            console.print(
                f"[red]The server exited before serving port {bind_port}.[/red]\n"
                f"  Log: {_LOG_FILE}"
            )
            sys.exit(1)

        if issue == USURPED:
            # Le port répond, mais il appartient à un autre : notre processus a
            # perdu la course. L'inscrire comme « le serveur » ferait perdre la
            # trace du vrai. On retire le nôtre plutôt que de mentir.
            _PID_FILE.unlink(missing_ok=True)
            with contextlib.suppress(OSError):
                proc.terminate()
            etat, detail = ports.port_state(bind_port)
            console.print(
                f"[red]Another server took port {bind_port} first.[/red]\n"
                f"  Held by: {detail or 'unknown'}\n"
                "  The process we launched has been stopped."
            )
            sys.exit(1)

        if issue == SILENT:
            # Vivant mais muet. On GARDE le fichier PID : l'effacer laisserait
            # un processus que `diapason stop` ne saurait plus retrouver. Code
            # de sortie 3, distinct de l'échec : ce n'est pas encore un succès,
            # mais ce n'est pas non plus une panne avérée.
            console.print(
                f"[yellow]Started (PID {proc.pid}) but no answer on "
                f"{bind_host}:{bind_port} yet.[/yellow]\n"
                "  A first boot can take a while to load its model.\n"
                f"  Log:    {_LOG_FILE}\n"
                "  Check:  diapason status\n"
                "  Stop:   diapason stop"
            )
            sys.exit(3)

        console.print(
            f"[green]Diapason server started[/green] (PID {proc.pid})\n"
            f"  URL: http://{bind_host}:{bind_port}\n"
            f"  Log: {_LOG_FILE}"
        )
    finally:
        if verrou is not None:
            os.close(verrou)

stop

stop() -> None

Stop the running Diapason server daemon.

Source code in src/diapason/cli/daemon_cmd.py
@daemon.command()
def stop() -> None:
    """Stop the running Diapason server daemon."""
    console = Console(stderr=True)
    config = load_config()
    bind_port = config.server.port
    pid = _read_pid(bind_port)
    if pid is None:
        console.print("[yellow]No running server found.[/yellow]")
        # Le fichier PID ne connaît que nos propres démarrages. Si le port est
        # servi par quelqu'un d'autre, le dire plutôt que laisser l'utilisateur
        # devant un « rien à arrêter » qui contredit ce qu'il voit.
        etat, detail = ports.port_state(bind_port)
        if etat == ports.OCCUPE:
            console.print(f"  But port {bind_port} is served by: {detail}")
            console.print(
                "  This server was not started by 'diapason start'. Stop it "
                "where it came from — launchd, the desktop app, or its terminal."
            )
        sys.exit(1)

    if sys.platform == "win32":
        # ``os.kill(pid, 0)`` ne teste rien sur Windows : il TERMINE le
        # processus. On n'a donc aucun moyen sûr de sonder ici, et on se
        # contente de demander l'arrêt puis de vérifier par le port.
        with contextlib.suppress(OSError):
            os.kill(pid, signal.SIGTERM)
    else:
        with contextlib.suppress(OSError):
            os.kill(pid, signal.SIGTERM)
            for _ in range(20):
                time.sleep(0.5)
                try:
                    os.kill(pid, 0)
                except OSError:
                    break
            else:
                with contextlib.suppress(OSError):
                    os.kill(pid, signal.SIGKILL)

    # Vérifier plutôt qu'annoncer : « Server stopped » s'affichait même quand
    # le signal avait échoué, et le fichier PID était effacé par-dessus — le
    # serveur survivait, sans plus aucune trace pour le retrouver.
    fin = time.time() + 5.0
    while time.time() < fin:
        etat, detail = ports.port_state(bind_port)
        if etat != ports.OCCUPE or all(str(pid) not in d for d in (detail,)):
            break
        time.sleep(0.3)
    etat, detail = ports.port_state(bind_port)
    encore_la = etat == ports.OCCUPE and f"PID {pid} " in f"{detail} "

    if encore_la:
        console.print(
            f"[red]Could not stop the server (PID {pid}).[/red]\n"
            f"  It still holds port {bind_port}: {detail}\n"
            "  The PID file is kept so you can try again."
        )
        sys.exit(1)

    _PID_FILE.unlink(missing_ok=True)
    console.print(f"[green]Server stopped[/green] (PID {pid}).")

restart

restart(ctx: Context) -> None

Restart the Diapason server daemon.

Source code in src/diapason/cli/daemon_cmd.py
@daemon.command()
@click.pass_context
def restart(ctx: click.Context) -> None:
    """Restart the Diapason server daemon."""
    console = Console(stderr=True)
    config = load_config()
    pid = _read_pid(config.server.port)
    if pid is not None:
        console.print(f"Stopping server (PID {pid})...")
        # `stop` sort en 1 s'il n'a pas pu arrêter le serveur. Enchaîner sur
        # `start` reviendrait alors à lancer un doublon par-dessus. On s'arrête
        # là, en le disant — plutôt que d'arrêter le serveur puis d'échouer en
        # silence à le relancer.
        try:
            ctx.invoke(stop)
        except SystemExit as sortie:
            if sortie.code not in (0, None):
                console.print(
                    "[red]Not restarting: the server could not be stopped.[/red]"
                )
                raise
    ctx.invoke(start)

status

status() -> None

Show status of the Diapason server daemon.

Source code in src/diapason/cli/daemon_cmd.py
@daemon.command()
def status() -> None:
    """Show status of the Diapason server daemon."""
    console = Console(stderr=True)
    config_pour_port = load_config()
    bind_port = config_pour_port.server.port
    pid = _read_pid(bind_port)
    if pid is None:
        console.print("[yellow]Server is not running.[/yellow]")
        # Trois commandes doivent raconter la même histoire. `start` refuse
        # quand le port est pris ; `status` doit donc le dire aussi, sans quoi
        # l'utilisateur reçoit deux réponses incompatibles et aucune action.
        etat, detail = ports.port_state(bind_port)
        if etat == ports.OCCUPE:
            console.print(f"  But port {bind_port} is served by: {detail}")
            console.print("  It was not started by 'diapason start'.")
        elif etat == ports.INCONNU:
            console.print(f"  (Could not check port {bind_port}: {detail})")
        return

    # Get process info
    uptime_info = ""
    try:
        import psutil

        proc = psutil.Process(pid)
        uptime = time.time() - proc.create_time()
        hours, remainder = divmod(int(uptime), 3600)
        minutes, seconds = divmod(remainder, 60)
        uptime_info = f"\n  Uptime: {hours}h {minutes}m {seconds}s"
    except (ImportError, Exception):
        pass

    config = load_config()
    console.print(
        f"[green]Server is running[/green] (PID {pid}){uptime_info}\n"
        f"  URL: http://{config.server.host}:{config.server.port}\n"
        f"  Log: {_LOG_FILE}"
    )