Fix reflex run hanging after fatal error in the frontend thread
All Submissions:
- Have you followed the guidelines stated in CONTRIBUTING.md file?
- Have you checked to ensure there aren't any other open Pull Requests for the desired changed?
Type of change
- Bug fix (non-breaking change which fixes an issue)
Changes To Core Features:
- Have you added an explanation of what your changes do and why you'd like us to include them?
- Have you written new tests for your core changes, as applicable?
- Have you successfully ran tests with your changes locally?
Defect
FINDING-011 (MEDIUM) from the 0.9.9a1 pre-release testing round: reflex run hangs indefinitely after a fatal node-version error on the npm path. With an outdated node first on PATH and REFLEX_USE_NPM=1, js_runtimes.validate_frontend_dependencies correctly prints "Reflex requires node version 22.22.0 or higher..." and raises SystemExit(1) โ but that happens inside the run_frontend task submitted to a ThreadPoolExecutor by processes.run_concurrently_context. The SystemExit only kills the worker thread: the futures are checked after the with-body, and in dev mode the main thread blocks forever serving the backend (granian serve()), so the error is swallowed and the process never exits (it kept the backend serving with no frontend until killed by timeout). The same swallowing applies to any fatal error raised by an executor task consumed through this helper.
Fix
run_concurrently_context now surfaces task failures to the main thread:
- A done-callback on each future interrupts the main thread when a task fails while the with-body is executing. On POSIX this must be a real signal (
signal.pthread_kill(main_thread, SIGINT)) so a blocking C call (lock wait, server loop) returns withEINTRand runs the SIGINT handler โ_thread.interrupt_main()only sets a pending flag there and never wakes a blocked main thread. On Windowsinterrupt_main()is the right mechanism (it sets the SIGINT event that blocking waits monitor). - The delivered SIGINT either triggers the backend server's own graceful-shutdown handler (granian/uvicorn), letting the existing post-body
as_completedcheck re-raise the task's exception, or raisesKeyboardInterruptin the body, which the context manager now catches and converts back into the failed task's own exception. A genuine Ctrl+C with no failed task propagates unchanged. - A task that already failed before the body starts is re-raised up front so the main thread never enters (and blocks in) the body; only one interrupt is ever sent per context, and no interrupt is sent once the body has finished, so
run_concurrently(empty body) keeps its existing error semantics.
Test plan
- New regression test
tests/units/utils/test_processes.py::test_run_concurrently_context_unblocks_main_thread_on_task_failure: a task raisingSystemExitwhile the with-body blocks on anEvent.wait(timeout=10)must propagate promptly. Against unfixed main it fails (the body blocks the full 10s before the error surfaces โ the unit-scale version of the infinite hang); with the fix it passes in milliseconds. Two companion tests cover genuineKeyboardInterruptpassthrough andrun_concurrentlyexception propagation. - 300-iteration in-process stress of the failure path (instant and delayed task failure, blocked body, and the empty-body
run_concurrentlypath): no hang, no escapedKeyboardInterrupt. - End-to-end repro from the finding: fresh blank app, fake
nodeshim printingv22.12.0,REFLEX_USE_NPM=1 reflex runโ unfixed main runs until killed (timeoutexit 124 after 90s); with the fix the process prints the node version error, "Reflex app stopped.", and exits code 1 in ~5s. uv run ruff check .anduv run ruff format .clean;uv run pyright reflex tests0 errors;uv run pytest tests/units/utilspasses (629 passed; the only 2 failures are pre-existing on clean main in this sandbox, which lacks IPv6).
๐ค Generated with Claude Code
https://claude.ai/code/session_01EMjBXPozsNeQNSBZecNH8x
Generated by Claude Code