Skip to content

fix(tui_gateway): join _SlashWorker drain threads on close - #53308

Open
pasevin wants to merge 3 commits into
NousResearch:mainfrom
pasevin:fix/slash-worker-drain-thread-leak
Open

fix(tui_gateway): join _SlashWorker drain threads on close#53308
pasevin wants to merge 3 commits into
NousResearch:mainfrom
pasevin:fix/slash-worker-drain-thread-leak

Conversation

@pasevin

@pasevin pasevin commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #53303

_SlashWorker.close() terminated the subprocess and closed the pipes but never joined the _drain_stdout and _drain_stderr daemon threads. This left 2 leaked threads per dashboard/TUI session.

Root cause

_SlashWorker.__init__ (line 260-261) spawns two anonymous daemon threads that drain the subprocess stdout/stderr via for line in proc.stdout. close() (line 303) calls proc.terminate() and closes stdin/stdout/stderr, but the drain threads — blocked on readline() — are never joined.

In production, closing proc.stdout usually causes the readline() to hit EOF and the thread exits. But close() returns before that happens, so the threads are unaccounted for. In edge cases where the subprocess is mid-write, the thread can stay blocked longer.

Evidence

Live measurement (dashboard with memory provider disabled, isolating this leak from #46082):

Session   Threads   Δ    Child processes
      1       45   +2           0
      2       47   +4           0
      3       49   +6           0
      4       51   +8           0
      5       53  +10           0

2 threads leak per session, 0 subprocesses leak.

Changes

  1. Store thread references in __init__ as self._drain_thread_stdout / self._drain_thread_stderr
  2. Give threads explicit names (slash-drain-stdout, slash-drain-stderr) for py-spy/thread dump visibility
  3. Join both threads with timeout=2 in close() after closing the streams

Testing

tests/tui_gateway/test_slash_worker_drain.py::test_slash_worker_close_joins_drain_threads PASSED
tests/tui_gateway/test_slash_worker_drain.py::test_slash_worker_close_is_idempotent PASSED
tests/tui_gateway/test_slash_worker_drain.py::test_slash_worker_drain_threads_are_named PASSED

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Related Issues

…rch#53303)

_SlashWorker spawns two daemon threads (_drain_stdout, _drain_stderr)
in __init__ to drain the subprocess stdout/stderr. close() terminated
the subprocess and closed the pipes but never joined these threads,
leaving 2 leaked daemon threads per session.

The threads are blocked on readline() from proc.stdout/proc.stderr.
After proc.terminate() and stream.close(), the readline hits EOF and
the threads exit — but only if close() waits for them. Without the
join, the threads linger (and in some edge cases where the subprocess
is mid-write, they can stay blocked indefinitely).

Fix: store thread references in __init__, join(timeout=2) in close()
after closing the streams. Also give the threads explicit names
(slash-drain-stdout, slash-drain-stderr) so they're identifiable in
py-spy/thread dumps.
@alt-glitch alt-glitch added type/bug Something isn't working comp/tui Terminal UI (ui-tui/ + tui_gateway/) P3 Low — cosmetic, nice to have labels Jun 26, 2026
@harjothkhara

Copy link
Copy Markdown
Contributor

Recommendation: keep this as the #53303 lane, but update close() so the new drain-thread joins are tolerant of _SlashWorker instances constructed without __init__, then rerun the existing close regression. The current diff breaks tests/test_tui_gateway_server.py::test_slash_worker_close_reaps_zombie_and_closes_fds because that test builds the worker with object.__new__(server._SlashWorker) and only assigns proc; close() now unconditionally reads self._drain_thread_stdout / self._drain_thread_stderr and raises AttributeError after closing the pipes. I reproduced it locally with scripts/run_tests.sh tests/test_tui_gateway_server.py -- -k test_slash_worker_close_reaps_zombie_and_closes_fds. A minimal fix is to collect the threads with getattr(..., None) and join only real thread objects. The new focused tests pass locally (scripts/run_tests.sh tests/tui_gateway/test_slash_worker_drain.py -> 3/3), so this looks like a narrow compatibility/test gap rather than a problem with the main approach. CI also flags pasevin@gmail.com missing from scripts/release.py AUTHOR_MAP, which will need a mapping before the required rollup can pass. Cluster disposition: #53308 is the only exact/strongest PR for #53303; #53315 is adjacent but targets the separate #46082 Honcho/AIAgent shutdown leak, not this slash-worker drain-thread gap.

…new__ tests

Existing test test_slash_worker_close_reaps_zombie_and_closes_fds
builds _SlashWorker via object.__new__ without setting drain thread
attributes. Use getattr with None fallback so close() doesn't raise
AttributeError on workers constructed without __init__.

Reported by @harjothkhara in PR NousResearch#53308 review.
@pasevin

pasevin commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @harjothkhara — good catch. Fixed in 226f2b4: close() now uses getattr(self, '_drain_thread_stdout', None) with a None guard so it's tolerant of _SlashWorker instances constructed via object.__new__ without __init__. The existing test test_slash_worker_close_reaps_zombie_and_closes_fds now passes alongside the 3 new drain-thread tests (4/4 green locally).

On the contributor attribution check: I'll add the pasevin@gmail.com mapping to scripts/release.py AUTHOR_MAP — is there a specific format required, or should I follow the existing entries?

@pasevin

pasevin commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

Note on CI failures: the Build&Test Docker image / build-arm64 failure is an infra issue — the same Docker build fails on main (both amd64 and arm64) with GHCR credential errors (denied: installation not allowed to Write organization package / Username and password required). All Python tests and lints pass on this PR.

@pasevin
pasevin force-pushed the fix/slash-worker-drain-thread-leak branch from c87ef8e to 8433680 Compare June 27, 2026 19:59
pasevin added a commit to pasevin/hermes-agent that referenced this pull request Jun 27, 2026
yingliang-zhang added a commit to yingliang-zhang/hermes-agent that referenced this pull request Jul 3, 2026
…rch#53303, by @pasevin)

_SlashWorker.close() terminated the subprocess and closed the pipes but
never joined the _drain_stdout and _drain_stderr daemon threads. This left
2 leaked daemon threads per dashboard/TUI session (NousResearch#53303).

Changes:
- Store thread references in __init__ as _drain_thread_stdout/_drain_thread_stderr
- Give threads explicit names (slash-drain-stdout, slash-drain-stderr) for py-spy visibility
- Join both threads with timeout=2 in close() after closing the streams
- Use getattr() in close() for compat with object.__new__ tests

Original PR: NousResearch#53308 by @pasevin (pasevin@gmail.com)
Squashed for cherry-pick tracking.

@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 slash-worker cleanup path. The underlying defect is still present on current main: tui_gateway/server.py:326-327 starts unreferenced drain threads, while tui_gateway/server.py:369-396 closes streams without joining them.

Problems

  • tests/tui_gateway/test_slash_worker_drain.py:63 sets exit_event before starting the threads, and line 78 waits for them to finish before calling close(). The final dead-thread assertions therefore also pass on current main, which does not join either thread. This does not regress the behavior the PR is intended to protect.

Suggested changes

  • Make the test observe join(timeout=2) on both stored thread objects, or keep controlled drain threads alive until close() triggers their release. Preserve the getattr compatibility path noted by the existing object.__new__ regression at tests/test_tui_gateway_server.py:8252-8261.

Automated hermes-sweeper review.


# Use threads that exit quickly (simulating EOF on the pipe)
exit_event = threading.Event()
exit_event.set() # let them exit immediately

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.

Because this event is set before either thread starts, both drain threads have already exited before close() at line 81. The test therefore passes on current main even though current close() never calls join(). Please assert join(timeout=2) directly or keep a controlled thread alive until close().

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tui Terminal UI (ui-tui/ + tui_gateway/) P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: _SlashWorker.close() does not join drain threads — 2 leaked threads per dashboard session

4 participants