Skip to content

fix(tui_gateway): close slash_worker on WS detach to prevent memory leak - #57687

Open
yingliang-zhang wants to merge 99 commits into
NousResearch:mainfrom
yingliang-zhang:fix/slash-worker-lifecycle-comprehensive
Open

fix(tui_gateway): close slash_worker on WS detach to prevent memory leak#57687
yingliang-zhang wants to merge 99 commits into
NousResearch:mainfrom
yingliang-zhang:fix/slash-worker-lifecycle-comprehensive

Conversation

@yingliang-zhang

Copy link
Copy Markdown
Contributor

Summary

The Desktop app uses one WebSocket for all sessions. When the user switches sessions or the WS reconnects (e.g. after macOS sleep), old sessions are detached to _detached_ws_transport but their slash_worker subprocesses (~13 MB each) stay alive until the 6 h TTL reaper or 20 s orphan reaper fires — which may never happen if a background curator review keeps the session flagged as running.

Over a few hours this accumulates dozens of idle workers, contributing to GIL pressure and memory bloat. In production this was observed as 17 zombie slash_workers (~230 MB) causing event-loop stalls up to 10.6 s.

Changes

1. Close slash_worker on WS detach (core fix)

tui_gateway/server.py_close_sessions_for_transport():

When a session is detached (WS disconnect without close_on_disconnect), its slash_worker is now closed immediately instead of lingering. The worker is recreated lazily on the next slash command — both the slash.exec handler and _restart_slash_worker already handle worker=None.

2. Add INFO logging to _close_session_by_id (observability)

Previously the entire session-close path was completely silent — no log at any level. This made it impossible to diagnose why sessions were or weren't being reaped. A single INFO line makes the teardown path visible:

INFO tui_gateway.server: session closed sid=abc123 end_reason=ws_orphan_reap

