diff --git a/opencode_bridge/opencode_client.py b/opencode_bridge/opencode_client.py index 0ad47d6..60ca98e 100644 --- a/opencode_bridge/opencode_client.py +++ b/opencode_bridge/opencode_client.py @@ -1,5 +1,6 @@ """Async HTTP client for the local OpenCode server.""" +import atexit import json from typing import Any, Optional @@ -13,6 +14,21 @@ class OpenCodeError(Exception): """Raised when an OpenCode API request fails.""" +# Ensure the tornado AsyncHTTPClient singleton is closed at interpreter +# shutdown so the IOLoop can finish and the process can exit cleanly +# (otherwise Ctrl+C on `jupyter lab` hangs after "received signal 2, +# stopping" because the singleton keeps persistent connections alive). +def _close_opencode_http() -> None: + try: + AsyncHTTPClient().close() + except Exception: + # Never block interpreter shutdown on close errors. + pass + + +atexit.register(_close_opencode_http) + + class OpenCodeClient: def __init__( self, @@ -20,7 +36,24 @@ class OpenCodeClient: http_client: Optional[AsyncHTTPClient] = None, ) -> None: self._config = config - self._http_client = http_client or AsyncHTTPClient() + # Use a dedicated client (not the singleton) so close() can + # deterministically release its connections without affecting + # other components. The module-level atexit still closes the + # singleton as a safety net. + self._http_client = http_client or AsyncHTTPClient(force_instance=True) + self._owns_http_client = http_client is None + + def close(self) -> None: + """Release the underlying HTTP client's connections. Idempotent.""" + if getattr(self, "_http_client", None) is None: + return + try: + if self._owns_http_client: + self._http_client.close() + except Exception: + pass + self._http_client = None # type: ignore[assignment] + self._owns_http_client = False async def health(self) -> dict[str, Any]: return await self._request("GET", "/global/health") @@ -82,15 +115,17 @@ class OpenCodeClient: } if body is not None: request_kwargs["body"] = json.dumps(body).encode("utf-8") + # HTTP Basic Auth must be set on the HTTPRequest itself; passing + # auth_* as **kwargs to fetch() when the first arg is already an + # HTTPRequest is rejected by tornado ("kwargs can't be used if + # request is an HTTPRequest object"). + if self._config.auth is not None: + request_kwargs["auth_username"] = self._config.auth[0] + request_kwargs["auth_password"] = self._config.auth[1] request = HTTPRequest(url, **request_kwargs) - fetch_kwargs: dict[str, str] = {} - if self._config.auth is not None: - fetch_kwargs["auth_username"] = self._config.auth[0] - fetch_kwargs["auth_password"] = self._config.auth[1] - - response = await self._http_client.fetch(request, **fetch_kwargs) + response = await self._http_client.fetch(request) if 200 <= response.code < 300: if not response.body: