Skip to content

fix(oneshot): shut down memory provider to prevent SIGABRT on exit - #61875

Open
koshaji wants to merge 5 commits into
NousResearch:mainfrom
koshaji:fix/oneshot-sigabrt-upstream
Open

fix(oneshot): shut down memory provider to prevent SIGABRT on exit#61875
koshaji wants to merge 5 commits into
NousResearch:mainfrom
koshaji:fix/oneshot-sigabrt-upstream

Conversation

@koshaji

@koshaji koshaji commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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

# With Honcho memory provider configured:
hermes -z "hello"
# Process crashes with SIGABRT (no traceback)

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 memory provider with background threads.
  • plugins/memory/honcho/__init__.py: harden shutdown() 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: add closed flag 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

  • CPython issue: gh-97940 — threads blocked in I/O at interpreter shutdown

Copilot AI review requested due to automatic review settings July 10, 2026 05:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 atexit cleanup in hermes_cli/oneshot.py to call shutdown_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.

Comment on lines +1435 to +1439
if self._manager:
if not (self._init_thread and self._init_thread.is_alive()):
try:
self._manager.flush_all()
except Exception:
Comment thread plugins/memory/honcho/session.py Outdated
Comment on lines 691 to 694
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()
Comment thread plugins/memory/honcho/session.py Outdated
Comment on lines +563 to +566
for t in self._context_prefetch_threads:
if t.is_alive():
t.join(timeout=5.0)
self._context_prefetch_threads.clear()
Comment thread hermes_cli/oneshot.py Outdated
Comment on lines 428 to 438
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)
Comment on lines +146 to +149
# 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] = []
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins tool/memory Memory tool and memory providers duplicate This issue or pull request already exists labels Jul 10, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Duplicate of #49498 — same author, same three files, and effectively the same mechanism (an atexit hook in hermes_cli/oneshot.py calling shutdown_memory_provider() plus Honcho-side thread-join/httpx-close hardening). #49498 (opened 2026-06-20) is the earlier open version; this re-submission reorganizes the hook inline. Suggest closing this in favor of #49498 (or folding any refinements back into it). Related distinct-mechanism siblings: #52186 (join prefetch threads), #50217 (skip prefetch enqueue), #31664 (try/finally).

@koshaji

koshaji commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

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.)

@koshaji

koshaji commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Copilot review feedback addressed

Pushed b55db41 addressing all 5 review comments:

  1. Shutdown ordering_close_honcho_http_client() is now called before _manager.shutdown(), so threads blocked in socket recv are interrupted first. The duplicate _manager.shutdown() call (post-http-close) has been removed; the manager shutdown now runs once, after the client close.

  2. Thread list pruningprefetch_context() now prunes completed threads ([th for th in ... if th.is_alive()]) before appending new ones, preventing unbounded growth in long-lived sessions.

  3. Thread ref preservationshutdown() keeps references to threads that don't join within the timeout instead of calling .clear(). Only threads that are still alive after the join attempt are retained for retry.

  4. Transcript gap_shutdown_oneshot_and_exit() is now called explicitly after run_conversation() returns, so the memory provider's teardown receives the real session state. The atexit registration remains as a guarded fallback (_shutdown_done flag prevents double-call).

  5. closed flag — Added self._closed to HonchoSessionManager. Both shutdown() and prefetch_context() check it to fail fast after teardown.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:437 invokes shutdown_memory_provider() without messages. run_agent.py:3313 therefore sends [] to on_session_end(), not the real transcript described in the PR. The established CLI pattern is cli.py:1132-1145, which forwards _session_messages when it is a list.
  • plugins/memory/honcho/session.py:694-709 has an unsynchronized check-then-register sequence. A prefetch can pass _closed before shutdown() sets it and then append/start a daemon after shutdown() 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-1145 for 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.

Comment thread hermes_cli/oneshot.py Outdated
_shutdown_done[0] = True
try:
if hasattr(agent, "shutdown_memory_provider"):
agent.shutdown_memory_provider()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@koshaji

koshaji commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

#49498 is now closed — this PR supersedes it (rebased + all Copilot feedback incorporated). The duplicate label can be removed; I don't have admin rights to do so on this repo.

@koshaji
koshaji force-pushed the fix/oneshot-sigabrt-upstream branch from b55db41 to e1d2814 Compare July 11, 2026 12:32
@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 11, 2026
@ottodevs

Copy link
Copy Markdown

