diff --git a/src/entrypoints/desktop_cli.py b/src/entrypoints/desktop_cli.py index 439ebeb6..69518069 100644 --- a/src/entrypoints/desktop_cli.py +++ b/src/entrypoints/desktop_cli.py @@ -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", @@ -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): diff --git a/tests/server/test_desktop_sessions.py b/tests/server/test_desktop_sessions.py index c7fd368a..19c226f6 100644 --- a/tests/server/test_desktop_sessions.py +++ b/tests/server/test_desktop_sessions.py @@ -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