fix(web_server): wrap _resolve_chat_argv in run_in_executor to prevent event-loop freeze in pty_ws - #26836
Conversation
…t event-loop freeze in pty_ws `pty_ws` is an async ASGI handler. After `await ws.accept()` queues the websocket.accept message, the handler then calls `_resolve_chat_argv` which delegates to `_make_tui_argv`. `_make_tui_argv` does `shutil.which`, file stat checks, and conditionally `subprocess.run([npm, 'install'])` / `subprocess.run([npm, 'run', 'build'])` — all synchronous. Any slow path freezes the uvicorn event loop until the subprocess returns, during which the accept message can't flush to the wire. Concrete failure mode observed in the wild: behind nginx + Cloudflare, the proxy upstream timeout fires before the build subprocess returns. The proxy closes the connection. Browser sees 502 Bad Gateway. Direct curl sees 'Empty reply from server'. Nothing in the dashboard log because the event loop never had a chance to drain Python's log handler queue either. Wrapping the call in `asyncio.run_in_executor` keeps the event loop responsive even when the underlying subprocess work takes 15+ seconds. The 101 response flushes immediately, and the slow work proceeds in a thread pool. Worst case: the user sees a brief pause before chat output starts, instead of a silent 502. Also broadens the exception handling from `except SystemExit` to also catch the broad `Exception` family, logging via Python's logging module and emitting a visible error frame to the client. This is defence-in-depth against any future regression — yesterday's silent-failure story (`_hermes_ink_bundle_stale` filename drift triggering an infinite npm run build loop) would have surfaced as a clear log + error frame with this patch in place, rather than a 14-second silent timeout. Discovered alongside the proxy_headers=False fix (also being sent as a separate PR) while diagnosing chat-tab 502s at mandy.loadmagic.ai.
|
Related: Competing fixes for the same pty_ws event loop blocking issue:
All target #19914. This PR takes the run_in_executor approach + broader exception handling. |
|
Thanks @davidcampbelldc — your diagnosis here is spot-on: resolving the chat TUI argv synchronously in This overlaps #26124 (by @0xdany), which takes the same off-the-event-loop approach via Closing this in favor of #48561 to avoid two competing implementations of the same fix — but your write-up of the 502 / "Empty reply from server" failure mode behind nginx+Cloudflare was a useful confirmation of the root cause. Thank you. |
Summary
pty_wsis an async ASGI handler. Afterawait ws.accept()queues thewebsocket.acceptASGI message, the handler then calls_resolve_chat_argv(which delegates to_make_tui_argv)._make_tui_argvdoes synchronous filesystem stat checks,shutil.whichcalls, and conditionalsubprocess.run([npm, "install"])/subprocess.run([npm, "run", "build"]). Any slow path freezes the uvicorn event loop until the subprocess returns, during which the queued accept message can't flush to the wire.Concrete failure mode observed in the wild: behind nginx + Cloudflare, the proxy upstream timeout fires before the npm-build subprocess returns. The proxy closes the connection. Browser sees 502 Bad Gateway. Direct curl (via
aws ssm start-session AWS-StartPortForwardingSession, bypassing all proxies) sees "Empty reply from server" after ~14-16 seconds. Nothing logged in the dashboard log because the event loop never had a chance to drain Python's log handler queue either.Wrapping the call in
asyncio.run_in_executorkeeps the event loop responsive even when the underlying subprocess work takes 15+ seconds. The 101 response flushes immediately, and the slow work proceeds in a thread pool. Worst case: the user sees a brief pause before chat output starts, instead of a silent 502.This patch also broadens the exception handling from
except SystemExitto also catch the broadExceptionfamily, logging via Python's logging module and emitting a visible error frame to the client. This is defence-in-depth against any future regression — a recent silent-failure story (a filename-mismatch in a since-removed_hermes_ink_bundle_stalefunction triggering an infinitenpm run buildloop) would have surfaced as a clear log entry + visible error frame with this patch in place, rather than a 14-second silent timeout that took a strace bisect to diagnose.Reproduction
Hard to reproduce on current main because the recent TUI refactor (
42627b4e refactor(tui): bundle with esbuild, drop runtime node_modules) eliminated most of the blocking subprocess work in_make_tui_argv. But the structural concern remains: any future code touching_resolve_chat_argvthat adds back a sync subprocess call (e.g., a new bootstrap helper, a network-bound resolver) would silently re-introduce this class of bug.To verify the patch works under a simulated slow path:
Related
This was discovered alongside a separate fix being sent as #26834 (
proxy_headers=False). Both fixes addressed the same chat-tab-doesn't-load symptom at one deployment behind nginx + Cloudflare. After landing both, the dashboard chat tab works end-to-end through arbitrary reverse-proxy chains.Tests
The existing
tests/hermes_cli/test_web_server.pysuite uses Starlette'sTestClientwhich runspty_wssynchronously in the test event loop — so any sync subprocess call in the handler would block the test runner, not the production event loop. Adding a regression test that monkey-patches_resolve_chat_argvto sleep N seconds and asserts the response time is bounded would lock in this fix. Happy to include in a follow-up.Discovered by
Diagnosing why chat-tab
/api/ptyreturned 502 atmandy.loadmagic.aiafter a separateproxy_headers=True-induced WS 403 fix had already landed. GLM-5.1 (parallel research session) identified the sync-in-async pattern inpty_wsfrom a source dive on 2026-05-15; the specific subprocess in 0.12.0 (since refactored away) wasnpm run buildtriggered by a filename-mismatch in_hermes_ink_bundle_stale. The defence-in-depth wrap is the right systemic fix even after the specific bug is gone.🤖 Generated with Claude Code