fix(oneshot): shut down memory provider to prevent SIGABRT on exit - #61875
fix(oneshot): shut down memory provider to prevent SIGABRT on exit#61875koshaji wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent hermes -z (oneshot mode) from exiting with SIGABRT when using memory providers that spawn daemon threads (notably the Honcho provider), by ensuring memory-provider shutdown runs before CPython interpreter teardown and by making Honcho’s shutdown more robust.
Changes:
- Register an
atexitcleanup inhermes_cli/oneshot.pyto callshutdown_memory_provider()in oneshot runs. - Track Honcho “context prefetch” background threads and attempt to join them during shutdown.
- Add Honcho-provider shutdown hardening intended to unblock in-flight httpx recv calls and join worker threads.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
hermes_cli/oneshot.py |
Adds oneshot-specific atexit cleanup to shut down the memory provider. |
plugins/memory/honcho/session.py |
Tracks context-prefetch threads and joins them during shutdown. |
plugins/memory/honcho/__init__.py |
Attempts to close Honcho’s http client and join worker threads during provider shutdown. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if self._manager: | ||
| if not (self._init_thread and self._init_thread.is_alive()): | ||
| try: | ||
| self._manager.flush_all() | ||
| except Exception: |
| t = threading.Thread(target=_run, name="honcho-context-prefetch", daemon=True) | ||
| # Track so shutdown() can join it before interpreter teardown. | ||
| self._context_prefetch_threads.append(t) | ||
| t.start() |
| for t in self._context_prefetch_threads: | ||
| if t.is_alive(): | ||
| t.join(timeout=5.0) | ||
| self._context_prefetch_threads.clear() |
| def _shutdown_oneshot_and_exit() -> None: | ||
| try: | ||
| if hasattr(agent, "shutdown_memory_provider"): | ||
| agent.shutdown_memory_provider() | ||
| except Exception: | ||
| pass | ||
|
|
||
| _atexit.register(_shutdown_oneshot_and_exit) | ||
|
|
||
| result = agent.run_conversation(prompt) | ||
| return (result.get("final_response") or "", result) |
| # Tracked fire-and-forget context-prefetch threads, joined in shutdown() | ||
| # so none is left blocked in HTTP recv at interpreter teardown (which | ||
| # aborts CPython — see HonchoMemoryProvider.shutdown). | ||
| self._context_prefetch_threads: list[threading.Thread] = [] |
Duplicate of #49498 — same author, same three files, and effectively the same mechanism (an |
|
Bump — friendly check-in on review status for the oneshot SIGABRT fix. Happy to address any feedback or rebase if needed. (This is the rebased replacement for #49498.) |
Copilot review feedback addressedPushed
|
teknium1
left a comment
There was a problem hiding this comment.
Thanks for pursuing a real current-main oneshot cleanup gap: hermes_cli/oneshot.py:419 currently returns directly after run_conversation() without memory-provider teardown.
Problems
hermes_cli/oneshot.py:437invokesshutdown_memory_provider()without messages.run_agent.py:3313therefore sends[]toon_session_end(), not the real transcript described in the PR. The established CLI pattern iscli.py:1132-1145, which forwards_session_messageswhen it is a list.plugins/memory/honcho/session.py:694-709has an unsynchronized check-then-register sequence. A prefetch can pass_closedbeforeshutdown()sets it and then append/start a daemon aftershutdown()has taken its thread-list snapshot at lines 569-575.- The PR changes lifecycle and concurrent teardown behavior but adds no regression test coverage.
Suggested changes
- Mirror the guarded transcript-forwarding behavior from
cli.py:1132-1145for both explicit and atexit cleanup. - Protect the closed flag and prefetch-thread registration with a shared lock; have shutdown atomically mark closed and snapshot tracked threads before joining.
- Add focused oneshot transcript, fallback/idempotence, and shutdown-race tests.
This is an automated hermes-sweeper review.
| _shutdown_done[0] = True | ||
| try: | ||
| if hasattr(agent, "shutdown_memory_provider"): | ||
| agent.shutdown_memory_provider() |
There was a problem hiding this comment.
shutdown_memory_provider() receives no transcript here, so run_agent.py:3313 calls every provider's on_session_end([]). Please mirror cli.py:1132-1145: pass agent._session_messages when it is a list, with no-argument fallback only for partial/test agents.
|
|
||
| Non-blocking. Consumed next turn via pop_context_result(). This avoids | ||
| a synchronous HTTP round-trip blocking every response. | ||
| """ | ||
| if self._closed: |
There was a problem hiding this comment.
This check is not synchronized with shutdown(): a caller can observe _closed == False, then shutdown can set it and snapshot the list, after which this call appends and starts a new daemon. Guard the closed check plus thread registration and shutdown's closed/snapshot transition with the same lock.
|
#49498 is now closed — this PR supersedes it (rebased + all Copilot feedback incorporated). The |
b55db41 to
e1d2814
Compare
|
Independently hit this on v0.18.2 (2026.7.7.2), NixOS, glibc 2.42, CPython 3.12: I wrote an equivalent fix before finding this PR and can confirm the approach: force-closing the SDK's httpx client is what actually unblocks the stuck threads — join-with-timeout alone (#37635 and friends) can't, because the read never returns. With the client closed + the manager shutdown sentinel, exit went from a consistent 134 to 0 across repeated runs (verified against the deployed build, not just the repo checkout). Two small deltas from my version that might be worth folding in: 1. Public accessor instead of # plugins/memory/honcho/client.py
def peek_honcho_client() -> "Honcho | None":
"""Return the client singleton if one was built, without creating it."""
return _honcho_client_slot.peek()2. Register the atexit hook in the plugin itself — covers every command path that initializes the provider, not just oneshot (with an idempotency guard in # plugins/memory/honcho/__init__.py
def register(ctx) -> None:
"""Register Honcho as a memory provider plugin."""
provider = HonchoMemoryProvider()
ctx.register_memory_provider(provider)
atexit.register(provider.shutdown)Happy to test an updated revision on my setup if useful — repro here is 100% deterministic. |
a90e28a to
6f7923b
Compare
6f7923b to
944fbbe
Compare
|
Pushed
Test results: 19 targeted tests pass; full |
In oneshot mode (`hermes -z`), the memory provider's shutdown() was never called — only the interactive CLI registers the atexit cleanup. Providers with daemon worker threads blocked in C-level I/O at interpreter exit (e.g. Honcho's httpx socket recv) are then forcibly killed by CPython at Py_FinalizeEx (PyThread_exit_thread → __pthread_unwind → abort()), producing SIGABRT (exit 134) with no Python traceback. Built-in memory is unaffected (no daemon threads). Fix: - hermes_cli/oneshot.py: register an atexit hook in _run_agent() calling agent.shutdown_memory_provider(), mirroring the interactive CLI's _run_cleanup. This is the general fix for any provider with background threads. - plugins/memory/honcho: harden shutdown() to join all daemon threads (init/prefetch/sync) and close the SDK's underlying httpx.Client, which interrupts any worker still blocked in recv so the joins actually complete instead of racing a 30s read poll. Track fire-and-forget context-prefetch threads in HonchoSessionManager so they can be joined. Verified: `hermes -z` with Honcho enabled now exits 0 (was 134) across multiple runs/prompts; built-in memory still exits 0 (no regression). Refs: CPython gh-97940 / bpo-20526 (daemon threads blocked in C I/O at interpreter exit abort the process). Co-Authored-By: Claude <noreply@anthropic.com>
1. Shutdown ordering: close httpx.Client BEFORE manager.shutdown() so threads blocked in socket recv are interrupted first, allowing the subsequent joins to complete instead of timing out. 2. Thread list pruning: prefetch_context() now prunes completed threads before appending new ones, preventing unbounded list growth in long-lived gateway sessions. 3. Thread ref preservation: shutdown() keeps references to threads that don't join within the timeout (instead of clearing the list), so a later shutdown phase can retry after the http client close unblocks them. 4. Transcript gap: call shutdown_memory_provider() explicitly after the conversation completes (not just via atexit), so providers' on_session_end hooks receive the real transcript. The atexit hook is now a guarded fallback for the uncaught-exception path only. 5. Add 'closed' flag to HonchoSessionManager: shutdown() and prefetch_context() now check the flag to fail fast after teardown, preventing new threads from spawning after shutdown.
- Add _prefetch_threads_lock to HonchoSession.__init__ to prevent TOCTOU race between prefetch_context() and shutdown() that could lose a tracked thread → lingering daemon → SIGABRT at exit - Snapshot-then-join pattern (same as OpenViking _runtime_start_lock) - Add logger.warning in _close_honcho_http_client when Honcho SDK client can't be located, so silent SDK attribute drift is observable instead of the SIGABRT silently returning
- Correct the SIGABRT mechanism comment: CPython abandons daemon threads at Py_FinalizeEx, it does not forcibly kill them via PyThread_exit_thread. The precise abort path is not fully characterized (likely Py_FatalError or glibc mutex assert), not __pthread_unwind → abort(). - Add worst-case shutdown latency comment (~30s+) - Add test_honcho_shutdown.py: 7 tests covering prefetch lock usage, SDK drift warning, and shutdown join behavior
… race Address sweeper review on the oneshot SIGABRT fix: 1. Oneshot cleanup previously called shutdown_memory_provider() bare, so run_agent.py's on_session_end() hooks received [] instead of the real transcript. Both the explicit finally path and the atexit fallback now funnel through a shared once-guarded hook that forwards the agent's _session_messages (mirroring cli.py:_run_cleanup's guarded pattern, with the same non-list fallback), so whichever path fires first wins and the other becomes a no-op. 2. HonchoSessionManager had an unsynchronized check-then-register window: prefetch_context() could pass the _closed check, then register and start a daemon thread after shutdown() had taken its thread-list snapshot — a lost thread left blocked in HTTP recv at interpreter teardown (SIGABRT). shutdown() now atomically marks closed and snapshots tracked threads under _prefetch_threads_lock, and prefetch_context() re-checks _closed and registers/starts under the same lock, so a thread either lands in the snapshot (and is joined) or never starts. 3. Add regression tests: oneshot cleanup forwards the real transcript; cleanup is idempotent across explicit + atexit paths; missing/non-list messages fall back to the bare call; a deterministic interleaving test that shutdown() landing mid-prefetch blocks registration; prefetch registered before shutdown is joined.
944fbbe to
2a194a0
Compare
|
Rebased onto current Three conflict resolutions in
Verification (Python 3.13, full dep install):
Should be |
Problem
In oneshot mode (
hermes -z), the memory provider's shutdown() is never called — only the interactive CLI registers the atexit cleanup. Providers with daemon worker threads blocked in C-level I/O at interpreter shutdown (e.g. Honcho's httpx socket recv) are then forcibly killed by CPython at Py_FinalizeEx, producing SIGABRT (signal 6) with no Python traceback.Built-in memory (SQLite) is unaffected — it has no daemon threads.
Reproduction
Fix
hermes_cli/oneshot.py: register anatexithook in_run_agent()callingagent.shutdown_memory_provider(), mirroring the interactive CLI's_run_cleanup. This is the general fix for any memory provider with background threads.plugins/memory/honcho/__init__.py: hardenshutdown()to join all daemon threads with a timeout, close the httpx client, and mark the session as closed so subsequent calls fail fast instead of hanging.plugins/memory/honcho/session.py: addclosedflag and_close()method.Testing
Verified on production fleet (3 Hermes gateway instances running 24/7 with Honcho memory). The crash no longer occurs after this fix.
References