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