From 9e86dd4e94f4c388a6ec61fffebda1cf032d5ceb Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 17 Sep 2026 12:22:45 +0100 Subject: [PATCH] fix: refuse a busy mock LLM port before dev starts anything The mock starts first and the API and worker are pointed at it, but only the API port was preflighted. A busy 8090 therefore showed up as a child exiting once the rest were running, or as the API talking to whatever else was on that port. --ui is deliberately left alone: vite.config.ts sets no strictPort, so Vite moves to the next free port rather than failing. --- docsgpt/deploy/commands.py | 7 +++++++ tests/deploy/test_doctor.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/docsgpt/deploy/commands.py b/docsgpt/deploy/commands.py index 758fc715..b8f063aa 100644 --- a/docsgpt/deploy/commands.py +++ b/docsgpt/deploy/commands.py @@ -739,6 +739,13 @@ def dev(args, context: Optional[Context] = None) -> int: f"port {args.port} is already in use, so the API cannot bind it. Stop what is on it " f"(a previous `docsgpt dev`, or `docsgpt down` for an install), or pass --port." ) + if getattr(args, "mock_llm", False) and not _port_is_free(dev_module.MOCK_LLM_PORT): + # It starts first and the others are pointed at it, so a busy port here would surface as the + # API talking to someone else's server, or as a child exiting once everything else is up. + raise DeployError( + f"port {dev_module.MOCK_LLM_PORT} is already in use, so the mock LLM cannot bind it. " + "Stop what is on it, or leave --mock-llm off and point DocsGPT at a real provider." + ) children = dev_module.plan(args, checkout) print(f"DocsGPT from {checkout}") for child in children: diff --git a/tests/deploy/test_doctor.py b/tests/deploy/test_doctor.py index ac046a85..c0a52b28 100644 --- a/tests/deploy/test_doctor.py +++ b/tests/deploy/test_doctor.py @@ -49,6 +49,23 @@ class TestDevCommand: _run(["dev", "--port", str(port)], _context()) assert started == [], "nothing is spawned when the port is taken" + def test_a_busy_mock_llm_port_is_refused_before_anything_starts(self, monkeypatch, tmp_path): + """It starts first and the rest are pointed at it, so a clash cannot wait until spawn time.""" + from docsgpt.deploy import dev as dev_module + + self._checkout(monkeypatch, tmp_path) + (tmp_path / "scripts").mkdir() + (tmp_path / "scripts" / "mock_llm.py").write_text("", encoding="utf-8") + started = [] + monkeypatch.setattr(dev_module, "run", lambda children, **kwargs: started.append(children) or 0) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as held: + held.bind(("127.0.0.1", 0)) + held.listen(1) + monkeypatch.setattr(dev_module, "MOCK_LLM_PORT", held.getsockname()[1]) + with pytest.raises(DeployError, match="mock LLM cannot bind"): + _run(["dev", "--mock-llm", "--port", str(self._free_port())], _context()) + assert started == [], "nothing is spawned when the mock LLM has nowhere to listen" + def test_it_runs_the_children_it_planned_and_says_where(self, monkeypatch, tmp_path, capsys): from docsgpt.deploy import dev as dev_module