{"repo":"NousResearch/hermes-agent","public_id":"24731","display_label":"TOCTOU race conditions in lazy async client init","summary":"These issues and PRs address time-of-check-to-time-of-use race conditions in lazy singleton initialization for async HTTP clients, specifically OpenRouter and Honcho clients. Proposed fixes involve locking mechanisms such as double-checked locking to prevent duplicate client construction.","subsystem":"agent","counts":{"members":7,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":42150,"merged_at":"2026-06-08T16:35:22+00:00"},"resolution":{"state":"partially_resolved","by":"pr-42150"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T16:35:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":24759,"pr_number":42150,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-08T16:35:22+00:00"}],"members":[{"kind":"issue","number":24731,"title":"concurrency: TOCTOU race in get_async_client() leaks orphaned async HTTP client","url":null,"state":"open"},{"kind":"issue","number":24759,"title":"bug: TOCTOU race in get_honcho_client() allows duplicate Honcho client construction","url":null,"state":"closed"},{"kind":"pr","number":24734,"title":"fix(openrouter): prevent TOCTOU race in get_async_client()","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":24763,"title":"fix(honcho): prevent TOCTOU race in get_honcho_client()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"47d5177a7d61af220869e9319f1ddd51433a92ab","number":0},{"kind":"pr","value":"#24759","number":24759},{"kind":"pr","value":"#42150","number":42150}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":24820,"title":"fix(concurrency): TOCTOU race in get_async_client() — double-checked locking","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24734","number":24734},{"kind":"pr","value":"#24731","number":24731}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42150,"title":"fix(plugins): thread-safe lazy-singleton helpers; fix honcho TOCTOU (#24759)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/memory/honcho/client.py","plugins/plugin_utils.py","plugins/video_gen/fal/__init__.py","tests/honcho_plugin/test_client.py","tests/test_honcho_client_concurrency.py","tests/test_plugin_utils.py","website/docs/guides/build-a-hermes-plugin.md"],"fix_hunks":[{"file":"plugins/memory/honcho/client.py","hunks":["@@ -22,6 +22,7 @@\n \n from hermes_constants import get_hermes_home\n from hermes_cli.profiles import _get_default_hermes_home\n+from plugins.plugin_utils import SingletonSlot\n from typing import Any, TYPE_CHECKING\n \n if TYPE_CHECKING:","@@ -737,7 +738,7 @@ def resolve_session_name(\n         return self.workspace_id\n \n \n-_honcho_client: Honcho | None = None\n+_honcho_client_slot: SingletonSlot = SingletonSlot()\n \n \n def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:","@@ -745,11 +746,14 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:\n \n     When no config is provided, attempts to load ~/.honcho/config.json\n     first, falling back to environment variables.\n-    \"\"\"\n-    global _honcho_client\n \n-    if _honcho_client is not None:\n-        return _honcho_client\n+    Thread-safe: the client is built exactly once even under concurrent\n+    first calls (double-checked locking via ``SingletonSlot``), so racing\n+    threads can't each construct a client and leak the loser's connection.\n+    \"\"\"\n+    cached = _honcho_client_slot.peek()\n+    if cached is not None:\n+        return cached\n \n     if config is None:\n         config = HonchoClientConfig.from_global_config()","@@ -762,111 +766,116 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:\n             \"For local instances, set HONCHO_BASE_URL instead.\"\n         )\n \n-    # Lazy-install the honcho SDK on demand. ensure() honors\n-    # security.allow_lazy_installs (default true). On failure we surface\n-    # the original ImportError-shape message so existing callers still get\n-    # the \"go run hermes honcho setup\" hint they used to.\n-    try:\n-        from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure\n-        _lazy_ensure(\"memory.honcho\", prompt=False)\n-    except ImportError:\n-        # lazy_deps module missing — fall through to the raw import below.\n-        pass\n-    except Exception:\n-        # FeatureUnavailable or unexpected error. Don't crash here; let the\n-        # actual import attempt produce the canonical error message.\n-        pass\n-\n-    try:\n-        from honcho import Honcho\n-    except ImportError:\n-        raise ImportError(\n-            \"honcho-ai is required for Honcho integration. \"\n-            \"Install it with: pip install honcho-ai  \"\n-            \"(or run `hermes honcho setup` to configure).\"\n-        )\n-\n-    # Allow config.yaml honcho.base_url to override the SDK's environment\n-    # mapping, enabling remote self-hosted Honcho deployments without\n-    # requiring the server to live on localhost.\n-    resolved_base_url = config.base_url\n-    resolved_timeout = config.timeout\n-    if not resolved_base_url or resolved_timeout is None:\n+    # Everything below is the expensive part the issue flags: lazy SDK\n+    # install, config resolution, and client construction. Run it inside the\n+    # slot's factory so it executes exactly once even when several threads\n+    # race the first call — the slot's double-checked lock serializes them and\n+    # the losers get the winner's client instead of building their own.\n+    def _build() -> \"Honcho\":\n+        # Lazy-install the honcho SDK on demand. ensure() honors\n+        # security.allow_lazy_installs (default true). On failure we surface\n+        # the original ImportError-shape message so existing callers still get\n+        # the \"go run hermes honcho setup\" hint they used to.\n         try:\n-            from hermes_cli.config import load_config\n-            hermes_cfg = load_config()\n-            honcho_cfg = hermes_cfg.get(\"honcho\", {})\n-            if isinstance(honcho_cfg, dict):\n-                if not resolved_base_url:\n-                    resolved_base_url = honcho_cfg.get(\"base_url\", \"\").strip() or None\n-                if resolved_timeout is None:\n-                    resolved_timeout = _resolve_optional_float(\n-                        honcho_cfg.get(\"timeout\"),\n-                        honcho_cfg.get(\"request_timeout\"),\n-                    )\n+            from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure\n+            _lazy_ensure(\"memory.honcho\", prompt=False)\n+        except ImportError:\n+            # lazy_deps module missing — fall through to the raw import below.\n+            pass\n         except Exception:\n+            # FeatureUnavailable or unexpected error. Don't crash here; let the\n+            # actual import attempt produce the canonical error message.\n             pass\n \n-    # Fall back to the default so an unconfigured install cannot hang\n-    # indefinitely on a stalled Honcho request.\n-    if resolved_timeout is None:\n-        resolved_timeout = _DEFAULT_HTTP_TIMEOUT\n+        try:\n+            from honcho import Honcho\n+        except ImportError:\n+            raise ImportError(\n+                \"honcho-ai is required for Honcho integration. \"\n+                \"Install it with: pip install honcho-ai  \"\n+                \"(or run `hermes honcho setup` to configure).\"\n+            )\n \n-    if resolved_base_url:\n-        logger.info(\"Initializing Honcho client (base_url: %s, workspace: %s)\", resolved_base_url, config.workspace_id)\n-    else:\n-        logger.info(\"Initializing Honcho client (host: %s, workspace: %s)\", config.host, config.workspace_id)\n-\n-    # Local Honcho instances don't require an API key, but the SDK\n-    # expects a non-empty string.  Use a placeholder for local URLs.\n-    # For local: only use config.api_key if the host block explicitly\n-    # sets apiKey (meaning the user wants local auth). Otherwise skip\n-    # the stored key -- it's likely a cloud key that would break local.\n-    _is_local = resolved_base_url and (\n-        \"localhost\" in resolved_base_url\n-        or \"127.0.0.1\" in resolved_base_url\n-        or \"::1\" in resolved_base_url\n-    )\n-    if _is_local:\n-        # Check if the host block has its own apiKey (explicit local auth).\n-        # Auth-skipping is loopback-only: a stored key is likely a cloud key\n-        # that would break a no-auth local server, so we substitute the SDK's\n-        # required-non-empty placeholder unless the host block opts in.\n-        _raw = config.raw or {}\n-        _host_block = (_raw.get(\"hosts\") or {}).get(config.host, {})\n-        _host_has_key = bool(_host_block.get(\"apiKey\"))\n-        effective_api_key = [redacted:secret-assignment] if _host_has_key else \"local\"\n-    else:\n-        effective_api_key = [redacted:secret-assignment]\n-\n-    # The Honcho SDK's route builders (e.g. routes.workspaces()) already\n-    # include the version prefix (e.g. \"/v3/workspaces\").  When a user-supplied\n-    # base_url already ends in a version segment (e.g.\n-    # \"http://localhost:38000/v3\", \"https://honcho.my.ts.net/v3\"), concatenating\n-    # the two produces \"/v3/v3/workspaces\" → 404 on every call.  This is a pure\n-    # routing concern independent of host, so strip a trailing version segment\n-    # from ANY base_url — loopback, LAN, custom domain, or cloud alike.  The\n-    # SDK then appends its own versioned paths correctly.\n-    if resolved_base_url:\n-        import re as _re\n-        resolved_base_url = _re.sub(r\"/v\\d+/*$\", \"\", resolved_base_url).rstrip(\"/\")\n-\n-    kwargs: dict = {\n-        \"workspace_id\": config.workspace_id,\n-        \"api_key\": [redacted:secret-assignment]\n-        \"environment\": config.environment,\n-    }\n-    if resolved_base_url:\n-        kwargs[\"base_url\"] = resolved_base_url\n-    if resolved_timeout is not None:\n-        kwargs[\"timeout\"] = resolved_timeout\n+        # Allow config.yaml honcho.base_url to override the SDK's environment\n+        # mapping, enabling remote self-hosted Honcho deployments without\n+        # requiring the server to live on localhost.\n+        resolved_base_url = config.base_url\n+        resolved_timeout = config.timeout\n+        if not resolved_base_url or resolved_timeout is None:\n+            try:\n+                from hermes_cli.config import load_config\n+                hermes_cfg = load_config()\n+                honcho_cfg = hermes_cfg.get(\"honcho\", {})\n+                if isinstance(honcho_cfg, dict):\n+                    if not resolved_base_url:\n+                        resolved_base_url = honcho_cfg.get(\"base_url\", \"\").strip() or None\n+                    if resolved_timeout is None:\n+                        resolved_timeout = _resolve_optional_float(\n+                            honcho_cfg.get(\"timeout\"),\n+                            honcho_cfg.get(\"request_timeout\"),\n+                        )\n+            except Exception:\n+                pass\n \n-    _honcho_client = Honcho(**kwargs)\n+        # Fall back to the default so an unconfigured install cannot hang\n+        # indefinitely on a stalled Honcho request.\n+        if resolved_timeout is None:\n+            resolved_timeout = _DEFAULT_HTTP_TIMEOUT\n \n-    return _honcho_client\n+        if resolved_base_url:\n+            logger.info(\"Initializing Honcho client (base_url: %s, workspace: %s)\", resolved_base_url, config.workspace_id)\n+        else:\n+            logger.info(\"Initializing Honcho client (host: %s, workspace: %s)\", config.host, config.workspace_id)\n+\n+        # Local Honcho instances don't require an API key, but the SDK\n+        # expects a non-empty string.  Use a placeholder for local URLs.\n+        # For local: only use config.api_key if the host block explicitly\n+        # sets apiKey (meaning the user wants local auth). Otherwise skip\n+        # the stored key -- it's likely a cloud key that would break local.\n+        _is_local = resolved_base_url and (\n+            \"localhost\" in resolved_base_url\n+            or \"127.0.0.1\" in resolved_base_url\n+            or \"::1\" in resolved_base_url\n+        )\n+        if _is_local:\n+            # Check if the host block has its own apiKey (explicit local auth).\n+            # Auth-skipping is loopback-only: a stored key is likely a cloud key\n+            # that would break a no-auth local server, so we substitute the SDK's\n+            # required-non-empty placeholder unless the host block opts in.\n+            _raw = config.raw or {}\n+            _host_block = (_raw.get(\"hosts\") or {}).get(config.host, {})\n+            _host_has_key = bool(_host_block.get(\"apiKey\"))\n+            effective_api_key = [redacted:secret-assignment] if _host_has_key else \"local\"\n+        else:\n+            effective_api_key = [redacted:secret-assignment]\n+\n+        # The Honcho SDK's route builders (e.g. routes.workspaces()) already\n+        # include the version prefix (e.g. \"/v3/workspaces\").  When a user-supplied\n+        # base_url already ends in a version segment (e.g.\n+        # \"http://localhost:38000/v3\", \"https://honcho.my.ts.net/v3\"), concatenating\n+        # the two produces \"/v3/v3/workspaces\" → 404 on every call.  This is a pure\n+        # routing concern independent of host, so strip a trailing version segment\n+        # from ANY base_url — loopback, LAN, custom domain, or cloud alike.  The\n+        # SDK then appends its own versioned paths correctly.\n+        if resolved_base_url:\n+            import re as _re\n+            resolved_base_url = _re.sub(r\"/v\\d+/*$\", \"\", resolved_base_url).rstrip(\"/\")\n+\n+        kwargs: dict = {\n+            \"workspace_id\": config.workspace_id,\n+            \"api_key\": [redacted:secret-assignment]\n+            \"environment\": config.environment,\n+        }\n+        if resolved_base_url:\n+            kwargs[\"base_url\"] = resolved_base_url\n+        if resolved_timeout is not None:\n+            kwargs[\"timeout\"] = resolved_timeout\n+\n+        return Honcho(**kwargs)\n+\n+    return _honcho_client_slot.get(_build)\n \n \n def reset_honcho_client() -> None:\n     \"\"\"Reset the Honcho client singleton (useful for testing).\"\"\"\n-    global _honcho_client\n-    _honcho_client = None\n+    _honcho_client_slot.reset()"]},{"file":"plugins/plugin_utils.py","hunks":["@@ -0,0 +1,135 @@\n+\"\"\"Shared concurrency helpers for plugin authors.\n+\n+The most common plugin footgun is the lazy process-wide singleton:\n+\n+    _client = None\n+\n+    def get_client():\n+        global _client\n+        if _client is not None:\n+            return _client\n+        _client = ExpensiveClient(...)   # <-- TOCTOU: two threads both run this\n+        return _client\n+\n+When two threads call ``get_client()`` before the singleton is set, both pass\n+the ``is not None`` guard, both run the expensive initialization, and the\n+second write clobbers the first — leaking whatever resource the first client\n+opened (connections, file handles, background threads).\n+\n+Multi-threaded agent sessions share one process (delegated tool calls,\n+background workers, the self-improvement fork), so this race is reachable in\n+practice. Rather than make every plugin author remember to hand-roll\n+double-checked locking, this module gives them two thread-safe primitives:\n+\n+* :func:`lazy_singleton` — decorator for the zero-arg accessor case.\n+* :class:`SingletonSlot` — manual slot for accessors that build different\n+  instances depending on a config/key argument.\n+\n+Both are import-light (stdlib ``threading`` only) so any plugin can import\n+them without dragging in heavyweight host modules.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import functools\n+import threading\n+from typing import Callable, Generic, Optional, TypeVar\n+\n+__all__ = [\"lazy_singleton\", \"SingletonSlot\"]\n+\n+T = TypeVar(\"T\")\n+\n+\n+def lazy_singleton(factory: Callable[[], T]) -> Callable[[], T]:\n+    \"\"\"Wrap a zero-argument factory into a thread-safe lazy singleton accessor.\n+\n+    The wrapped callable returns the same instance on every call; the factory\n+    runs exactly once even under concurrent first calls, using double-checked\n+    locking. A ``.reset()`` attribute is attached for tests/teardown.\n+\n+    Example::\n+\n+        @lazy_singleton\n+        def get_client():\n+            return ExpensiveClient(load_config())\n+\n+        client = get_client()   # built once, safe across threads\n+        get_client.reset()      # drop the instance (next call rebuilds)\n+\n+    Note: if the factory raises, no instance is cached and the next call\n+    retries (the lock is released either way).\n+    \"\"\"\n+    lock = threading.Lock()\n+    box: list = []  # one-element [instance]; empty == not yet built\n+\n+    @functools.wraps(factory)\n+    def accessor() -> T:\n+        if box:\n+            return box[0]\n+        with lock:\n+            if box:  # re-check inside the lock\n+                return box[0]\n+            instance = factory()\n+            box.append(instance)\n+            return instance\n+\n+    def reset() -> None:\n+        with lock:\n+            box.clear()\n+\n+    accessor.reset = reset  # type: ignore[attr-defined]\n+    return accessor\n+\n+\n+class SingletonSlot(Generic[T]):\n+    \"\"\"Thread-safe lazy slot for accessors that take a build argument.\n+\n+    Use this when the cached instance depends on a config/key passed to the\n+    accessor (so a bare zero-arg :func:`lazy_singleton` doesn't fit). The slot\n+    caches the first successfully-built instance and ignores the argument on\n+    subsequent calls — matching the established \"first config wins\" singleton\n+    semantics most plugins already rely on.\n+\n+    Example::\n+\n+        _slot: SingletonSlot[Honcho] = SingletonSlot()\n+\n+        def get_honcho_client(config=None):\n+            return _slot.get(lambda: Honcho(**resolve(config)))\n+\n+        def reset_honcho_client():\n+            _slot.reset()\n+\n+    The factory runs at most once even under concurrent first calls. If the\n+    factory raises, nothing is cached and the next call retries.\n+    \"\"\"\n+\n+    __slots__ = (\"_lock\", \"_value\", \"_set\")\n+\n+    def __init__(self) -> None:\n+        self._lock = threading.Lock()\n+        self._value: Optional[T] = None\n+        self._set = False\n+\n+    def get(self, factory: Callable[[], T]) -> T:\n+        # Fast path: already built, no lock needed (a set bool + ref read is\n+        # atomic under CPython's GIL).\n+        if self._set:\n+            return self._value  # type: ignore[return-value]\n+        with self._lock:\n+            if self._set:  # re-check inside the lock\n+                return self._value  # type: ignore[return-value]\n+            value = factory()\n+            self._value = value\n+            self._set = True\n+            return value\n+\n+    def peek(self) -> Optional[T]:\n+        \"\"\"Return the cached instance without building it (None if unset).\"\"\"\n+        return self._value if self._set else None\n+\n+    def reset(self) -> None:\n+        \"\"\"Drop the cached instance so the next ``get()`` rebuilds it.\"\"\"\n+        with self._lock:\n+            self._value = None\n+            self._set = False"]},{"file":"plugins/video_gen/fal/__init__.py","hunks":["@@ -291,6 +291,7 @@ def _build_payload(\n # ---------------------------------------------------------------------------\n \n _fal_client: Any = None\n+_fal_client_lock = threading.Lock()\n \n \n def _load_fal_client() -> Any:","@@ -298,13 +299,19 @@ def _load_fal_client() -> Any:\n \n     Delegates the actual import to :func:`tools.fal_common.import_fal_client`\n     so the ``lazy_deps`` ensure-install handling stays in one place.\n+\n+    Thread-safe via double-checked locking: concurrent first calls import\n+    the SDK exactly once instead of each racing thread re-running the import.\n     \"\"\"\n     global _fal_client\n     if _fal_client is not None:\n         return _fal_client\n-    from tools.fal_common import import_fal_client\n-    _fal_client = import_fal_client()\n-    return _fal_client\n+    with _fal_client_lock:\n+        if _fal_client is not None:  # re-check inside the lock\n+            return _fal_client\n+        from tools.fal_common import import_fal_client\n+        _fal_client = import_fal_client()\n+        return _fal_client\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/honcho_plugin/test_client.py","hunks":["@@ -819,10 +819,15 @@ def test_truncated_result_has_hash_suffix(self):\n class TestResetHonchoClient:\n     def test_reset_clears_singleton(self):\n         import plugins.memory.honcho.client as mod\n-        mod._honcho_client = MagicMock()\n-        assert mod._honcho_client is not None\n+\n+        # Seed the cached client through the slot's public surface, then\n+        # verify reset_honcho_client() clears it. (The client is cached in\n+        # mod._honcho_client_slot, a thread-safe SingletonSlot, not a bare\n+        # module global anymore — see #24759.)\n+        mod._honcho_client_slot.get(lambda: MagicMock())\n+        assert mod._honcho_client_slot.peek() is not None\n         reset_honcho_client()\n-        assert mod._honcho_client is None\n+        assert mod._honcho_client_slot.peek() is None\n \n \n class TestDialecticDepthParsing:"]},{"file":"tests/test_honcho_client_concurrency.py","hunks":["@@ -0,0 +1,109 @@\n+\"\"\"Concurrency test for get_honcho_client() — the TOCTOU race fix (#24759).\n+\n+Proves the Honcho client is constructed exactly once even when many threads\n+race the first call, by stubbing the SDK constructor and counting invocations.\n+\"\"\"\n+\n+import sys\n+import threading\n+import types\n+\n+import pytest\n+\n+from plugins.memory.honcho import client as honcho_client\n+from plugins.memory.honcho.client import (\n+    HonchoClientConfig,\n+    get_honcho_client,\n+    reset_honcho_client,\n+)\n+\n+\n[redacted:email](autouse=True)\n+def _reset_singleton():\n+    reset_honcho_client()\n+    yield\n+    reset_honcho_client()\n+\n+\n+def _install_fake_honcho_sdk(monkeypatch, build_count, build_lock):\n+    \"\"\"Make `from honcho import Honcho` resolve to a counting fake.\"\"\"\n+\n+    class _FakeHoncho:\n+        def __init__(self, **kwargs):\n+            with build_lock:\n+                build_count[\"n\"] += 1\n+            import time\n+            time.sleep(0.01)  # widen the race window\n+            self.kwargs = kwargs\n+\n+    fake_mod = types.ModuleType(\"honcho\")\n+    fake_mod.Honcho = _FakeHoncho\n+    monkeypatch.setitem(sys.modules, \"honcho\", fake_mod)\n+    # Skip the lazy-install path entirely.\n+    monkeypatch.setattr(\n+        honcho_client, \"_resolve_optional_float\", lambda *a, **k: None, raising=False\n+    )\n+\n+\n+def test_get_honcho_client_builds_once_under_concurrent_first_call(monkeypatch):\n+    build_count = {\"n\": 0}\n+    build_lock = threading.Lock()\n+    _install_fake_honcho_sdk(monkeypatch, build_count, build_lock)\n+\n+    config = HonchoClientConfig(\n+        api_key=\"[redacted:secret-assignment]\",\n+        workspace_id=\"ws\",\n+        environment=\"production\",\n+    )\n+\n+    barrier = threading.Barrier(20)\n+    results = []\n+    results_lock = threading.Lock()\n+\n+    def worker():\n+        barrier.wait()\n+        c = get_honcho_client(config)\n+        with results_lock:\n+            results.append(c)\n+\n+    threads = [threading.Thread(target=worker) for _ in range(20)]\n+    for t in threads:\n+        t.start()\n+    for t in threads:\n+        t.join()\n+\n+    assert build_count[\"n\"] == 1, \"Honcho client must be constructed exactly once\"\n+    assert len(results) == 20\n+    assert all(r is results[0] for r in results), \"all threads share one client\"\n+\n+\n+def test_reset_allows_rebuild(monkeypatch):\n+    build_count = {\"n\": 0}\n+    build_lock = threading.Lock()\n+    _install_fake_honcho_sdk(monkeypatch, build_count, build_lock)\n+\n+    config = HonchoClientConfig(\n+        api_key=\"[redacted:secret-assignment]\", workspace_id=\"ws\", environment=\"production\"\n+    )\n+\n+    c1 = get_honcho_client(config)\n+    assert build_count[\"n\"] == 1\n+    # Cached: no rebuild.\n+    assert get_honcho_client(config) is c1\n+    assert build_count[\"n\"] == 1\n+\n+    reset_honcho_client()\n+    c2 = get_honcho_client(config)\n+    assert build_count[\"n\"] == 2\n+    assert c2 is not c1\n+\n+\n+def test_missing_credentials_still_raises_before_build(monkeypatch):\n+    build_count = {\"n\": 0}\n+    build_lock = threading.Lock()\n+    _install_fake_honcho_sdk(monkeypatch, build_count, build_lock)\n+\n+    bad = HonchoClientConfig(api_key=\"\", base_url=\"\", workspace_id=\"ws\")\n+    with pytest.raises(ValueError):\n+        get_honcho_client(bad)\n+    assert build_count[\"n\"] == 0"]},{"file":"tests/test_plugin_utils.py","hunks":["@@ -0,0 +1,159 @@\n+\"\"\"Tests for plugins/plugin_utils.py — thread-safe lazy singleton helpers.\n+\n+These exercise the actual concurrency guarantee with real threads (not mocks):\n+a barrier releases N threads simultaneously into the accessor, and we assert\n+the factory ran exactly once.\n+\"\"\"\n+\n+import threading\n+\n+import pytest\n+\n+from plugins.plugin_utils import SingletonSlot, lazy_singleton\n+\n+\n+# --- lazy_singleton -------------------------------------------------------\n+\n+\n+def test_lazy_singleton_builds_once_and_returns_same_instance():\n+    calls = []\n+\n+    @lazy_singleton\n+    def get():\n+        calls.append(1)\n+        return object()\n+\n+    a = get()\n+    b = get()\n+    assert a is b\n+    assert len(calls) == 1\n+\n+\n+def test_lazy_singleton_reset_rebuilds():\n+    counter = {\"n\": 0}\n+\n+    @lazy_singleton\n+    def get():\n+        counter[\"n\"] += 1\n+        return counter[\"n\"]\n+\n+    assert get() == 1\n+    assert get() == 1\n+    get.reset()\n+    assert get() == 2\n+\n+\n+def test_lazy_singleton_factory_exception_not_cached():\n+    state = {\"fail\": True}\n+\n+    @lazy_singleton\n+    def get():\n+        if state[\"fail\"]:\n+            raise RuntimeError(\"boom\")\n+        return \"ok\"\n+\n+    with pytest.raises(RuntimeError):\n+        get()\n+    # First call raised → nothing cached → retry succeeds once we stop failing.\n+    state[\"fail\"] = False\n+    assert get() == \"ok\"\n+\n+\n+def test_lazy_singleton_concurrent_first_call_builds_once():\n+    build_count = {\"n\": 0}\n+    build_lock = threading.Lock()\n+    barrier = threading.Barrier(16)\n+    results = []\n+    results_lock = threading.Lock()\n+\n+    @lazy_singleton\n+    def get():\n+        # Count builds under a lock so the assertion is exact even if the\n+        # double-checked lock had a bug and let two through.\n+        with build_lock:\n+            build_count[\"n\"] += 1\n+        # Simulate an expensive build so threads genuinely overlap.\n+        import time\n+        time.sleep(0.01)\n+        return object()\n+\n+    def worker():\n+        barrier.wait()  # release all threads at once\n+        obj = get()\n+        with results_lock:\n+            results.append(obj)\n+\n+    threads = [threading.Thread(target=worker) for _ in range(16)]\n+    for t in threads:\n+        t.start()\n+    for t in threads:\n+        t.join()\n+\n+    assert build_count[\"n\"] == 1, \"factory must run exactly once under race\"\n+    assert len(results) == 16\n+    assert all(r is results[0] for r in results), \"all callers share one instance\"\n+\n+\n+# --- SingletonSlot --------------------------------------------------------\n+\n+\n+def test_slot_caches_first_value():\n+    slot: SingletonSlot = SingletonSlot()\n+    assert slot.peek() is None\n+    v1 = slot.get(lambda: \"first\")\n+    assert slot.peek() == \"first\"\n+    # Subsequent factory is ignored — first value wins.\n+    v2 = slot.get(lambda: \"second\")\n+    assert v1 == v2 == \"first\"\n+\n+\n+def test_slot_reset():\n+    slot: SingletonSlot = SingletonSlot()\n+    slot.get(lambda: \"a\")\n+    slot.reset()\n+    assert slot.peek() is None\n+    assert slot.get(lambda: \"b\") == \"b\"\n+\n+\n+def test_slot_factory_exception_not_cached():\n+    slot: SingletonSlot = SingletonSlot()\n+\n+    def boom():\n+        raise ValueError(\"nope\")\n+\n+    with pytest.raises(ValueError):\n+        slot.get(boom)\n+    assert slot.peek() is None\n+    assert slot.get(lambda: \"recovered\") == \"recovered\"\n+\n+\n+def test_slot_concurrent_first_call_builds_once():\n+    build_count = {\"n\": 0}\n+    build_lock = threading.Lock()\n+    barrier = threading.Barrier(16)\n+    slot: SingletonSlot = SingletonSlot()\n+    results = []\n+    results_lock = threading.Lock()\n+\n+    def factory():\n+        with build_lock:\n+            build_count[\"n\"] += 1\n+        import time\n+        time.sleep(0.01)\n+        return object()\n+\n+    def worker():\n+        barrier.wait()\n+        obj = slot.get(factory)\n+        with results_lock:\n+            results.append(obj)\n+\n+    threads = [threading.Thread(target=worker) for _ in range(16)]\n+    for t in threads:\n+        t.start()\n+    for t in threads:\n+        t.join()\n+\n+    assert build_count[\"n\"] == 1\n+    assert len(results) == 16\n+    assert all(r is results[0] for r in results)"]},{"file":"website/docs/guides/build-a-hermes-plugin.md","hunks":["@@ -488,6 +488,53 @@ When `security.allow_lazy_installs: false` is set globally, `ensure()` raises `F\n \n \n \n+### Thread-safe lazy singletons\n+\n+Plugins often cache an expensive object — an SDK client, an HTTP session, a connection pool — in a module-level variable built on first use:\n+\n+```python\n+_client = None\n+\n+def get_client():\n+    global _client\n+    if _client is not None:\n+        return _client\n+    _client = ExpensiveClient(...)   # ← TOCTOU race\n+    return _client\n+```\n+\n+This is a footgun. Hermes runs multiple threads in one process (delegated tool calls, background workers, the self-improvement fork), so two threads can hit `get_client()` before `_client` is set, **both** pass the `is not None` check, **both** run the expensive build, and the second write clobbers the first — leaking whatever resource the loser opened (connection, file handle, background thread).\n+\n+Don't hand-roll the lock. Use the helpers in `plugins/plugin_utils.py`:\n+\n+```python\n+from plugins.plugin_utils import lazy_singleton, SingletonSlot\n+\n+# Zero-arg accessor → decorate it:\n+@lazy_singleton\n+def get_client():\n+    return ExpensiveClient(load_config())   # runs exactly once\n+\n+client = get_client()    # safe across threads\n+get_client.reset()       # drop the instance (tests / teardown)\n+\n+\n+# Accessor that takes a build argument → use a slot:\n+_slot: SingletonSlot = SingletonSlot()\n+\n+def get_client(config=None):\n+    return _slot.get(lambda: ExpensiveClient(resolve(config)))\n+\n+def reset_client():\n+    _slot.reset()\n+```\n+\n+Both serialize concurrent first calls with double-checked locking and run the factory at most once. If the factory raises, nothing is cached and the next call retries. The honcho memory plugin (`plugins/memory/honcho/client.py`) is the reference consumer.\n+\n+> Rule of thumb: any time you write `global _something` followed by a `is None` check and a build, reach for one of these instead.\n+\n+\n+\n ### Conditional tool availability\n \n For tools that depend on optional libraries:\n"]}]},{"kind":"pr","number":83779,"title":"fix(tools): lock OpenRouter async client init against TOCTOU leak","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}