Skip to content

fix(plugins): thread-safe lazy-singleton helpers; fix honcho TOCTOU (#24759)#42150

Merged
teknium1 merged 2 commits into
mainfrom
hermes/hermes-e1fae77b
Jun 8, 2026
Merged

fix(plugins): thread-safe lazy-singleton helpers; fix honcho TOCTOU (#24759)#42150
teknium1 merged 2 commits into
mainfrom
hermes/hermes-e1fae77b

Conversation

@teknium1

@teknium1 teknium1 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Plugin authors get two reusable thread-safe lazy-singleton primitives, so the check-then-init TOCTOU race in get_honcho_client() (#24759) — and its sibling in fal — can't recur.

Root cause: get_honcho_client() and fal's _load_fal_client() did if x is not None: return x then an unlocked expensive build + assign. Hermes runs multiple threads per process (delegated calls, background workers, the self-improvement fork), so racing first-callers both built a client and the loser's open connection leaked.

Changes

  • plugins/plugin_utils.py (new): lazy_singleton decorator (zero-arg accessors) + SingletonSlot (config-keyed accessors, first-wins). Both use double-checked locking, run the factory at most once, and don't cache failed builds. Stdlib-only so any plugin can import it.
  • plugins/memory/honcho/client.py: get_honcho_client() now routes the build through a SingletonSlot; validation still raises before the slot; reset_honcho_client() delegates to the slot. (Large diff is mostly the +4 reindent of the existing build body into the factory closure.)
  • plugins/video_gen/fal/__init__.py: _load_fal_client() gets a matching double-checked lock (it reads the module global directly at two call sites, so an in-file lock is the lowest-risk fix there).
  • website/docs/guides/build-a-hermes-plugin.md: new "Thread-safe lazy singletons" section next to the lazy-install guidance, pointing authors at the helpers.

Validation

Suite Result
tests/test_plugin_utils.py (incl. 16-thread barrier stress for both primitives) pass
tests/test_honcho_client_concurrency.py (20-thread race → exactly 1 build) pass
tests/test_honcho_client_config.py (existing, no regression) pass
tests/plugins/video_gen/test_fal_plugin.py (existing, no regression) pass
import-cycle check (plugin_utils + honcho + fal) clean

Closes #24759

Infographic

thread-safe-plugin-singletons

…OU (#24759)

get_honcho_client() and fal's _load_fal_client() used unlocked
check-then-init: racing threads both ran the expensive build and the
loser's client (open connection) leaked.

Rather than one-off locks, add plugins/plugin_utils.py with two
reusable primitives every plugin author can drop in:
- lazy_singleton: decorator for zero-arg accessors
- SingletonSlot: manual slot for config-keyed accessors (first wins)

Both use double-checked locking; factory runs at most once; failed
builds aren't cached. honcho is the reference consumer; fal's sibling
TOCTOU gets a matching double-checked lock. Plugin dev guide documents
the pattern so future plugins don't reintroduce the race.

Closes #24759
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-e1fae77b vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10405 on HEAD, 10399 on base (🆕 +6)

🆕 New issues (6):

Rule Count
unresolved-attribute 3
unresolved-import 2
invalid-return-type 1
First entries
tests/test_plugin_utils.py:42: [unresolved-attribute] unresolved-attribute: Object of type `() -> Unknown` has no attribute `reset`
tests/test_honcho_client_concurrency.py:40: [unresolved-attribute] unresolved-attribute: Unresolved attribute `Honcho` on type `ModuleType`
tests/test_plugin_utils.py:10: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/plugin_utils.py:118: [invalid-return-type] invalid-return-type: Return type does not match returned value: expected `T@SingletonSlot`, found `T@SingletonSlot | None`
tests/test_honcho_client_concurrency.py:11: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/plugin_utils.py:80: [unresolved-attribute] unresolved-attribute: Unresolved attribute `reset` on type `_Wrapped[(), T@lazy_singleton, (), T@lazy_singleton]`

✅ Fixed issues: none

Unchanged: 5432 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

test_reset_clears_singleton poked the removed _honcho_client module
global directly. Assert through the slot's public peek() surface
instead, matching the #24759 refactor.
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins tool/memory Memory tool and memory providers labels Jun 8, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

Code Review — Clean ✅

Reviewed the full diff (7 files, ~29K chars). Findings:

Concurrency guard (SingletonSlot): The double-checked locking in plugins/plugin_utils.py is correctly implemented — _set boolean check on the fast path avoids the lock, and the lock-internal re-check prevents the TOCTOU race. The __slots__ usage keeps per-instance memory minimal. The peek() method correctly returns None before initialization and the cached value after, relying on CPython's GIL for the _set flag + _value ref read atomicity.

Honcho client migration: The get_honcho_client() function cleanly moves from a bare global _honcho_client pattern to SingletonSlot.get(factory). The validation checks (credentials, config resolution) still run outside the slot's factory, so a ValueError from missing credentials is raised before any lock acquisition — correct fast-fail behavior.

Falcon video plugin: Same pattern applied to fal/__init__.py, consistent migration.

Test coverage: test_get_honcho_client_builds_once_under_concurrent_first_call uses a threading.Barrier(20) to release all threads simultaneously — this is a proper race window stress test, not a mock-based assertion. The test_missing_credentials_still_raises_before_build test confirms the factory is never invoked when validation fails early.

No issues found.

@teknium1 teknium1 merged commit 47d5177 into main Jun 8, 2026
24 checks passed
@teknium1 teknium1 deleted the hermes/hermes-e1fae77b branch June 8, 2026 16:35
jhjaggars-hermes pushed a commit to jhjaggars/hermes-agent that referenced this pull request Jun 8, 2026
…ousResearch#24759) (NousResearch#42150)

* fix(plugins): add thread-safe lazy-singleton helpers, fix honcho TOCTOU (NousResearch#24759)

get_honcho_client() and fal's _load_fal_client() used unlocked
check-then-init: racing threads both ran the expensive build and the
loser's client (open connection) leaked.

Rather than one-off locks, add plugins/plugin_utils.py with two
reusable primitives every plugin author can drop in:
- lazy_singleton: decorator for zero-arg accessors
- SingletonSlot: manual slot for config-keyed accessors (first wins)

Both use double-checked locking; factory runs at most once; failed
builds aren't cached. honcho is the reference consumer; fal's sibling
TOCTOU gets a matching double-checked lock. Plugin dev guide documents
the pattern so future plugins don't reintroduce the race.

Closes NousResearch#24759

* test(honcho): update reset test for SingletonSlot internals

test_reset_clears_singleton poked the removed _honcho_client module
global directly. Assert through the slot's public peek() surface
instead, matching the NousResearch#24759 refactor.
changman pushed a commit to changman/hermes-agent that referenced this pull request Jun 10, 2026
…ousResearch#24759) (NousResearch#42150)

* fix(plugins): add thread-safe lazy-singleton helpers, fix honcho TOCTOU (NousResearch#24759)

get_honcho_client() and fal's _load_fal_client() used unlocked
check-then-init: racing threads both ran the expensive build and the
loser's client (open connection) leaked.

Rather than one-off locks, add plugins/plugin_utils.py with two
reusable primitives every plugin author can drop in:
- lazy_singleton: decorator for zero-arg accessors
- SingletonSlot: manual slot for config-keyed accessors (first wins)

Both use double-checked locking; factory runs at most once; failed
builds aren't cached. honcho is the reference consumer; fal's sibling
TOCTOU gets a matching double-checked lock. Plugin dev guide documents
the pattern so future plugins don't reintroduce the race.

Closes NousResearch#24759

* test(honcho): update reset test for SingletonSlot internals

test_reset_clears_singleton poked the removed _honcho_client module
global directly. Assert through the slot's public peek() surface
instead, matching the NousResearch#24759 refactor.
alt-glitch pushed a commit that referenced this pull request Jun 14, 2026
…24759) (#42150)

* fix(plugins): add thread-safe lazy-singleton helpers, fix honcho TOCTOU (#24759)

get_honcho_client() and fal's _load_fal_client() used unlocked
check-then-init: racing threads both ran the expensive build and the
loser's client (open connection) leaked.

Rather than one-off locks, add plugins/plugin_utils.py with two
reusable primitives every plugin author can drop in:
- lazy_singleton: decorator for zero-arg accessors
- SingletonSlot: manual slot for config-keyed accessors (first wins)

Both use double-checked locking; factory runs at most once; failed
builds aren't cached. honcho is the reference consumer; fal's sibling
TOCTOU gets a matching double-checked lock. Plugin dev guide documents
the pattern so future plugins don't reintroduce the race.

Closes #24759

* test(honcho): update reset test for SingletonSlot internals

test_reset_clears_singleton poked the removed _honcho_client module
global directly. Assert through the slot's public peek() surface
instead, matching the #24759 refactor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have tool/memory Memory tool and memory providers type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: TOCTOU race in get_honcho_client() allows duplicate Honcho client construction

3 participants