From bf5931e7c12c6b2f82bbfc3b5ed2f82a49319b3a Mon Sep 17 00:00:00 2001 From: guyuming Date: Wed, 27 May 2026 10:19:21 +0000 Subject: [PATCH] fix(runtime): close API clients on shutdown Close API clients when the runtime shuts down to release HTTP connection pool resources, and surface the active session ID in /session output. Cherry-picked from PR #273. Excluded the embedded-wrapper session resolver and memory parser API changes because they need a narrower public API design. --- src/openharness/api/client.py | 4 ++++ src/openharness/api/copilot_client.py | 4 ++++ src/openharness/api/openai_client.py | 4 ++++ src/openharness/commands/registry.py | 1 + src/openharness/ui/runtime.py | 3 +++ tests/test_commands/test_registry.py | 2 ++ tests/test_ui/test_runtime_close.py | 32 +++++++++++++++++++++++++++ 7 files changed, 50 insertions(+) create mode 100644 tests/test_ui/test_runtime_close.py diff --git a/src/openharness/api/client.py b/src/openharness/api/client.py index 26be6a7..63b76f4 100644 --- a/src/openharness/api/client.py +++ b/src/openharness/api/client.py @@ -150,6 +150,10 @@ class AnthropicApiClient: kwargs["base_url"] = self._base_url return AsyncAnthropic(**kwargs) + async def close(self) -> None: + """Close the underlying HTTP client.""" + await self._client.close() + def _refresh_client_auth(self) -> None: if not self._claude_oauth or self._auth_token_resolver is None: return diff --git a/src/openharness/api/copilot_client.py b/src/openharness/api/copilot_client.py index db37013..7ec1a6c 100644 --- a/src/openharness/api/copilot_client.py +++ b/src/openharness/api/copilot_client.py @@ -128,3 +128,7 @@ class CopilotClient: ) async for event in self._inner.stream_message(patched): yield event + + async def close(self) -> None: + """Close the underlying OpenAI-compatible client.""" + await self._inner.close() diff --git a/src/openharness/api/openai_client.py b/src/openharness/api/openai_client.py index 3bac631..45a5bc8 100644 --- a/src/openharness/api/openai_client.py +++ b/src/openharness/api/openai_client.py @@ -276,6 +276,10 @@ class OpenAICompatibleClient: kwargs["timeout"] = timeout self._client = AsyncOpenAI(**kwargs) + async def close(self) -> None: + """Close the underlying HTTP client.""" + await self._client.close() + async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]: """Yield text deltas and the final message, matching the Anthropic client interface.""" last_error: Exception | None = None diff --git a/src/openharness/commands/registry.py b/src/openharness/commands/registry.py index 141614c..e1c9874 100644 --- a/src/openharness/commands/registry.py +++ b/src/openharness/commands/registry.py @@ -867,6 +867,7 @@ def create_default_command_registry( latest = session_dir / "latest.json" transcript = session_dir / "transcript.md" lines = [ + f"Session ID: {context.session_id or '(none)'}", f"Session directory: {session_dir}", f"Latest snapshot: {'present' if latest.exists() else 'missing'}", f"Transcript export: {'present' if transcript.exists() else 'missing'}", diff --git a/src/openharness/ui/runtime.py b/src/openharness/ui/runtime.py index feda659..d057d0c 100644 --- a/src/openharness/ui/runtime.py +++ b/src/openharness/ui/runtime.py @@ -496,6 +496,9 @@ async def close_runtime(bundle: RuntimeBundle) -> None: HookEvent.SESSION_END, {"cwd": bundle.cwd, "event": HookEvent.SESSION_END.value}, ) + close_api_client = getattr(bundle.api_client, "close", None) + if close_api_client is not None: + await close_api_client() def _last_user_text(messages: list[ConversationMessage]) -> str: diff --git a/tests/test_commands/test_registry.py b/tests/test_commands/test_registry.py index 7ceedc6..ce23c0a 100644 --- a/tests/test_commands/test_registry.py +++ b/tests/test_commands/test_registry.py @@ -1155,7 +1155,9 @@ async def test_agents_session_files_and_reload_plugins_commands(tmp_path: Path, (tmp_path / "src" / "app.py").write_text("print('hi')\n", encoding="utf-8") session_command, session_args = registry.lookup("/session") + context.session_id = "session-smoke" session_result = await session_command.handler(session_args, context) + assert "Session ID: session-smoke" in session_result.message assert "Session directory:" in session_result.message session_path_command, session_path_args = registry.lookup("/session path") diff --git a/tests/test_ui/test_runtime_close.py b/tests/test_ui/test_runtime_close.py new file mode 100644 index 0000000..2c72854 --- /dev/null +++ b/tests/test_ui/test_runtime_close.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from openharness.ui.runtime import build_runtime, close_runtime + + +class _ClosableApiClient: + def __init__(self) -> None: + self.closed = False + + async def stream_message(self, request): + del request + if False: + yield None + + async def close(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def test_close_runtime_closes_api_client(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) + client = _ClosableApiClient() + + bundle = await build_runtime(cwd=str(tmp_path), api_client=client) + await close_runtime(bundle) + + assert client.closed is True