Independently hit this on v0.18.2 (2026.7.7.2), NixOS, glibc 2.42, CPython 3.12: hermes -z printed its answer, then died with SIGABRT (exit 134) on every run. Core backtrace matched the mechanism this PR targets — daemon thread blocked in sock_recv wakes during finalization → take_gilPyThread_exit_threadpthread_exit → glibc abort while unwinding.

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 _manager._honcho — the manager sets _honcho lazily in the honcho property, so the traversal can come up empty when nothing touched the property in that run; the singleton slot always knows:

# 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 shutdown() so explicit + atexit double calls are harmless):

# 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.

@teknium1 teknium1 added the area/memory Memory subsystem: store, providers, sync, background reviews label Jul 19, 2026
@koshaji
koshaji force-pushed the fix/oneshot-sigabrt-upstream branch from a90e28a to 6f7923b Compare July 27, 2026 14:22
@alt-glitch alt-glitch removed the duplicate This issue or pull request already exists label Jul 27, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Correction: #49498 is closed, and the author confirms this PR is its rebased replacement with additional review fixes. The stale duplicate relation has been removed. Related to open #31664 and the broader oneshot-Honcho SIGABRT fix cluster.

@koshaji
koshaji force-pushed the fix/oneshot-sigabrt-upstream branch from 6f7923b to 944fbbe Compare July 27, 2026 14:37
@koshaji

koshaji commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 944fbbe27 — rebased onto current main and addressed all three review points:

  1. Transcript forwarding — one note: current main already forwards the transcript on the explicit finally path in _run_agent (the review referenced an older main). What remained was the atexit fallback: there is now a shared, once-guarded _shutdown_memory_provider_with_transcript(agent) mirroring cli.py's guarded pattern (_session_messages forwarded when it is a list, bare call otherwise), used by both the explicit path and the atexit hook — whichever fires first wins, the other is a no-op.
  2. Shutdown/prefetch raceshutdown() now atomically marks _closed and snapshots tracked prefetch threads inside _prefetch_threads_lock, and prefetch_context() re-checks _closed, registers, and starts the thread under the same lock. A prefetch thread now either lands in shutdown's join snapshot or never starts.
  3. Regression tests — new tests/hermes_cli/test_oneshot_memory_cleanup.py (8 tests: real-transcript forwarding, once-only cleanup across explicit+atexit, non-list fallback, exception safety) and TestShutdownPrefetchRace in tests/honcho_plugin/test_honcho_shutdown.py (4 tests, incl. a deterministic TOCTOU interleaving that injects shutdown() between prefetch's closed-check and registration and asserts the thread never starts).

Test results: 19 targeted tests pass; full tests/honcho_plugin/ suite 437 passed / 20 skipped.

koshaji and others added 5 commits August 12, 2026 12:27
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.
@koshaji
koshaji force-pushed the fix/oneshot-sigabrt-upstream branch from 944fbbe to 2a194a0 Compare August 12, 2026 02:45
@koshaji

koshaji commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (9da6d455, was 3481 commits behind) and verified locally.

Three conflict resolutions in plugins/memory/honcho/session.py — all in the shutdown() region, all caused by main adding an _async_thread_lock and a lazy honcho property that starts the async writer on first access (unrelated to this fix):

  1. Init (line ~204): kept both main's _async_thread_lock = threading.Lock() and this PR's _context_prefetch_threads: list[threading.Thread] = []. Independent attributes, both wanted.
  2. shutdown() async-writer guard: main introduced a two-level guard (if self._async_queue is not None:flush_all()if self._async_thread is not None and .is_alive(): → signal+join). This PR's prior version combined them into a single if queue is not None and thread is not None:. Kept main's two-level form — it's safer: if the writer thread was never started (the new lazy honcho property means _async_thread can be None even when _async_queue is set), main's version still flushes queued messages, the combined form would skip flush_all() and leak them. The prefetch-thread joining from this PR is preserved unchanged.
  3. TOCTOU race fix (final commit): the atomic _prefetch_threads_lock-guarded closed-check + snapshot is preserved verbatim; only the async-writer guard line underneath was switched to main's two-level form per point 2.

Verification (Python 3.13, full dep install):

  • tests/honcho_plugin/ + tests/hermes_cli/test_oneshot_memory_cleanup.py + oneshot tests: 316 passed, 0 failed (was 297 baseline; the +19 are this PR's new tests, all green on current main).
  • Full tests/hermes_cli/ sweep shows 166 webhook_cli failures, but I confirmed these are pre-existing on plain main (same 166 fail without this PR's changes) — a test-isolation issue in test_webhook_cli.py unrelated to this fix.

Should be MERGEABLE now with no conflicts. The SIGABRT mechanism this targets is still live on main (daemon prefetch threads are still spawned and still not joined at teardown).

@alt-glitch alt-glitch removed the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/memory Memory subsystem: store, providers, sync, background reviews comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state 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.

5 participants