Why this is safe

  • _SlashWorker.close() is already idempotent (_closed guard + poll() guard)
  • _finalize_session (the session-end chokepoint, PR fix(tui): close slash_worker inside _finalize_session (defense-in-depth, #38095) #42149) already closes the worker — our fix just closes it sooner, at detach time
  • The slash.exec handler creates a new worker on demand when session["slash_worker"] is None (server.py ~L12553)
  • _restart_slash_worker does the same after each turn

Testing

tests/tui_gateway/test_slash_worker_detach.py::test_close_sessions_for_transport_closes_worker_on_detach PASSED
tests/tui_gateway/test_slash_worker_detach.py::test_close_sessions_for_transport_preserves_worker_on_reap PASSED
tests/tui_gateway/test_slash_worker_detach.py::test_close_sessions_for_transport_handles_worker_close_exception PASSED
tests/tui_gateway/test_slash_worker_detach.py::test_close_session_by_id_logs_end_reason PASSED

All 24 existing slash_worker / ws_orphan / close_session tests still pass.

Related work (complementary, non-overlapping)

This PR addresses a different leak path from three open PRs. They are complementary and can all merge independently:

PR Author Fixes Leak path
#53308 @pasevin Drain thread join on close 2 leaked threads per session
#41473 @Fewmanism Worker registry + idempotent close Same-session-key duplicate workers
#48656 @Nigmat-future Startup orphan reaper + zombie parent detection Workers surviving gateway crash
this PR Close worker on WS detach Workers surviving session switch

Related issues

@alt-glitch alt-glitch added type/bug Something isn't working comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 3, 2026
@yingliang-zhang

Copy link
Copy Markdown
Contributor Author

CI slice 7/8 failed on tests/gateway/test_allowed_channels_widening.py — this is a collection timeout (0 tests ran), not a test assertion failure. This file is unrelated to this PR (which only touches tui_gateway/server.py).

Local verification — all pass:

  • tests/gateway/test_allowed_channels_widening.py — 27 passed (1.36s)
  • tests/tui_gateway/test_slash_worker_drain.py — 3 passed
  • tests/tui_gateway/test_slash_worker_detach.py — 4 passed

This looks like a flaky CI timeout. Could a maintainer re-run the failed job?

@Kinkoolino-Hermes

Copy link
Copy Markdown
Contributor

This matches a real deployment pain point: detached Desktop/TUI WebSocket sessions can leave slash workers around long after the visible chat has moved on or recovered.

The useful boundary here is narrower than the older all-in-one session-leak PRs: close/reap the worker tied to the detached WS transport without changing unrelated CLI session finalization behavior. That narrower scope makes this easier to review as a targeted stale-worker fix.

@yingliang-zhang

Copy link
Copy Markdown
Contributor Author

Thanks @Kinkoolino-Hermes for the validation. Agreed on the narrower boundary — closing/reaping the worker tied to the detached WS transport without touching unrelated CLI session finalization is the right scope. The comprehensive lifecycle fix (WS detach close + drain thread join + getattr compat for object.__new__ constructed workers) is all in this PR.

@yingliang-zhang
yingliang-zhang force-pushed the fix/slash-worker-lifecycle-comprehensive branch 5 times, most recently from 9f34c44 to 1bddff0 Compare July 12, 2026 08:29
@yingliang-zhang

Copy link
Copy Markdown
Contributor Author

Rebased this PR onto current origin/main (964ecef40) and force-pushed the fork branch with lease protection. New head: 1bddff030.

Conflict resolution preserved the three slash-worker lifecycle commits plus the contributor AUTHOR_MAP entry; no unrelated changes were added.

Fresh verification on the rebased head:

  • scripts/run_tests.sh tests/tui_gateway/test_slash_worker_detach.py tests/tui_gateway/test_slash_worker_drain.py7 passed
  • Three existing close-path regressions (zombie_and_closes_fds, close_session_by_id_is_idempotent_and_full, attach_worker_closes_orphan_when_session_already_torn_down) → 3 passed
  • ruff check on all four changed files → passed
  • py_compile on all four changed files → passed

I also re-audited all worker close/set-None paths: detach closes and nulls the worker, later finalization therefore skips it, and close() remains idempotent.

@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 isolating the detached-session lifecycle path. The premise still holds on current main: tui_gateway/server.py:823-826 detaches non-flagged sessions without closing their worker, while tui_gateway/server.py:760-762 excludes running sessions from orphan reaping. The lazy recreation paths already exist at tui_gateway/server.py:2813-2833 and :13314-13322.

Problems

  • tests/tui_gateway/test_slash_worker_drain.py:130 uses inspect.getsource; root AGENTS.md:1358-1379 explicitly bans source-shape tests.
  • tests/tui_gateway/test_slash_worker_drain.py:68 releases the test threads before close() is invoked, so the assertion cannot prove the new join(timeout=2) loop is executed.

Suggested changes

  • Replace the source inspection with a behavioral constructor test, or omit the thread-name assertion.
  • Use fake thread objects with spyable join() methods and assert both receive timeout=2 during _SlashWorker.close().

This is an automated hermes-sweeper review.

Comment thread tests/tui_gateway/test_slash_worker_drain.py
@teknium1 teknium1 added sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
@yingliang-zhang
yingliang-zhang force-pushed the fix/slash-worker-lifecycle-comprehensive branch 2 times, most recently from 7aa97f1 to 157c75b Compare July 18, 2026 22:33
@teknium1 teknium1 added the area/memory Memory subsystem: store, providers, sync, background reviews label Jul 19, 2026
@Kinkoolino-Hermes

Copy link
Copy Markdown
Contributor

Thanks for replacing the source-shape assertion with a behavioral constructor test. One review point still appears open on current head 157c75b001959da83f2f4f52eaa1e4b16b248dcc: test_slash_worker_close_joins_drain_threads starts threads whose event is already set, sleeps before close(), and then only checks is_alive(). It can therefore pass even if the join(timeout=2) loop is removed.

Could you use fake thread spies there and assert that both stored drain-thread references receive join(timeout=2) during close()? That would directly cover the cleanup behavior the test is intended to protect.

@yingliang-zhang

yingliang-zhang commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in commit be38ba56101eb2623d33f8c91d53f13344e04041: the drain-cleanup regression now uses spyable stored thread doubles and directly asserts both receive join(timeout=2). Focused test: tests/tui_gateway/test_slash_worker_drain.py (3 passed).

@Kinkoolino-Hermes

Copy link
Copy Markdown
Contributor

Thanks — the spy-based regression described in be38ba56101eb2623d33f8c91d53f13344e04041 directly addresses the remaining review point by checking that both stored drain-thread references receive join(timeout=2). The current head’s required checks are green, so the test gap raised in this review thread is resolved.

@yingliang-zhang
yingliang-zhang force-pushed the fix/slash-worker-lifecycle-comprehensive branch 2 times, most recently from 292bb03 to c706e9c Compare August 17, 2026 00:54
@yingliang-zhang
yingliang-zhang requested a review from a team August 17, 2026 00:54
…ross-session bleed (NousResearch#51058)

Rebased on current upstream/main. Combines two fixes:
1. null-sid guard: when sid is null during session switch/reset, drop ALL
   non-gateway events instead of letting them through (original NousResearch#51058 fix)
2. empty-string session_id: _emit() can set session_id='' when callers
   omit it. Use explicit equality (evSid !== sid) instead of truthiness,
   and broaden global-event bypass to gateway./pet./skin./billing.

Closes NousResearch#51058

(cherry picked from commit 638fc85ba945da7eb0a10b066a4237266d55515d)
(cherry picked from commit ee6b42b7c42fed7e376a79e4426c6aafc5577d35)
The Copy button on write_file / patch tool rows copied the inline diff
shown in the tool card, which is capped at _MAX_INLINE_DIFF_LINES (80)
for display. Large file writes (>80 lines) copied incomplete content
with a trailing '… omitted N diff line(s)' marker.

Fix: toolCopyPayload now prefers the full content from the tool call
args (write_file) or the full diff from the tool result (patch) over
the truncated inline diff. This mirrors the existing pattern where
clampForDisplay bounds what is painted, but the Copy button yields
the full output.

Tests: 3 new cases in toolCopyPayload — full write_file content,
full patch diff, and fallback to inline diff when args.content is
absent.

(cherry picked from commit 3fccc76)
The union merge dropped the single call-site wiring line for the already-configured option (patch 360's only material gap); compress options read the value but never passed it.
…hropic wire

MiniMax-M3 rides MiniMax's server-side automatic prefix cache; explicit
cache_control is documented for the M2 family only, so markers on M3 are
dead weight (cache_creation always 0, never billable). Restore the
exclusion (provider-id or host match) checked BEFORE the native-Anthropic
return, so provider="anthropic" pointed at a MiniMax /anthropic proxy is
also caught. The later M2 opt-in reuses the hoisted route predicate.

Fixes tests/run_agent/test_anthropic_prompt_cache_policy.py
::TestMiniMaxAnthropicWire (4 candidate-only failures).
switch_model snapshots _reasoning_echo_flag into _primary_runtime and a
fallback entry overwrites the live flag, but restore_primary_runtime never
reverted it, so the fallback's opt-in leaked into the restored primary.
Restore the flag from the snapshot (default False for snapshots taken
before the field existed). Also tolerate a missing context_compressor when
rebinding context-engine state; gateway/CLI paths may construct it lazily.

Fixes tests/agent/test_message_sanitization_policy.py
::TestPerProviderReasoningEcho::test_restore_primary_reverts_flag.
With the inline dispatch branch amputated, setup_mcp escaped to
handle_function_call while the post-hook ownership predicate also dropped
its membership, breaking the emit-once contract on both executor paths.
Restore the invoke_tool branch and the AGENT_RUNTIME_POST_HOOK_TOOL_NAMES
entry.

Fixes tests/run_agent/test_run_agent.py::TestAgentRuntimePostHookOwnershipSync
(2 candidate-only failures).
…ach, log close reason

Three restorations for the slash-worker lifecycle:

- Keep references to the drain threads (named slash-drain-stdout /
  slash-drain-stderr for leaked-thread attribution) and join them with a
  bounded 2s timeout in close(); previously each closed session leaked two
  daemon threads holding worker references (NousResearch#53303).
- _close_sessions_for_transport now closes the session's slash worker
  immediately when detaching a non-close_on_disconnect session instead of
  letting it linger until the orphan/TTL reaper; tolerates worker.close()
  failure and a missing history_lock. The worker recreates lazily on next
  use.
- _close_session_by_id logs "session closed: <sid> (end_reason=...)" at
  INFO; the teardown path was silent, making reap behavior undiagnosable.

Fixes tests/tui_gateway/test_slash_worker_drain.py (2) and
tests/tui_gateway/test_slash_worker_detach.py (3).
switch_model refreshed only model metadata on the compressor, so a move
onto a custom codex_responses route never applied the per-route Codex
threshold autoraise (0.85) and moving back never restored the preserved
config baseline. Resolve the new route's threshold the same way agent_init
does at startup — _compression_threshold_for_model gated by
_codex_gpt55_autoraise, combined with the raise-only resolver semantics —
and pass it through update_model(default_threshold_percent=...). The
agent-side _compression_global_threshold attribute is optional; the
compressor's immutable config baseline substitutes when the agent wasn't
fully constructed.

Also tolerate partially-initialised ContextCompressor instances in
_effective_max_tail_message_floor (fixtures built via __new__ set only the
attributes under test).

Fixes tests/run_agent/test_switch_model_context.py
::test_switch_model_custom_codex_threshold_uses_resolved_window,
tests/agent/test_context_compressor_cross_session_guard.py (3) and
tests/agent/test_compressor_image_tokens.py
::TestTokenBudgetWithImages::test_image_heavy_turns_count_toward_budget.
…d tool tails on the wire

Restoration of three lost behaviors around durable prompt/transcript
reuse:

1. _restore_or_build_system_prompt reused the persisted prompt verbatim
   but never seeded the frozen plugin-section snapshot, so the first
   invalidate/rebuild in the resumed process re-evaluated section
   callbacks and rewrote prompt bytes, breaking the byte-identical-resume
   contract and the cache prefix. Seed the snapshot from the persisted
   prompt; section callbacks evaluate only on new sessions.

2. sanitize_api_messages lost the NousResearch#48879/NousResearch#63292 closure pass: a durable
   _interrupted_tool_tail tool row followed by a user redirect reached the
   wire as a bare tool -> user alternation, which strict providers reject
   or hallucinate against. Close the sequence on the per-call API copy
   with the interim "Operation interrupted." assistant marker, and strip
   the internal provenance key from the wire copy.

3. Re-align the NousResearch#68454 rotation-flush control test with this stack's
   schema: the partial UNIQUE index
   idx_messages_active_dedupe(session_id, role, content, timestamp) WHERE
   active=1 makes a bare flush of unstamped cold-resume rows a DB-level
   no-op, so the control now forbids duplicates instead of asserting the
   upstream double-write. Production is unchanged; the boundary/no-op and
   tail-only sibling tests are untouched.

Fixes tests/agent/test_plugin_prompt_sections.py
::test_fresh_process_resume_restores_identical_full_prompt_without_callback,
tests/agent/test_session_rotation_flush_cold_resume_68454.py
::test_rotation_flush_without_history_boundary_duplicates,
tests/agent/test_interrupt_tool_tail_api_sanitization.py
::test_user_after_interrupted_tool_tail_is_closed_only_in_api_copy, and
tests/agent/test_turn_finalizer_interrupt_alternation.py
::test_interrupt_after_tool_keeps_transcript_clean_and_closes_api_copy.
…e-boundary submits

The 63298 queue-boundary contract emits `queued: true` on re-submission and rebinds the queued entry's session identity to the recovered runtime before the first visible submit; update the two stale assertions (3 session.resume calls, recovered id on both submits).
The runtime updater pipeline's hindsight_post_sync verifier ran fully on the promoted branch state but rejected the 0.6.1 pin; bumping to the same 0.8.4 the server env already carries.
…lersistence)

uv sync --locked prunes any package without a lock entry; main-venv embed package must be part of the resolving graph to survive dependency_sync before hindsight_post_sync.
…ddedClient)

Restores plugins/memory/hindsight/embedded_runtime.py from the P0 hindsight-server isolation lineage (1660e5528). Required by the updater's verify_hindsight_runtime runtime-adapter contract: imports cleanly without the full hindsight namespace and exposes _ensure_started/close/__getattr__.
…_cron_session_db

The done-callback _close_late_session_db_result was registered before
future.result() was called, so when SessionDB() completed on time (the
normal case) the callback fired immediately in the calling thread and
closed the connection — returning a SessionDB with _conn=None.  This
caused the stale-session reaper to silently return 0 (list_open_cron_sessions
hit _conn.execute on None) and run_job to fail when titling sessions.

Matching the upstream pattern: register the callback only inside the
except TimeoutError block so it fires exclusively for a late result.

Fixes 5 TestReaperFailClosed tests and test_monitor_kind failures.
The Hindsight plugin's _get_client() still used the legacy
'from hindsight import HindsightEmbedded' path requiring hindsight-all
(torch/transformers/sentence-transformers ~2-3GB) in the agent venv.
The split-runtime design (DedicatedEmbeddedClient in embedded_runtime.py)
was committed (ec7ba1a) but the __init__.py wiring was lost in a
subsequent rebase, leaving Hindsight silently offline since Aug 14.

Changes:
- _check_local_runtime: probe hindsight_client + hindsight_embed +
  embedded_runtime (lightweight, <1s) instead of hindsight +
  sentence_transformers (monolith, requires 134-package install)
- _get_client: use DedicatedEmbeddedClient from .embedded_runtime
  instead of HindsightEmbedded; drop __del__ monkey-patch; add
  server_executable kwarg
- _probe_url: read inner Hindsight._base_url (private attr) instead
  of .url (would trigger __getattr__ daemon start)
- _local_runtime_hint: broadened trigger; new guidance points to
  hermes memory setup + HINDSIGHT_EMBED_API_EXECUTABLE, not hindsight-all
- Version constants: restore _MIN=0.8.4, _MAX=0.10,
  _CLIENT_REQUIREMENT, _EMBED_REQUIREMENT (was regressed to 0.6.1)
- Setup wizard: install [client, embed] not hindsight-all for local_embedded
- embedded_runtime.py: add seal cache (keyed by candidate+fingerprint,
  45000x speedup) + conditional LLM config (only set HINDSIGHT_API_LLM_*
  when truthy, preventing empty-string shadowing of hermes.env keys)
- plugin.yaml: declare hindsight-embed>=0.8.4,<0.10
- Remove hindsight-all from memory_setup.py, web_server.py, update_cmd.py
- Update tests to mock DedicatedEmbeddedClient; port
  test_hindsight_embedded_runtime.py (621 LOC) from reference; add
  seal-cache regression test
- Update website docs (EN + zh-Hans) to split-runtime terminology

Verified: hermes memory status → available; _check_local_runtime →
(True, None) in 0.49s; seal cache 5.45s→0.0001s; live recall 26 facts
in 3.68s; 154 passed / 3 pre-existing failures (confirmed on pristine HEAD).
Resolved conflicts:
- cron/scheduler.py: kept both hashlib (ours) and errno (upstream) imports
- cron/scheduler_provider.py: adopted upstream EMFILE self-heal backoff
- pyproject.toml: kept our hindsight 0.8.4 + upstream mcp 2.0.0/httpx2 upgrade

Auto-merged 23 overlapping files incl: agent/conversation_loop.py,
gateway/run.py, hermes_state.py, run_agent.py, tools/*, tui_gateway/server.py,
apps/desktop/src/app/session/hooks/*.

Upstream highlights: Bot Mode builtin plugin (6935 lines), HUD surface,
multi-gateway connections registry, session list density modes, transcript
tail-page hydration, per-turn duration badges, find-bar positioning fixes,
titlebar clusters, sidebar filter/inbox/density, running≠busy, composer
settle-hold bound, session tile reclaim self-heal.
@yingliang-zhang
yingliang-zhang force-pushed the fix/slash-worker-lifecycle-comprehensive branch 2 times, most recently from ef7fe8e to f2c8b04 Compare August 18, 2026 01:05
Resolved 4 conflicts in tui_gateway/ — preserved our submitted_at/message_id
params alongside upstream's new display_kind param:
- server.py: _run_prompt_submit + _compute_host_turn_frame signatures
- compute_host.py: frame kwargs forwarding
- methods_prompt.py: _run_prompt_submit call in run_after_agent_ready
- test_tui_gateway_server.py: _run_inline mock accepts display_kind
…ration

Address review feedback on NousResearch#62492 from @jrleal10:

1. _migrate_profile_config: print an actionable per-profile warning when
   missing required settings prevent silent auto-migration (previously
   silently skipped, leaving stale profiles undiagnosed).

2. cmd_update all-profiles loop: replace logger.debug (invisible to
   users) with a visible stderr warning when per-profile migration fails,
   including the profile name and remediation command.

3. Add behavioral test coverage for both entry points:
   - TestMigrateProfileConfig: helper unit tests (4 tests)
   - TestUpdateAllProfilesMigration: update-loop coverage (2 tests)
   - TestDashboardStartupMigration: serve/dashboard startup coverage (3 tests)

Tests cover: version-bump-only silent path, missing-settings warning,
exception surfacing, up-to-date skip, multi-profile iteration, and
failure visibility.
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/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants