Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/entrypoints/desktop_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,27 @@ def build_launch_plan(app_dir: Path, *, install: bool, dev: bool) -> list[list[s
return plan


# The dev renderer binds this fixed port (ui-desktop package.json dev:renderer).
DEV_RENDERER_PORT = 5174


def dev_port_busy(port: int | None = None, host: str = "127.0.0.1") -> bool:
"""True when something already listens on the dev renderer port.

A second `clawcodex desktop` would otherwise die mid-boot on vite's raw
"Port 5174 is already in use" stack trace — the app is almost certainly
just already running. The port resolves at call time so tests (and a
future config override) can repoint DEV_RENDERER_PORT.
"""
import socket

if port is None:
port = DEV_RENDERER_PORT
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.settimeout(0.5)
return probe.connect_ex((host, port)) == 0


def run_desktop_subcommand(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
prog="clawcodex desktop",
Expand All @@ -77,6 +98,11 @@ def run_desktop_subcommand(argv: list[str]) -> int:
print("desktop: npm not found on PATH — install Node.js 22+ first.",
file=sys.stderr)
return 2
if not args.no_dev and dev_port_busy():
print("desktop: ClawCodex Desktop appears to be already running "
f"(port {DEV_RENDERER_PORT} is in use). Switch to its window, "
"or quit it and run this again.", file=sys.stderr)
return 1

env = launch_env(root)
for cmd in build_launch_plan(app_dir, install=args.install, dev=not args.no_dev):
Expand Down
31 changes: 31 additions & 0 deletions tests/server/test_desktop_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,34 @@ def test_desktop_subcommand_refuses_without_app(tmp_path: Path,

monkeypatch.setattr(mod, "repo_root", lambda: tmp_path)
assert mod.run_desktop_subcommand([]) == 2


def test_desktop_port_guard_detects_running_instance(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
) -> None:
import socket

import src.entrypoints.desktop_cli as mod

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind(("127.0.0.1", 0))
# Backlog > 1: each dev_port_busy probe completes a handshake that
# sits unaccepted in the queue; a backlog of 1 makes the second probe
# fail spuriously.
listener.listen(8)
port = listener.getsockname()[1]

assert mod.dev_port_busy(port) is True

# The subcommand refuses cleanly when the dev port is held.
app_dir = tmp_path / "ui-desktop"
app_dir.mkdir()
(app_dir / "package.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(mod, "repo_root", lambda: tmp_path)
monkeypatch.setattr(mod, "DEV_RENDERER_PORT", port)
monkeypatch.setattr(mod.shutil, "which", lambda n: "/usr/bin/npm")
assert mod.run_desktop_subcommand([]) == 1
assert "already running" in capsys.readouterr().err

# Listener closed — the guard clears.
assert mod.dev_port_busy(port) is False
Loading