diff --git a/cli/planoai/trace_cmd.py b/cli/planoai/trace_cmd.py index dd8194ef..5012f419 100644 --- a/cli/planoai/trace_cmd.py +++ b/cli/planoai/trace_cmd.py @@ -28,6 +28,17 @@ MAX_TRACES = 50 MAX_SPANS_PER_TRACE = 500 +class TraceListenerBindError(RuntimeError): + """Raised when the OTLP/gRPC listener cannot bind to the requested address.""" + + +def _trace_listener_bind_error_message(address: str) -> str: + return ( + f"Failed to start OTLP listener on {address}: address is already in use.\n" + "Stop the process using that port or run `planoai trace listen --port `." + ) + + @dataclass class TraceSummary: trace_id: str @@ -501,7 +512,15 @@ def _create_trace_server(host: str, grpc_port: int) -> grpc.Server: trace_service_pb2_grpc.add_TraceServiceServicer_to_server( _OTLPTraceServicer(), grpc_server ) - grpc_server.add_insecure_port(f"{host}:{grpc_port}") + address = f"{host}:{grpc_port}" + try: + bound_port = grpc_server.add_insecure_port(address) + except RuntimeError as exc: + raise TraceListenerBindError( + _trace_listener_bind_error_message(address) + ) from exc + if bound_port == 0: + raise TraceListenerBindError(_trace_listener_bind_error_message(address)) grpc_server.start() return grpc_server @@ -509,7 +528,10 @@ def _create_trace_server(host: str, grpc_port: int) -> grpc.Server: def _start_trace_listener(host: str, grpc_port: int) -> None: """Start the OTLP/gRPC listener and block until interrupted.""" console = Console() - grpc_server = _create_trace_server(host, grpc_port) + try: + grpc_server = _create_trace_server(host, grpc_port) + except TraceListenerBindError as exc: + raise click.ClickException(str(exc)) from exc console.print() console.print(f"[bold {PLANO_COLOR}]Listening for traces...[/bold {PLANO_COLOR}]") diff --git a/cli/test/test_trace_cmd.py b/cli/test/test_trace_cmd.py new file mode 100644 index 00000000..23373277 --- /dev/null +++ b/cli/test/test_trace_cmd.py @@ -0,0 +1,44 @@ +import pytest +import rich_click as click + +from planoai import trace_cmd + + +class _FakeGrpcServer: + def add_insecure_port(self, _address: str) -> int: + raise RuntimeError("bind failed") + + def start(self) -> None: + return None + + +def test_create_trace_server_raises_bind_error(monkeypatch): + monkeypatch.setattr( + trace_cmd.grpc, "server", lambda *_args, **_kwargs: _FakeGrpcServer() + ) + monkeypatch.setattr( + trace_cmd.trace_service_pb2_grpc, + "add_TraceServiceServicer_to_server", + lambda *_args, **_kwargs: None, + ) + + with pytest.raises(trace_cmd.TraceListenerBindError) as excinfo: + trace_cmd._create_trace_server("0.0.0.0", 4317) + + assert "already in use" in str(excinfo.value) + assert "planoai trace listen --port" in str(excinfo.value) + + +def test_start_trace_listener_converts_bind_error_to_click_exception(monkeypatch): + monkeypatch.setattr( + trace_cmd, + "_create_trace_server", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + trace_cmd.TraceListenerBindError("port in use") + ), + ) + + with pytest.raises(click.ClickException) as excinfo: + trace_cmd._start_trace_listener("0.0.0.0", 4317) + + assert "port in use" in str(excinfo.value)