fix(dashboard): recycle the console executor when every worker is permanently stuck - #74356
Open
briandevans wants to merge 1 commit into
Open
Conversation
…manently stuck Dashboard console commands run on a process-global, bounded four-worker pool (added in a6b9597). asyncio.wait_for can only abandon the *await* on timeout -- Python threads aren't preemptible, so a genuinely wedged command holds its worker for the life of the process. After four such commands the pool is permanently exhausted: every later command from every session and every profile waits the full 60s and returns "Command timed out. Hermes Console returned to the prompt." until Hermes is restarted. Track which submissions actually timed out and swap in a fresh pool once none are left. Ownership is per-future and per-generation: - Only the asyncio.TimeoutError path marks a future, so a command that merely succeeded can never clear a different worker's mark and hold the pool below its recycle threshold. - Marks are keyed by executor generation, so a late completion -- or a late timeout report -- from a retired pool cannot touch the replacement's state, and cannot retire a live pool. - A future that finished while we were giving up on the await is slow, not wedged, and is not marked. - Submitting happens under the same lock that selects the pool, so a concurrent recycle can't hand back an executor that is already shut down. The atexit teardown is now bound to each concrete executor instead of reading the module global; after a recycle the global names the replacement, so the old hook would have shut down the wrong pool and left the retired one registered.
Contributor
|
Thanks for the careful follow-up to #59240. The premise is real on current main: Problems
Suggested changes
Automated hermes-sweeper review. |
Open
19 tasks
Open
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #59240 — sibling follow-up to
a6b9597d5a6b9597d5("perf(console): cache CLI-surface summaries + bound console worker pool") introduced the bounded four-worker console pool this builds on.#59240 is the only other open PR on this code and is abandoned: last commit
2026-07-05, no force-pushes and no author reply since, currentlyCONFLICTING/DIRTY, and the review landed after its last commit. This is a fresh implementation on currentmainrather than a rebase, because the review's first two findings are design-level rather than textual.What does this PR do?
Dashboard console commands run on a process-global, bounded four-worker
ThreadPoolExecutor(hermes_cli/web_server.py).asyncio.wait_forcan only abandon the await — Python threads aren't preemptible, so a genuinely wedged command holds its worker for the life of the process.After four such commands the pool is permanently exhausted. Every later console command, from every dashboard session and every profile, then queues behind threads that will never return, sits the full 60s, and returns:
...forever, until Hermes is restarted. That is an availability failure, not just a slow path: the dashboard console is dead with no user-visible indication of why and no way to recover short of a restart.
This tracks which submissions actually timed out and swaps in a fresh pool once none are left.
How the review's findings are addressed
The review on #59240 named three problems. Each maps to a specific change here:
1. "The done callback decrements the global count for every non-cancelled future, not just a future that previously timed out."
There is no counter any more. A future is recorded only on the
asyncio.TimeoutErrorpath (_note_console_command_stuck), and the done-callback (_release_console_future) only everdiscards that same future object from the set. A command that never timed out is never in the set, so it structurally cannot clear another worker's mark or hold the pool below its recycle threshold.2. "
_console_executor_stuck_counthas no executor-generation ownership; a late completion from a retired executor can decrement timeout state accumulated by the replacement."Marks live in
Dict[generation, Set[Future]]and every callback is bound to the generation that ran it. A retired generation's entry is dropped wholesale at recycle time, so both a late completion and a late timeout report from an old pool are no-ops. The generation guard on the mark path also prevents the worse case: without it, four stale timeout reports would re-trip the threshold and retire the live pool.3. "The diff adds no timeout/replacement regression test."
Four added, covering exactly the requested cases — four blocked workers, replacement, and late completion from the old pool. See How to Test.
Also fixed, because the recycle requires it
The
atexithook on main closes over the module global:Once a pool is recycled that global names the replacement, so the hook would shut down the wrong pool and leave the retired one registered. Teardown is now bound to each concrete executor via a
_new_console_executor()factory.Scope
_get_console_executor()had exactly one call site, and a sweep of the root cause (grep -rn ThreadPoolExecutorover non-test sources) found no sibling site sharing it. Deliberately excluded, with reasons:run_in_executorcalls inweb_server.pypassNone— they use the shared default loop executor, which is precisely what the dedicated console pool exists to protect. Different lifecycle, different concern.tools/async_delegation.py::_get_executoris also a persistent global pool, but it grows on demand rather than being fixed-cap, and has no per-call abandon-on-timeout, so it cannot reach this failure mode.tools/vision_tools.py::_vision_cpu_executorhas no per-command timeout that abandons a worker.Related Issue
No linked issue exists — filing this as a supersede of #59240 rather than inventing one.
Type of Change
Changes Made
hermes_cli/web_server.py_console_executor_generationand_console_stuck_futures: Dict[int, Set[Future]]beside the existing pool globals. (Named to avoid colliding with the unrelated per-connectioncommand_generationcounter inconsole_ws.)_new_console_executor()— builds a pool and registers anatexithook bound to that executor._submit_console_command()— selects the pool, submits, and attaches the generation-bound done-callback. Pool selection and submit happen under one lock so a concurrent recycle can never hand back an executor that is already shut down by the time we submit._release_console_future()— discards a future's own mark from its own generation._note_console_command_stuck()— marks a timed-out future, and atomically swaps in a fresh pool once_CONSOLE_EXECUTOR_MAX_WORKERSmarks accumulate, logging a warning and abandoning the old pool (shutdown(wait=False, cancel_futures=True))._get_console_executor();console_ws.run_commandnow submits via_submit_console_commandand awaitsasyncio.wrap_future(cf_future), so the rawconcurrent.futures.Futurestays reachable after the await is abandoned.CallableandSetto thetypingimport.tests/hermes_cli/test_web_server_console_ws.pyconsole_poolfixture (pristine pool per test, threads torn down after) and a_Wedgehelper that blocks until the test releases it.Thread-safety notes
_console_executor_lock._note_console_command_stuckskips a future that is alreadydone()— it finished while we were giving up on the await, so it is slow, not wedged, and must not count toward the threshold.Future.done()flips before done-callbacks run, and those callbacks take the same lock, so this check cannot race the release path in either direction._CONSOLE_EXECUTOR_MAX_WORKERSper live generation, dropped at recycle.How to Test
Four new deterministic tests (no real 60s timeout — they drive the pool helpers directly):
test_console_pool_recycles_only_when_every_worker_is_stucktest_completed_console_command_is_never_marked_stucktest_late_completion_from_retired_pool_leaves_replacement_untouchedtest_console_executor_atexit_hook_shuts_down_its_own_poolFails before, passes after. Reverting the production hunk alone turns all four red. Each test was also verified against a targeted mutation, so the coverage binds to the logic rather than to the new symbol names — one mutation per test, no overlap:
_release_console_futureclears marks it doesn't own (the original finding #1)..._recycles_only_when_every_worker_is_stuck..._late_completion_from_retired_pool...atexitreads the module global instead of its own pool..._atexit_hook_shuts_down_its_own_pooldone()guard removed..._completed_console_command_is_never_marked_stuckAdjacent suites run green (
627 passed, 1 skipped):test_web_server_console_ws.py,test_console_engine.py,test_web_server.py,test_web_server_boot_handshake.py,test_web_server_pty_reconnect.py,test_web_server_pty_import.py,test_verify_console_scripts.py.ruff checkis clean on both touched files.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — ran the console WS suite plus six adjacenttests/hermes_cli/web_server suites (627 passed, 1 skipped), not the full treeDocumentation & Housekeeping
docs/, docstrings) — module comments and docstrings on the touched helpers; no user-facing docs affectedcli-config.yaml.exampleif I added/changed config keys — N/A, no config keysCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Athreading/concurrent.futuresonly, no platform-specific paths or APIsContract Protected
Invariant: a console command's timeout mark is owned by exactly one
(future, executor generation)pair. Only that future's own completion may clear it, and only while its generation is live.test_console_pool_recycles_only_when_every_worker_is_stuck, which waits for the production done-callback to have actually run (via a probe callback registered afterwards, since callbacks fire in registration order) before asserting the mark survived._CONSOLE_EXECUTOR_MAX_WORKERSrather than a literal, and the tests derive their loop bounds from it too, so changing the pool size cannot silently invalidate either.